"""M02 불러오기 재료 표기 다름 찾기(PLAN 817-1 · 요구 773-5) — `M02_Hopyo_Row._m01_picks` 재료 길이 부름. 서버 단독 · 읽기만 · 별칭 · 정확 일치 · 바탕 이름 유일 짝(`M02_Hopyo_Material.sure`)이 못 찾은 줄만. - `same(줄)` ㉮ 이음 = 이름이 같은 품목(표기만 다름) · 규격 수 묶음 같음 · 규격 낱말 안 부딪힘 · 그런 우리 품목 하나(지역 · 관급/사급 줄은 한 품목) → 그 자재 줄 · 아니면 None. - `narrow(줄)` ㉯ 후보 좁힘 = 이름 같은 품목 묶음을 규격 낱말(어근 「초본류」 → 초본)을 품은 줄 · 수 묶음 닮은 줄 · 덧말 적은 줄 차례로(자동 이음 아님 · 1 순위 맨 위). - `spec_first(후보, 줄)` = 후보 가운데 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저. 이름 같음: 바탕(괄호 · 지역 · 관급/사급 꼬리 · 등급 「1급」 뗌 · 빈칸 · 점 「P.V.C」 · 소리 표기 `SPELL`) 같음 · 괄호 차례 「패널(유로폼)」 = 「유로폼패널」(글자 묶음 같음) · 괄호 이름 「서비스볼트(고장력볼트)」 = 고장력볼트 · (약한 같음 — 수 묶음이 같을 때만) 끝 낱말 같음 「지수판」 ↔ 「PVC지수판」 · 앞 낱말 「구역화물자동차」 ↔ 구역화물 · 규격에 든 이름 「안고초」 ↔ 산림종자 안고초(가져온 규격 빔). 수 묶음: 규격 + 이름 괄호의 수(차례 무관) · KS 번호 · 무게(kg · ㎏/포 · m/kg) · 제곱 꼬리(㎡ · ㎠) 뗌 · 천 단위 쉼표 · 같음 > 낱마다 단위 배(1 · 10 · 100 · 1000 — 3.6×3.0×3.0 m = 30×30×3600 mm) > 한쪽이 다른 쪽에 듦 (이름 같을 때만 · 덧글 「2000 길이 · 20~30mm」). 규격 낱말 부딪힘 = 두 쪽 다 괄호 · 규격 한글 낱말이 있고 두 글자 묶음이 하나도 안 겹침(「연강용」 ↔ 「주철용」) — 상대 이름에 든 낱말은 뺌. """ from __future__ import annotations import re import unicodedata from functools import lru_cache from itertools import permutations from typing import Any from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat SPELL = ( (r"칠렌", "틸렌"), (r"매쉬", "메쉬"), (r"신나(?![가-힣])", "신너"), (r"pipe", "파이프"), ) # 소리 · 영문 표기 NOISE = frozenset({"이내", "이하", "이상", "국산", "포함"}) SCALES = (1, 10, 100, 1000) _STEM = re.compile(r"(?<=..)[류형용]$") _KS = re.compile(r"ks\s*[a-z]?\s*-?\s*\d+") _SQUARE = re.compile(r"(?:c|m)?m[23](?![\d.])") _THOUSAND = re.compile(r"(?<=\d),(?=\d{3}(?!\d))") _NUM = re.compile(r"\d+(?:\.\d+)?") _PAREN = re.compile(r"\(([^()]*)\)") def _low(text: Any) -> str: t = unicodedata.normalize("NFKC", str(text or "")).lower().replace("m/m", "mm") return hmat._APART.sub("", t) def _tidy(text: str) -> str: t = re.sub(r"(?<=[a-z])[.\s]+(?=[a-z])", "", text) for a, b in SPELL: t = re.sub(a, b, t) t = re.sub(r"\d+급", "", t) return re.sub(r"[^가-힣a-z0-9]", "", t) def _bag(text: str) -> tuple[float, ...]: t = _THOUSAND.sub("", _SQUARE.sub("", _KS.sub("", text))) return tuple(sorted(float(x) for x in _NUM.findall(t))) def _quals(text: str) -> frozenset[str]: words = (w for w in re.findall(r"[가-힣]{2,}", text) if not hmat._region(w)) return frozenset(w for w in words if w not in hmat.SUPPLY and w not in NOISE) def _item(name: Any, spec: Any) -> dict[str, Any]: """이름 · 규격 → 바탕 · 괄호 조각 · 수 묶음 · 규격 낱말 · 온 이름 글.""" low, spec = _low(name), _low(spec) parens = _PAREN.findall(low) pieces = tuple(p for p in (_tidy(x) for x in parens for x in x.split(",")) if p) core = _tidy(hmat._bare(low)) return { "core": core, "pieces": pieces, "chars": "".join(sorted(core + "".join(pieces))), "bag": _bag(f"{spec} {' '.join(parens)}"), "quals": _quals(f"{' '.join(pieces)} {spec}"), "whole": core + "".join(pieces), "spec": _tidy(spec), } @lru_cache(maxsize=2) def _book(_got: int) -> dict[str, Any]: rows, _ = hmat.pool() out: list[dict[str, Any]] = [] by: dict[str, dict[str, list[int]]] = { "core": {}, "chars": {}, "piece": {}, "tail": {}, "spec": {}, } for at, r in enumerate(rows): one = {**_item(r["one"]["이름"], r["one"]["규격"]), "row": r} out.append(one) c = one["core"] by["core"].setdefault(c, []).append(at) by["chars"].setdefault(one["chars"], []).append(at) by["spec"].setdefault(one["spec"], []).append(at) for p in one["pieces"]: by["piece"].setdefault(p, []).append(at) for i in range(1, len(c) - 1): # 끝 낱말(두 글자 이상) by["tail"].setdefault(c[i:], []).append(at) return {"줄": out, "찾기": by} def book() -> dict[str, Any]: return _book(id(hm.targets())) def _related(me: dict[str, Any]) -> dict[int, bool]: """이름 같은 우리 줄 → {차례: 굳셈(True) · 약함(False)}.""" by, c = book()["찾기"], me["core"] strong = [*by["core"].get(c, []), *by["chars"].get(me["chars"], []), *by["piece"].get(c, [])] if ( c not in by["core"] ): # 괄호 이름(「서비스볼트(고장력볼트)」) — 바탕 이름이 우리 표에 없을 때만 strong += [i for p in me["pieces"] for i in by["core"].get(p, [])] weak = ( [*by["tail"].get(c, [])] if len(c) >= 2 else [] ) # 우리 이름 끝 = 가져온 이름(「PVC지수판」) for k in range( 2, len(c) - 1 ): # 우리 이름 = 가져온 이름 앞(「구역화물자동차」) · 뒤 덧말이 우리 낱말 아님 if c[k:] not in by["core"] and c[k:] not in by["tail"]: weak += by["core"].get(c[:k], []) out = dict.fromkeys(weak, False) out.update(dict.fromkeys(strong, True)) return out def _bigrams(words: frozenset[str]) -> set[str]: return {w[i : i + 2] for w in words for i in range(len(w) - 1)} def _qual_fit(me: dict[str, Any], x: dict[str, Any]) -> tuple[bool, bool, bool]: """(부딪힘, 겹침, 낱말 있음) — 상대 온 이름에 든 낱말은 뺌.""" a = frozenset(w for w in me["quals"] if _tidy(w) not in x["whole"]) b = frozenset(w for w in x["quals"] if _tidy(w) not in me["whole"]) share = bool(_bigrams(a) & _bigrams(b)) return bool(a and b and not share), share, bool(a or b) def _near(x: float, y: float) -> bool: return any(abs(x * s - y) <= 1e-6 * max(abs(y), 1) or abs(y * s - x) <= 1e-6 * max(abs(x), 1) for s in SCALES) # fmt: skip def num_fit(a: tuple[float, ...], b: tuple[float, ...]) -> tuple[int, int]: """(갈래 3 같음 · 2 단위 배 · 1 한쪽이 듦 · 0 다름, 맞은 수 개수).""" if a == b: return 3, len(a) if a and len(a) == len(b) and len(a) <= 6: if any(all(_near(x, y) for x, y in zip(a, p)) for p in permutations(b)): return 2, len(a) small, big = sorted((list(a), list(b)), key=len) if small and len(small) < len(big): rest = list(big) for x in small: if x not in rest: return 0, 0 rest.remove(x) return 1, len(small) return 0, 0 def _fold(x: dict[str, Any]) -> tuple: r = x["row"] return r["base"], r["key"][0], r["key"][2] def _scored(line: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[tuple, dict[str, Any]]]]: me = _item(line.get("이름"), line.get("규격")) rows = book()["줄"] out = [] for at, strong in _related(me).items(): x = rows[at] clash, share, words = _qual_fit(me, x) if not clash: out.append( ((strong, share, *num_fit(me["bag"], x["bag"]), words, x["core"] == me["core"]), x) ) return me, out def _pick(group: list[dict[str, Any]], line: dict[str, Any]) -> dict[str, Any]: """한 품목의 변형 줄들 → 하나(줄 관급/사급 › 줄 지역 › 서울 › 같은 단위 › 엑셀 차례 — `M02_Elem_Alias.one` 과 같은 차례).""" supply, regions = hmat._wants(line) unit = hm.mm.plain_unit(line.get("단위")) def rank(r: dict[str, Any]) -> tuple: return ( supply is not None and r["supply"] not in (None, supply), bool(regions) and not hmat._hit(r, regions), "서울" not in r["region"], r["key"][1] != unit, r["at"], ) return min((x["row"] for x in group), key=rank)["one"] def same(line: dict[str, Any]) -> dict[str, Any] | None: """㉮ 표기만 다름 — 우리 품목 하나면 그 자재 줄(`hm.targets` 꼴) · 아니면 None.""" me, scored = _scored(line) if not me["spec"]: # 가져온 규격 빔 = 규격에 든 이름(「안고초」)만 · 그 밖은 ㉯ hits = [book()["줄"][i] for i in book()["찾기"]["spec"].get(me["whole"], [])] hits = [x for x in hits if not x["bag"] and not me["bag"]] return _pick(hits, line) if hits and len({_fold(x) for x in hits}) == 1 else None ok = [] for (strong, share, tier, common, words, same_core), x in scored: if not tier or (tier == 1 and not strong): continue if not me["bag"] and not x["bag"] and not share and words: continue # 수 없는 규격 = 규격 낱말이 겹쳐야 ok.append(((tier, common, share, strong, same_core), x)) if not ok: return None best = max(k for k, _ in ok) group = [x for k, x in ok if k == best] return _pick(group, line) if len({_fold(x) for x in group}) == 1 else None def _stems(me: dict[str, Any]) -> set[str]: return {_STEM.sub("", w) for w in me["quals"]} def narrow(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]: """㉯ 이름 같은 품목 묶음(굳센 같음) → 규격 낱말 어근 품은 줄 › 수 묶음 닮음 › 덧말 적음 차례(품목마다 하나).""" me, scored = _scored(line) stems = _stems(me) rows = [(k, x) for k, x in scored if k[0]] rank = [] for (_strong, _share, tier, common, _words, same_core), x in rows: text = x["whole"] + x["spec"] hit = sum(s in text for s in stems) rank.append(((-hit, -tier, -common, not same_core, len(x["spec"]), x["row"]["at"]), x)) seen, out = set(), [] for _k, x in sorted(rank, key=lambda t: t[0]): if (f := _fold(x)) not in seen: seen.add(f) out.append(_pick([y for _, y in rank if _fold(y) == f], line)) return out[:limit] def spec_first(cands: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]: """후보 차례 — 규격 수 묶음(둘 이상)이 줄과 같은 것 먼저(「중하중그레이팅 395×995×52」).""" mine = _item(line.get("이름"), line.get("규격"))["bag"] if len(mine) < 2: return cands return sorted(cands, key=lambda c: _item(c.get("이름"), c.get("규격"))["bag"] != mine)