"""불러오기 재료 찾기 판정 · 단서 · 자동 규칙(PLAN 827-2 · 요구 810 ~ 812 · 조사 810-2 · 811-2). `M02_San_Link.m01` 재료 줄이 부름 · 서버 단독 · 읽기만 · 판정은 이름 · 규격 · 단위 · 단서 글만(단가 값 안 봄) · 별칭을 만들거나 제안하지 않음(별칭 = 사람이 표기 변형을 잇는 칸 · 요구 812-1). 줄 판정 `자원["재료찾기"]` = {판정, 방법, 키, 후보, 단서}: - 「이어짐」 — 방법 = 코드 · 별칭 · 정확(정확 일치 · 바탕 이름 규격 맞음) · 표기(817-1 ㉮ · 후보 하나) · 「규칙 A」 ~ 「규칙 F2」 · 「단서」 - 「규격 없음 — 고르기」 — 가져온 규격 빔 · 별도계상 · 바탕 이름 같은 후보 있음 - 「후보 여럿」 — 규격은 있는데 하나로 못 정함 - 「우리 표에 없음」 — 바탕 이름 같은 후보 없음 · 규격 수가 하나도 안 닮음(후보 = 닮은 줄 · 참고) 후보 = [{키, 이름, 규격, 단위, 단서?}] 차례 = 단서로 좁힌 것 › 이름 같은 묶음 › 옛 후보. 별도계상 줄(요구 811-2 — 품목은 정해짐 · 셈만 프로젝트에서) = 같은 찾기 · 키는 판정에만(줄 이음 · 단가 0 그대로). 단서(걸음 1 · 규격 빈 줄 · 별도계상 줄만 좁힘) = 산근 줄 설명 글(「철물(앵커용)」) · 산근 머리(Φ800mm) · 같은 파일 STC 내역서 자재 직접 줄 · STmate 엑셀 일위대가표 자재 줄(같은 코드 · 대가 머리 · 이름 같은 줄) — 하나로 정해지면 이음 · 여럿이면 후보 차례만 · 관급형 · 일반형처럼 단서로 안 갈리면 후보로 둠. 자동 규칙(걸음 2 · 조사 810-2 5 절 · G 이름 + 단위만 = 안 씀): A 가져온 규격 빔 + 바탕 이름 같은 우리 품목 하나(괄호 낱말 부딪힘 · 덧말 든 우리 줄 뺌) · B 이름 같은 우리 품목이 하나뿐 + 그 규격 빔 · C 규격 낱말이 우리 규격 낱말에 듦 + 단위 같음 · E 수 묶음(둘 이상) 같음 + 이름 끝 세 글자 이상 같음(이름이 같은 우리 줄 없을 때) + 단위 맞음 · F 이름 괄호 풀어 붙임 글자 같음 + 규격 빔 + 단위 같음 · F2 이름 + 한글 규격 글자 = 우리 이름 글자 + 단위 같음. """ from __future__ import annotations import re from contextlib import contextmanager from contextvars import ContextVar from functools import lru_cache from typing import Any from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_MatSame as ms LINKED, NO_SPEC, MANY, NONE = "이어짐", "규격 없음 — 고르기", "후보 여럿", "우리 표에 없음" TOP = 8 _CTX: ContextVar[dict] = ContextVar("재료단서", default={}) _B = re.compile(r"B\d+") _LEAD = re.compile(r"^[\s\-·\d.)]+") _RANGE = re.compile(r"[A-Za-z]*\d+(?:\.\d+)?\s*[~∼]\s*[A-Za-z]*\d+(?:\.\d+)?") # ── 단서 읽기(걸음 1) ───────────────────────────────────────────────────── def from_boq(rows: Any) -> list[dict]: """STC 내역서(`M02_San_Stc.read` 「내역」) → 자재 직접 줄 단서.""" out = [ { "곳": "내역서", "코드": str(r.get("코드") or ""), "이름": r.get("이름"), "규격": r.get("규격"), } for r in rows or [] if str(r.get("코드") or "").startswith("M") and r.get("이름") ] return _uniq(out) def from_book(rows: Any) -> list[dict]: """STmate 엑셀 「일위대가표」 시트 줄 → 자재 줄 단서(숨은 메타 COD M… · 대가 머리 = 이름 · 규격).""" from M02_MasterTemplete import M02_San_Book as book out, head = [], "" for r in rows or []: r = list(r) + [None] * 16 if _B.fullmatch(str(r[14] or "").strip()): head = " ".join(str(x or "").strip() for x in r[:2]).strip() continue code = book.meta(r[13]).get("COD", "") if code.startswith("M") and r[0]: out.append( {"곳": "일위대가표", "코드": code, "이름": str(r[0]), "규격": str(r[1] or "")} ) out[-1] |= {"머리": head} if head else {} return _uniq(out) def _uniq(rows: list[dict]) -> list[dict]: seen, out = set(), [] for r in rows: k = tuple(sorted((k, str(v)) for k, v in r.items())) if k not in seen: seen.add(k) out.append(r) return out @contextmanager def clues_on(rows: Any, head: Any = ""): """블록 하나 옮기는 동안 파일 단서 · 산근 머리 글(`M02_Doc_FromNative._emit`).""" token = _CTX.set({"줄": tuple(map(_frozen, rows or [])), "머리": str(head or "").strip()}) try: yield finally: _CTX.reset(token) def _frozen(r: dict) -> tuple: return tuple(sorted((k, str(v or "")) for k, v in r.items())) def _texts(me: dict, code: str, label: str) -> tuple[tuple[str, str], ...]: """줄 단서 글 [(곳, 글)] — 설명 글(이름이 같은 바탕일 때) · 산근 머리 · 같은 코드 · 같은 이름 파일 줄.""" ctx, out = _CTX.get(), [] label = _LEAD.sub("", label or "") # 「5) 철물(앵커용)」 · 「-안전표지판(…)」 mine = ms._item(label, "") if label and mine["core"] == me["core"] and mine["whole"] != me["whole"]: out.append(("설명 글", label)) if ctx.get("머리"): out.append(("산근 머리", ctx["머리"])) for one in ctx.get("줄") or (): r = dict(one) same = code and r.get("코드") == code if same or ms._item(r.get("이름"), "")["core"].startswith( me["core"] ): # 「파형강관(2.7T)-영월」 out.append((r["곳"], f"{r.get('이름')} {r.get('규격')}".strip())) out += [(f"{r['곳']} 머리", r["머리"])] if same and r.get("머리") else [] return tuple(dict.fromkeys(out)) # ── 판정 ───────────────────────────────────────────────────────────────── def find( line: dict, src: dict | None = None, keys: Any = (), label: str = "", apart: bool = False ) -> dict: """재료 줄(별도계상 글 뗀) → 판정(`src` 있으면 `자원["재료찾기"]` 에 둠) · keys = 지금 엔진 찾기 키들.""" text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위")) code = str((src or {}).get("코드") or "") me = ms._item(text[0], text[1]) keys = tuple(sorted(k for k in keys if k)) got = dict(_find(*text, keys, _texts(me, code, label), apart, id(hm.targets()))) got |= {"후보": [dict(x) for x in got["후보"]], "단서": list(got["단서"])} if src is not None: src["재료찾기"] = got return got def done(key: str, how: str, src: dict | None = None) -> dict: """이미 정한 이음(코드 길) 판정.""" got = {"판정": LINKED, "방법": how, "키": key, "후보": [], "단서": []} if src is not None: src["재료찾기"] = got return got @lru_cache(maxsize=4096) def _find( name: str, spec: str, unit: str, keys: tuple, texts: tuple, apart: bool, _got: int ) -> dict: line = {"이름": name, "규격": spec, "단위": unit} me = ms._item(name, spec) if len(keys) == 1 and (how := _how(line, me, keys[0])): return _out(LINKED, how, keys[0]) for rule, fn in RULES.items(): if key := fn(line, me): return _out(LINKED, f"규칙 {rule}", key) pool = _same_name(line, me) narrowed, used = _narrow(pool, texts) if not me["spec"] or apart else (pool, []) if used and len(_folds(narrowed)) == 1: return _out(LINKED, "단서", ms._pick(narrowed, line).get("자재"), [], used) cands = _cands(line, narrowed, used) twin = len( me["bag"] ) > 1 and any( # 이름은 달라도 규격 수 묶음이 같은 후보(그레이팅 395×995×52) ms._item(c["이름"], c["규격"])["bag"] == me["bag"] for c in map(dict, cands) ) if not twin and not any(_alike(me, x) for x in pool): verdict = NONE else: verdict = NO_SPEC if apart or not spec.strip() else MANY return _out(verdict, "", None, cands, used) def _alike(me: dict, x: dict) -> bool: """이름 같은 우리 줄이 이 물건일 수 있음 — 우리 규격 빔 · 가져온 수 없음 · 수 하나라도 같음(BUTTON BIT Ø38 ↔ 105mm = 아님).""" return ( not x["spec"] or not me["bag"] or any(ms._near(a, b) for a in me["bag"] for b in x["bag"]) ) def _out(verdict: str, how: str, key: Any, cands: Any = (), used: Any = ()) -> dict: return {"판정": verdict, "방법": how, "키": key, "후보": tuple(cands), "단서": tuple(used)} def _how(line: dict, me: dict, key: str) -> str | None: """지금 엔진이 정한 키의 길 — 별칭 · 정확 · 표기 · 후보 하나인데 규격 수가 부딪힘 = None(못 이음).""" from M02_MasterTemplete import M02_Elem_Alias as ea if ea.keys(ea.MARKET, line["이름"], line["규격"]): return "별칭" if ea.exact(line["이름"], line["규격"]) or hmat.sure(line)[0]: return "정확" x = _row(key) if ms.same(line) or x is None or not me["bag"] or not x["bag"]: return "표기" return "표기" if ms.num_fit(me["bag"], x["bag"])[1] else None # 드릴롯드 T38×R32 ↔ AX 1.5 # ── 우리 표 색인 ──────────────────────────────────────────────────────── @lru_cache(maxsize=2) def _index(_got: int) -> dict: rows = ms.book()["줄"] out: dict[str, dict] = {"키": {}, "bag": {}, "whole": {}} for at, x in enumerate(rows): out["키"].setdefault(x["row"]["one"].get("자재"), at) if len(x["bag"]) >= 2: out["bag"].setdefault(x["bag"], []).append(at) out["whole"].setdefault(x["whole"], []).append(at) return out def _ix() -> dict: return _index(id(hm.targets())) def _row(key: Any) -> dict | None: at = _ix()["키"].get(key) return None if at is None else ms.book()["줄"][at] def _rows(ats: Any) -> list[dict]: rows = ms.book()["줄"] return [rows[i] for i in dict.fromkeys(ats)] def _unit_ok(line: dict, x: dict) -> bool: return not line.get("단위") or hm.fits(line["단위"], x["row"]["one"]["단위"]) def _unit_same(line: dict, x: dict) -> bool: return bool(line.get("단위")) and hm.mm.plain_unit(line["단위"]) == x["row"]["key"][1] def _folds(xs: list[dict]) -> set: """품목 갈래 — 지역 · 관급/사급 변형은 한 품목 · 이름 괄호 수도 가름(「치즐(0.2㎥)」 ≠ 「치즐(0.7㎥)」).""" return {(*ms._fold(x), x["bag"]) for x in xs} def _one(xs: list[dict], line: dict) -> str | None: return ms._pick(xs, line).get("자재") if xs and len(_folds(xs)) == 1 else None def _clean(me: dict, xs: list[dict]) -> list[dict]: """괄호 · 규격 낱말 부딪힘 뺌(「각재(외송)」 ↔ 「각재(나왕)」).""" return [x for x in xs if not ms._qual_fit(me, x)[0]] def _same_name(line: dict, me: dict) -> list[dict]: xs = _rows( at for at, strong in ms._related(me).items() if strong ) # 817-1 굳센 같음(바탕 · 글자 · 괄호 이름) return [x for x in _clean(me, xs) if _unit_ok(line, x)] # ── 자동 규칙(걸음 2) ─────────────────────────────────────────────────── def _has(x: dict, words: frozenset) -> bool: return all(ms._tidy(w) in x["whole"] + x["spec"] for w in words) def _rule_a(line: dict, me: dict) -> str | None: if me["spec"] or me["bag"]: return None xs = [x for x in _same_name(line, me) if x["core"] == me["core"] and _has(x, me["quals"])] return _one([x for x in xs if not x["quals"] - me["quals"]], line) if xs else None def _rule_b(line: dict, me: dict) -> str | None: xs = _rows( ms.book()["찾기"]["core"].get(me["core"], []) ) # 이름 같은 우리 품목이 하나뿐(단위 무관) if not xs or len(_folds(xs)) != 1 or xs[0]["spec"]: return None return _one([x for x in _clean(me, xs) if _unit_ok(line, x)], line) def _rule_c(line: dict, me: dict) -> str | None: if me["bag"] or not me["quals"]: return None xs = [x for x in _same_name(line, me) if x["core"] == me["core"]] return _one([x for x in xs if me["quals"] <= x["quals"] and _unit_same(line, x)], line) def _tail(a: str, b: str) -> int: n = 0 while n < min(len(a), len(b)) and a[-1 - n] == b[-1 - n]: n += 1 return n def _rule_e(line: dict, me: dict) -> str | None: if len(me["bag"]) < 2: return None xs = _rows(_ix()["bag"].get(me["bag"], [])) xs = [x for x in xs if x["core"] != me["core"] and _tail(me["core"], x["core"]) >= 3] if any(x["core"] == me["core"] for x in _rows(ms.book()["찾기"]["core"].get(me["core"], []))): return None # 이름이 같은 우리 줄이 있으면 E 아님(PC강연선 Unbonded · 고정핀 — 이미 이은 줄 시험) xs = [x for x in xs if _unit_ok(line, x)] return _one(_clean(me, xs), line) def _rule_f(line: dict, me: dict) -> str | None: if me["spec"] or not me["pieces"]: return None by = ms.book()["찾기"] xs = _rows([*_ix()["whole"].get(me["whole"], []), *by["chars"].get(me["chars"], [])]) return _one([x for x in xs if _unit_same(line, x)], line) def _rule_f2(line: dict, me: dict) -> str | None: if not me["spec"] or me["bag"] or re.search(r"\d", line["규격"]): return None chars = "".join(sorted(me["core"] + "".join(me["pieces"]) + me["spec"])) xs = _rows(ms.book()["찾기"]["chars"].get(chars, [])) return _one([x for x in xs if _unit_same(line, x)], line) RULES = {"E": _rule_e, "C": _rule_c, "A": _rule_a, "B": _rule_b, "F": _rule_f, "F2": _rule_f2} # ── 단서로 좁힘 · 후보 ───────────────────────────────────────────────── def _narrow(xs: list[dict], texts: tuple) -> tuple[list[dict], list[str]]: """단서 글의 수(같은 수) · 낱말 어근마다 그걸 품은 줄만 — 남는 줄이 있고 줄어들 때만 · 관급 · 사급을 가르는 걸음은 안 씀(사람 몫) → (남은 줄, 쓴 단서).""" used = [] for where, text in texts: item = ms._item("", _RANGE.sub(" ", text)) # 범위(「D13~D32」)는 규격 하나가 아님 tests = [lambda x, n=n: n in x["bag"] for n in item["bag"]] stems = {ms._STEM.sub("", w) for w in item["quals"]} tests += [lambda x, s=s: s in x["whole"] + x["spec"] and s not in x["core"] for s in stems] for test in tests: keep = [x for x in xs if test(x)] supply = {x["row"]["supply"] for x in keep} == {x["row"]["supply"] for x in xs} if keep and supply and len(_folds(keep)) < len(_folds(xs)): xs = keep used.append(f"{where} — {text}") elif ( keep and len(keep) == len(xs) and len(_folds(xs)) == 1 ): # 하나뿐인 줄의 덧말 · 수를 단서가 댐 used.append(f"{where} — {text}") return xs, list(dict.fromkeys(used)) def _cands(line: dict, narrowed: list[dict], used: list) -> list[tuple]: from M02_MasterTemplete import M02_Hopyo_Row as hrow out: dict[str, tuple] = {} for x in narrowed if used else []: out.setdefault(x["row"]["one"].get("자재"), _pub(x["row"]["one"], "단서")) for p in hrow.m01_picks("재료", line): out.setdefault(p.get("키"), _pub({**p, "자재": p.get("키")}, "")) for x in ms.narrow(line, TOP): out.setdefault(x.get("자재"), _pub(x, "")) return [v for k, v in out.items() if k][:TOP] def _pub(one: dict, why: str) -> tuple: got = {"키": one.get("자재"), **{k: str(one.get(k) or "") for k in ("이름", "규격", "단위")}} return tuple((got | ({"단서": why} if why else {})).items())