diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py index 3180dc8ce..fc316068f 100644 --- a/M02_MasterTemplete/M02_Doc_FromNative.py +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -82,6 +82,12 @@ def pack(got: dict, name: str) -> dict[str, Any]: for code, p in (got.get("단가") or {}).items() } out["끝전"] = RULE.get(str((got.get("옵션") or {}).get("SAN_TYP")), "비목버림") + from M02_MasterTemplete import M02_San_MatFind as mf + + if clues := got.get("단서") or mf.from_boq( + got.get("내역") + ): # 827-2 재료 단서(내역서 · 일위대가표) + out["단서"] = clues return out @@ -130,7 +136,10 @@ def _emit(saved: dict, no: int, keys: bool) -> dict[str, Any]: head["F"] = saved_amt.get("S") block = {"코드": blk["코드"], "이름": blk.get("이름"), "머리": head} block |= {"몸": blk.get("몸") or [], "꼬리": blk.get("꼬리") or []} # 엑셀 = 줄 금액 · 계 꼬리 - out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"]) + from M02_MasterTemplete import M02_San_MatFind as mf + + with mf.clues_on(saved.get("단서"), f"{blk.get('이름') or ''} {blk.get('규격') or ''}"): + out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"]) marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지")) sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"]) out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}} diff --git a/M02_MasterTemplete/M02_Hopyo_Material.py b/M02_MasterTemplete/M02_Hopyo_Material.py index 4e3d3d7e8..5b1766fcd 100644 --- a/M02_MasterTemplete/M02_Hopyo_Material.py +++ b/M02_MasterTemplete/M02_Hopyo_Material.py @@ -181,7 +181,14 @@ def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float: return 1.0 if inside else 0.0 if not mine or not row["nums"]: return 0.0 - return sum(any(_near(x, y) for y in row["nums"]) for x in mine) / len(mine) + mine, rest, hit = list(dict.fromkeys(mine)), list(row["nums"]), 0 + for x in ( + mine + ): # 827-2 자재 수 하나는 한 번만(「d=320~400 t=3.2」 의 320 · 3.2 가 둘 다 3.2 로 맞던 것) + if (j := next((i for i, y in enumerate(rest) if _near(x, y)), None)) is not None: + hit += 1 + rest.pop(j) + return hit / len(mine) def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]: diff --git a/M02_MasterTemplete/M02_San_Book.py b/M02_MasterTemplete/M02_San_Book.py index 148f659f2..e11fe2f4c 100644 --- a/M02_MasterTemplete/M02_San_Book.py +++ b/M02_MasterTemplete/M02_San_Book.py @@ -22,6 +22,7 @@ STM_SHEETS = ( "경비목록표", "중기목록표", "중기사용료", # 821-4 기계 구성(못 이은 기계 → 내 기계 값) + "일위대가표", # 827-2 재료 단서(자재 줄 · 대가 머리 — `M02_San_MatFind.from_book`) ) EST_SHEETS = ( "단가산출총괄표", diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index 9a7441489..0b5e8c3b8 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -420,7 +420,7 @@ class _Stm: res["찾기"], link, many = parts[0], parts[0]["키"], None res |= {"조합첫": True} if len(parts) > 1 else {} else: - link, many = _m01(res["갈래"], res) if self.keys else (None, None) + link, many = _m01(res["갈래"], res, _label(node)) if self.keys else (None, None) one = first_spec(resource(o.names.item(name), pr.get("단위"), cf, res, link), parts) if parts and one["종류"] == "로직": one["이음"] = combo_link(one, parts) or hourly_text(link) diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py index 747d0115c..1edc8d0ed 100644 --- a/M02_MasterTemplete/M02_San_Link.py +++ b/M02_MasterTemplete/M02_San_Link.py @@ -10,12 +10,15 @@ NOTE = "참고 · " # `M02_San_Emit.NOTE` 와 같음(사람 확인 표지 머 OIL_BASE = ("전국평균", "일반용(갑)Ⅰ 저압") # 유가 · 전력 기본(요구 748 · 우리 기초 기본값) QUOTE = "견적 — 그대로(이름 · 값) · [단가 갱신] 대상 아님" # 815-1 K5 · 요구 762-3 APART = "별도계상 · 품목은 프로젝트에서" # 817-1 — 일위대가 길(`M02_CheckApart`)과 같은 글 · 요소 잇기 없음 +APART_ITEM = "별도계상 · 품목 이음 · 셈은 프로젝트에서" # 827-2 — 품목은 판정(`재료찾기`)에만 · 줄 이음 · 단가 0 그대로 -def m01(kind: str, src: dict) -> tuple[str | None, str | None]: +def m01(kind: str, src: dict, label: str = "") -> tuple[str | None, str | None]: """1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 · 환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면 - 유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님).""" + 유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님). + 재료 = 판정 `src["재료찾기"]`(827-2 `M02_San_MatFind` · label = 산근 줄 설명 글) · 별도계상 줄도 품목을 찾되 + 이음은 비움(셈 제외 · 단가 0 그대로).""" if kind == "고정": return quote(src) if kind not in ("인력", "재료", "기계"): @@ -23,12 +26,13 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: from M02_MasterTemplete import M02_Elem_Alias as ea from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Row as hrow + from M02_MasterTemplete import M02_San_MatFind as mf line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} - if kind == "재료" and apart( - line - ): # 817-1 — 원가 합 밖(원본 금액 그대로) · 품목은 프로젝트에서 이음 - return None, f"{NOTE}{APART}" + if kind == "재료" and apart(line): # 817-1 원가 합 밖(원본 금액 그대로) · 827-2 품목은 판정에 + clean = hmat_clean(line) + got = mf.find(clean, src, _keys(clean, hrow.m01_picks("재료", clean)), label, apart=True) + return None, f"{NOTE}{APART_ITEM if got['키'] else APART}" if ( kind == "기계" ): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음) @@ -39,7 +43,8 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: src.setdefault("찾기", {**got, "이름": got.get("이름") or line["이름"], "규격": got.get("규격") or line["규격"]}) # fmt: skip return got["키"], mfind.mark(got, line["이름"], line["규격"]) if kind == "재료" and src.get("종류") == "자재" and (hit := ea.est_code(src.get("코드"))): - return ea.one(line, hit) or hit[0], None # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드 + key = ea.one(line, hit) or hit[0] # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드 + return mf.done(key, "코드", src)["키"], None oil = ea.keys("MO", line["이름"], line["규격"]) if kind == "재료" else [] if kind == "재료": if oil or elem.oil_rows(line["이름"]): @@ -57,15 +62,13 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: ) else: got = hrow.m01_picks(kind, line) + if kind == "재료": # 827-2 — 지금 찾기 키 → 판정(규칙 · 단서 · 네 갈래) + return mf.find(line, src, _keys(line, got), label)["키"], None keys = {x.get("키") for x in got if x.get("키")} if ( len(keys) > 1 and kind == "유가" ): # 814-1 — 전국평균 · 전력 = 일반용(갑)Ⅰ 저압(지역 · 계약종별은 옵션) keys = {x["키"] for x in got if x["규격"] in OIL_BASE} or keys - elif len(keys) > 1 and kind == "재료": # 814-1 — 지역 꼬리 뗀 이름 · 규격 정확 일치 - keys = exact(line, got) or keys - if not keys and kind == "재료": # 815-1 K3 — 자재표 전체에서 표기 정규화 정확 일치 - keys = {k} if (k := ea.one(line, ea.exact(line["이름"], line["규격"]))) else keys if len(keys) == 1: return keys.pop(), None if len(keys) > 1 and kind in ("유가", "환율"): @@ -73,6 +76,24 @@ def m01(kind: str, src: dict) -> tuple[str | None, str | None]: return None, None +def _keys(line: dict, got: list[dict]) -> set[str]: + """재료 1차 찾기 키 — 여럿이면 지역 꼬리 뗀 정확 일치(814-1) · 없으면 자재표 전체 정확 일치(815-1 K3).""" + from M02_MasterTemplete import M02_Elem_Alias as ea + + keys = {x.get("키") for x in got if x.get("키")} + if len(keys) > 1: + keys = exact(line, got) or keys + if not keys: + keys = {k} if (k := ea.one(line, ea.exact(line["이름"], line["규격"]))) else keys + return keys + + +def hmat_clean(line: dict) -> dict: + from M02_MasterTemplete import M02_Hopyo_Material as hmat + + return {**hmat.clean(line)[0], "단위": line.get("단위") or ""} + + def apart(line: dict) -> bool: """줄 이름 · 규격에 「별도계상」(`M02_Hopyo_Material.clean`).""" from M02_MasterTemplete import M02_Hopyo_Material as hmat @@ -157,7 +178,7 @@ def why(item: dict) -> str: from M02_MasterTemplete import M02_Hopyo_Row as hrow if apart({"이름": name, "규격": res.get("규격") or ""}): - return APART + return APART_ITEM if (res.get("재료찾기") or {}).get("키") else APART if elem.oil_rows(name): return "유가 후보 여럿 — 고름" line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""} @@ -175,7 +196,12 @@ def tally(logic: dict) -> dict: """우리 정보로 셈 — 자원 줄 {됨, 줄, 조합(형제 단가산출 부름뿐)} (연결 판정 「없음」 띠 · 814-1).""" items = logic.get("호표") or [] done = sum( - 1 for x in items if x.get("이음") or x.get("참조") or (x.get("자원") or {}).get("조합식") + 1 + for x in items + if x.get("이음") + or x.get("참조") + or (x.get("자원") or {}).get("조합식") + or ((x.get("자원") or {}).get("재료찾기") or {}).get("키") # 827-2 별도계상 품목 ) inner = [x for x in items if (x.get("자원") or {}).get("갈래") == "단가산출" or x.get("참조")] return {"됨": done, "줄": len(items), "조합": bool(items) and len(inner) == len(items)} diff --git a/M02_MasterTemplete/M02_San_MatFind.py b/M02_MasterTemplete/M02_San_MatFind.py new file mode 100644 index 000000000..8ed86dc20 --- /dev/null +++ b/M02_MasterTemplete/M02_San_MatFind.py @@ -0,0 +1,360 @@ +"""불러오기 재료 찾기 판정 · 단서 · 자동 규칙(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()) diff --git a/M02_MasterTemplete/M02_San_Xlsx_Stmate.py b/M02_MasterTemplete/M02_San_Xlsx_Stmate.py index f5ca52870..ed506ff87 100644 --- a/M02_MasterTemplete/M02_San_Xlsx_Stmate.py +++ b/M02_MasterTemplete/M02_San_Xlsx_Stmate.py @@ -70,6 +70,9 @@ def read(sheets: dict[str, list]) -> dict[str, Any]: rule, got = vote(out["산근"], prices) out["옵션"] = {"SAN_TYP": RULE[rule]} out["내역"] = None + from M02_MasterTemplete import M02_San_MatFind as mf + + out["단서"] = mf.from_book(sheets.get("일위대가표")) # 827-2 재료 단서 out["표지"] = [ f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)", f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)", diff --git a/resources/tester/test_817_1_material_find.py b/resources/tester/test_817_1_material_find.py index 768b8bc2d..2dfa7edc5 100644 --- a/resources/tester/test_817_1_material_find.py +++ b/resources/tester/test_817_1_material_find.py @@ -20,10 +20,10 @@ TABLE = [ ("접착제", "", "후보", None), # 1 ("패널(유로폼)", "600*1200mm", "이음", "MT000467"), # 2 ("각재", "외송", "후보", "MT000393"), # 3 - ("요소", "", "후보", "MT006763"), # 4 + ("요소", "", "이음", "MT006763"), # 4 — 827-2 규칙 A ("내부코너패널(유로폼)", "(100x200)x1200mm", "이음", "MT000470"), # 5 ("결속선(철선)", "#20 , 0.9M/M", "후보", None), # 6 - ("각재 (외송)", "", "후보", "MT000393"), # 7 + ("각재 (외송)", "", "이음", "MT000393"), # 7 — 827-2 규칙 A ("파형강관", "별도계상", "후보", None), # 8 ("금강사", "", "없음", None), # 9 ("고무테이프", "0.5mm×19mm×10m", "이음", "MT009202"), # 10 @@ -47,7 +47,7 @@ TABLE = [ ("전기뇌관(MS)", "3.5m×500개(하이데토)", "이음", "MT005677"), # 28 ("BUTTON BIT", "Ø38×76㎜", "후보", None), # 29 ("유압드릴샹크", "T38×CM470", "후보", None), # 30 - ("유압식드릴스리브", "T38×190mm", "후보", "MT005687"), # 31 + ("유압식드릴스리브", "T38×190mm", "이음", "MT005687"), # 31 — 827-2 규칙 E ("미송(판재)", "", "후보", None), # 32 ("산림종자", "참싸리(박피)", "이음", "MT006664"), # 33 ("유기질퇴비", "", "후보", None), # 34 @@ -92,7 +92,7 @@ TABLE = [ ("산소(6000ℓ)", "99%,120kg/㎠", "이음", "MT003165"), # 73 ("아세칠렌", "AC 98%,용접용", "이음", "MT003163"), # 74 ("서비스볼트(고장력볼트)", "M20×60", "이음", "MT002536"), # 75 - ("보조강재(열연강판)", "T=9-12", "후보", None), # 76 + ("보조강재(열연강판)", "T=9-12", "이음", "MT030619"), # 76 — 827-2 규칙 B ("구역화물자동차(8Ton)", "10km이내", "이음", "MT023654"), # 77 ("구역화물자동차(8Ton)", "20km이내", "이음", "MT023655"), # 78 ("참싸리(박피)", "", "없음", None), # 79 @@ -106,15 +106,15 @@ TABLE = [ ("P.V.C PIPE", "VG1(35mm)", "이음", "MT002859"), # 87 ("휠라", "T=15mm,1000×1000mm", "없음", None), # 88 ("측구수로관", "300×300×2000×120,앵글포함", "이음", "MT015883"), # 89 - ("중하중그레이팅", "395×995×52", "후보", "MT007025"), # 90 + ("중하중그레이팅", "395×995×52", "이음", "MT007025"), # 90 — 827-2 규칙 E ("측구수로관", "500×500×2000×150,앵글포함", "이음", "MT015887"), # 91 - ("중하중그레이팅", "595×995×67", "후보", "MT007032"), # 92 + ("중하중그레이팅", "595×995×67", "이음", "MT007032"), # 92 — 827-2 규칙 E ("잔디", "0.18×0.18×0.01", "이음", "MT006796"), # 93 ("강관비계", "4000×48.6mm×2.3mm", "이음", "MT000481"), # 94 ("삼각표지판", "삼각,900mm", "후보", None), # 95 ("표지판지주", "D=76.3mm×3.0m", "이음", "MT003241"), # 96 ("공사안내표지판", "300×1500", "후보", None), # 97 - ("공사안전PE드럼", "500×800", "후보", "MT003311"), # 98 + ("공사안전PE드럼", "500×800", "이음", "MT003311"), # 98 — 827-2 규칙 E ("윙카호스", "10cm", "후보", None), # 99 ("조합페인트", "KSM6020 2급,백색", "이음", "MT003372"), # 100 ] @@ -139,7 +139,7 @@ def test_target_rows(name, spec, want, key): def test_counts(): linked = sum(1 for *_x, w, _k in TABLE if w == "이음") - assert linked == 51 + assert linked == 58 @pytest.mark.parametrize( diff --git a/resources/tester/test_827_2_material_clues.py b/resources/tester/test_827_2_material_clues.py new file mode 100644 index 000000000..c0d25f03b --- /dev/null +++ b/resources/tester/test_827_2_material_clues.py @@ -0,0 +1,194 @@ +"""827-2 불러오기 재료 찾기 — 별도계상 품목 · 단서 넷 · 자동 규칙 A ~ F2 · 판정 네 갈래 · 별칭 안 만듦(요구 810 ~ 812). + +판정 = `M02_San_MatFind`(`M02_San_Link.m01` 이 부름 · 줄 `자원["재료찾기"]`). 실무 원파일 시험은 파일 없으면 건너뜀. +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "master_data" / "scripts"), str(ROOT / "resources" / "tester")] # fmt: skip + +from M02_MasterTemplete import M02_Elem_Alias as ea # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_MatSame as ms # noqa: E402 +from M02_MasterTemplete import M02_San_Link as sl # noqa: E402 +from M02_MasterTemplete import M02_San_MatFind as mf # noqa: E402 + + +def _go(name, spec, unit, label="", code=""): + src = {"이름": name, "규격": spec, "단위": unit, "갈래": "재료", "코드": code} + key, mark = sl.m01("재료", src, label) + return key, mark, src["재료찾기"] + + +# ── 자동 규칙(걸음 2) — 맞는 보기 ────────────────────────────────────────── +@pytest.mark.parametrize( + "name,spec,unit,rule,key", + [ + ("유압식드릴스리브", "T38×190mm", "EA", "E", "MT005687"), + ("중하중그레이팅", "395×995×52", "EA", "E", "MT007025"), + ("공사안전PE드럼", "500×800", "EA", "E", "MT003311"), + ("아세틸렌", "용접용", "kg", "C", "MT003163"), + ("요소", "", "KG", "A", "MT006763"), + ("보조강재(열연강판)", "T=9-12", "kg", "B", "MT030619"), + ("미송(판재)", "", "M3", "F", "MT000366"), + ("각재 (외송)", "", "M3", "F", "MT000339"), + ("각재", "외송", "M3", "F2", "MT000339"), + ], +) +def test_rule_links(name, spec, unit, rule, key): + got, _mark, found = _go(name, spec, unit) + assert got == key and found["판정"] == mf.LINKED and found["방법"] == f"규칙 {rule}" + + +# ── 틀리면 안 되는 보기 ─────────────────────────────────────────────────── +def _keys_named(word): + return {k for k, r in ea.book()["줄"]["MT"].items() if word in str(r.get("이름"))} + + +def test_no_wrong_rule_links(): + nawang = _keys_named("나왕") + assert nawang + for name, spec in ( + ("각재(외송)", ""), + ("각재 (외송)", ""), + ("각재", "외송"), + ("각재(외송)", "3.6×3.0×3.0"), + ): + assert _go(name, spec, "M3")[0] not in nawang + me = ms._item("치즐", "0.7m3") + assert mf._rule_b({"이름": "치즐", "규격": "0.7m3", "단위": "EA"}, me) in (None, "MT000011") + assert _go("치즐", "0.7m3", "EA")[0] == "MT000011" # 이미 이은 줄 그대로 + bit = _go("BUTTON BIT", "Ø38×76㎜", "EA") + assert bit[0] is None and bit[2]["판정"] == mf.NONE + rod = _go("PVC용접봉", "Ø3.2 (국산)", "KG") + assert rod[0] is None and rod[2]["판정"] == mf.NONE + for name in ("에폭시수지",): + assert _go(name, "YD-128", "kg")[0] is None + + +def test_doubted_three(): + """조사 810-2 의심 셋 — 블레이드 · 드릴롯드는 풀고 · 참싸리(박피)는 사용자 수동연결 과녁(817-1 #33)대로.""" + assert _go("블레이드", "d=320~400mm t=3.2", "EA")[0] != "MT005744" + assert _go("유압식드릴롯드", "T38×R32x3660mm", "EA")[0] != "MT005695" + assert _go("산림종자", "참싸리(박피)", "KG")[0] == "MT006664" + assert _go("블레이드(커터)", 'd=320 400mm 14"xt3.2', "EA")[0] == "MT005749" + + +# ── 판정 네 갈래 ────────────────────────────────────────────────────────── +def test_four_verdicts(): + assert _go("유압식드릴스리브", "T38×190mm", "EA")[2]["판정"] == mf.LINKED + no_spec = _go("철물", "", "EA")[2] + assert no_spec["판정"] == mf.NO_SPEC and no_spec["후보"][0]["키"] == "MT000511" + many = _go("결속선(철선)", "#20 , 0.9M/M", "KG")[2] + assert many["판정"] == mf.MANY and len(many["후보"]) > 1 and many["키"] is None + for name, spec, unit in (("떼", "", "m2"), ("BUTTON BIT", "Ø38×76㎜", "EA")): + assert _go(name, spec, unit)[2]["판정"] == mf.NONE + linked = _go("결속선(철선)", "#20 , 0.9M/M", "KG")[2] + assert set(linked) == {"판정", "방법", "키", "후보", "단서"} + + +# ── 단서 넷(걸음 1) ─────────────────────────────────────────────────────── +BOQ = [ # STC 내역서 자재 직접 줄(동부청영월 꼴) + {"번": "1", "이름": "파형강관(2.7T)-영월", "규격": "Φ800-1RS", "단위": "M", "코드": "M01092"}, + {"번": "2", "이름": "이형철근", "규격": "D13~D32", "단위": "Ton", "코드": "M00654"}, + {"번": "3", "이름": "파형강관부설", "규격": "Φ800", "단위": "M", "코드": "D00071"}, +] + + +def test_clue_label(): + got, _mark, found = _go("철물", "", "EA", label="5) 철물(앵커용)") + assert ( + got == "MT000511" + and found["방법"] == "단서" + and found["단서"] == ["설명 글 — 철물(앵커용)"] + ) + + +def test_clue_head_and_boq_apart(): + clues = mf.from_boq(BOQ) + assert [c["코드"] for c in clues] == ["M01092", "M00654"] # 자재(M) 줄만 + with mf.clues_on(clues, "파형강관부설 Φ800mm"): + key, mark, found = _go("파형강관", "별도계상", "M") + assert key is None and mark == f"{sl.NOTE}{sl.APART}" # 관급 · 일반 둘 = 사람 몫 · 이음 비움 + assert found["판정"] == mf.NO_SPEC + assert {c["키"] for c in found["후보"][:2]} == {"MT015181", "MT015282"} + assert all(c.get("단서") == "단서" for c in found["후보"][:2]) + assert found["단서"][0] == "산근 머리 — 파형강관부설 Φ800mm" + with mf.clues_on(clues, "철근가공조립(현장) TYPE Ⅰ"): + found = _go("이형철근", "별도계상", "Ton")[2] + assert found["판정"] == mf.NO_SPEC and found["단서"] == [] # 범위 D13~D32 = 규격 하나 아님 + + +def test_clue_book_sheet(): + meta = "_x0007_`COD|{}_x0005_`EXI|0_x0005_`" + rows = [ + ["파형강관부설", "D=800mm", None, "m", *[None] * 8, "대가 17", None, "B01217"], + [ + "파형강관(800mm)", + "별도계상", + 1, + "M", + *[0] * 8, + "별산자재 19 ", + meta.format("M00222"), + "M00222_1", + ], # fmt: skip + ["보통인부", None, 0.66, "인", *[0] * 8, "노무 1 ", meta.format("L00001"), "L00001_1"], + ] + got = mf.from_book(rows) + assert got == [{"곳": "일위대가표", "코드": "M00222", "이름": "파형강관(800mm)", "규격": "별도계상", "머리": "파형강관부설 D=800mm"}] # fmt: skip + with mf.clues_on(got, ""): + found = _go("파형강관", "별도계상", "M", code="M00222")[2] + assert found["판정"] == mf.NO_SPEC and all("800" in c["규격"] for c in found["후보"][:3]) + assert any(x.startswith("일위대가표") for x in found["단서"]) + + +def test_apart_linked_item_keeps_zero(): + """별도계상 줄 = 품목이 하나로 정해지면 판정 키 · 줄 이음은 비움(셈 제외 · 단가 0 그대로).""" + key, mark, found = _go("요소(별도계상)", "", "KG") + assert key is None and found["키"] == "MT006763" and mark == f"{sl.NOTE}{sl.APART_ITEM}" + assert sl.why({"자원": {"갈래": "재료", "이름": "요소(별도계상)", "규격": "", "재료찾기": found}}) == sl.APART_ITEM # fmt: skip + assert sl.tally({"호표": [{"이름": "요소", "자원": {"재료찾기": found}}]})["됨"] == 1 + + +# ── 별칭 안 만듦 ────────────────────────────────────────────────────────── +def test_no_alias_written(): + before = {k: r.get("별칭") for k, r in ea.book()["줄"]["MT"].items()} + for args in ( + ("각재", "외송", "M3"), + ("종자", "초본류", "KG"), + ("철물", "", "EA"), + ("떼", "", "m2"), + ): + _go(*args) + assert {k: r.get("별칭") for k, r in ea.book()["줄"]["MT"].items()} == before + + +# ── 실무 원파일(있을 때) — 단서가 원파일 · 엑셀에서 읽힘 ───────────────────── +def test_real_files_carry_clues(): + import test_797_1a_import_flags as practice + import test_810_1_stc as c + + from M02_MasterTemplete import M02_Doc_FromNative as fn + from M02_MasterTemplete import M02_San_Xlsx as sx + + stc, xl = c.FILES["동부청영월"], practice.FILES.get("울진대흥") + if not stc.exists() or not xl or not xl.exists(): + pytest.skip("실무 원파일 없음") + saved = fn.read(stc.name, stc.read_bytes()) + assert any(x["이름"].startswith("파형강관") for x in saved["단서"]) + book = fn.pack(sx.read(xl.name, xl.read_bytes()), xl.name) + assert any(x["곳"] == "일위대가표" and x.get("머리") for x in book["단서"]) + out = fn.emit(saved, 71) # 파형강관부설 Φ800mm + line = next( + h for h in out["로직"]["호표"] if str((h.get("자원") or {}).get("규격")) == "별도계상" + ) + assert ( + line["이음"] is None + and not line.get("단가") + and not any((line.get("값묶음") or {}).values()) + ) + assert {c["키"] for c in line["자원"]["재료찾기"]["후보"][:2]} == {"MT015181", "MT015282"}