From bed35e3bc36f7729c2b176c628ab78696993ee3d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 10 Nov 2026 14:47:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(814-1=203=C2=B74=C2=B76=C2=B77=C2=B78?= =?UTF-8?q?=C2=B79=C2=B710):=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=84=B8?= =?UTF-8?q?=20=EC=A4=84=20=EB=AC=B6=EA=B8=B0=20=C2=B7=20=EA=BC=AC=EB=A6=AC?= =?UTF-8?q?=20=EB=97=80=20=EB=A7=9E=EB=8C=80=EA=B8=B0=20=C2=B7=20=EC=9C=A0?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD=20=C2=B7=20=EA=B0=80=EB=B2=BC=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3 연결 찾기 = 엑셀 이름 규격 · 단위 꼬리 뗀 이름으로 맞댐(`M02_Doc_Match_Note.bare` · 보이는 이름 그대로) · STmate 블록 규격도 가져온 칸에 - 4 기계 세 줄 = 기계 한 줄 — STmate X 코드 노 · 재 · 경 세 참조 줄(같은 수량 · 번지) 묶음(묶어서 셈 못 하는 세 줄만 그대로) · 문서 단계 묶기 넓힘(차례 아무렇게 · 시간당 경비 이음 없으면 엑셀 값 묶음 · 번지 같을 때) · 조합 다음 줄 = 세 줄 뒤 · 같은 기계 한 항목 - 6 공구손료 율 비목 차이(재료비 ↔ 경비) = 참고(`M02_Doc_Match_Value.tool_cost`) — 확정 안 막음 - 7 못 이은 줄 까닭(`M02_San_Link.why` — 자재 없음 · 운임표 없음 · 기계 표에 없음 · 별칭 대기 · 후보 여럿 · 시간당 경비 없음 · 조합) = 문서 설명 · 이음 없는 자원 띠 · 연결 「없음」 띠 = 「우리 기초 없음 — 우리 정보로 셈 n/m 줄」 · 형제만 = 「조합」 - 8 유가 = 전국평균 줄 + 지역 옵션(`MO:경유:{S_지역}`) · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 · 엑셀 값 = 검산 칸 - 9 재료 = 지역 꼬리 뗀 이름 · 규격 정확 일치면 이음(지역 여럿 = 줄 지역 › 서울) - 10 조합 첫 줄 규격 = 그 기계 · 얼린 줄도 소계 식에(북부청임도 33) · 기계 줄 찾기 판정 늘 붙음 · 올린 직후 409(HP 보이기 전 산근 보관) · 엑셀 블록 「엑셀 읽기 확정」 글 · 전수 도우미 str() 뗌 · 줄 쌓기 이름에 규격이 들면 한 번만 - M02_San_Link 새 파일(San_Emit 700 줄 막음 — 요소 잇기 · 시간당 · 조합 · 까닭) - 시험: 새 test_814_1 · 812-2 · 810-4 거푸집 41 = 근접 → 확정(공구손료 비목 = 값 · 요구 744 C1) · 바꾸기 거절 견본 = 19 · 812-9 조합 첫 줄 규격 「0.7+브레이카」 → 「0.7」(칩 글 · 요구 738 흠 12) Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_out.ts | 6 +- M02_MasterTemplete/M02_Doc_FromNative.py | 3 + M02_MasterTemplete/M02_Doc_Import.py | 57 ++++ M02_MasterTemplete/M02_Doc_Import_Shape.py | 100 +++++-- M02_MasterTemplete/M02_Doc_Match.py | 1 + M02_MasterTemplete/M02_Doc_MatchSk.py | 4 +- M02_MasterTemplete/M02_Doc_Match_Note.py | 19 +- M02_MasterTemplete/M02_Doc_Match_Value.py | 13 + M02_MasterTemplete/M02_Doc_NativeEst.py | 29 +- M02_MasterTemplete/M02_Doc_NativeLayout.py | 6 +- M02_MasterTemplete/M02_Hopyo_Router.py | 10 +- M02_MasterTemplete/M02_Hopyo_Store.py | 11 +- .../M02_MasterTemplete_Hopyo_Imported.ts | 2 +- .../M02_MasterTemplete_Hopyo_ImportedPanel.ts | 5 +- .../M02_MasterTemplete_Sangeun_Side.ts | 2 +- M02_MasterTemplete/M02_San_Emit.py | 154 +++++++---- M02_MasterTemplete/M02_San_Link.py | 167 +++++++++++ M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 11 +- M02_MasterTemplete/M02_Sangeun_Imported.py | 6 +- M02_MasterTemplete/logic/M02_Sangeun_Match.ts | 8 +- .../logic/M02_Sangeun_Origin.ts | 3 + resources/tester/helper_810_4_sweep.py | 5 +- resources/tester/test_810_4_match.py | 18 +- resources/tester/test_812_2_match_logic.py | 8 +- resources/tester/test_812_9_machine_link.py | 5 +- .../tester/test_814_1_import_decisions.py | 260 ++++++++++++++++++ 26 files changed, 786 insertions(+), 127 deletions(-) create mode 100644 M02_MasterTemplete/M02_San_Link.py create mode 100644 resources/tester/test_814_1_import_decisions.py diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 46d0fceb0..86b8c9b0b 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -248,7 +248,11 @@ function rows( } }); if (Object.keys(at).length) scope.번지 = at; - const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" "); + const spec = (line as { 규격?: string }).규격; + const name = + spec && !String(line.이름 ?? "").includes(spec) + ? `${line.이름 ?? ""} ${spec}`.trim() + : String(line.이름 ?? ""); // 이름에 규격이 들면 한 번만(814-1) const qty = line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null; const qtyNode = (cost?: string): (string | HTMLElement)[] => diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py index 207a061c3..cd8fdad89 100644 --- a/M02_MasterTemplete/M02_Doc_FromNative.py +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -121,6 +121,9 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]: marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지")) sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"]) out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}} + out["원본"] |= ( + {"규격": blk["규격"]} if blk.get("규격") else {} + ) # 연결 찾기 근거 · 이름 꼬리(814-1) return _excel(saved, out, blk) diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 9950f112c..3c9145ee7 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -226,6 +226,8 @@ def _link_of(item: dict) -> tuple[dict | None, str]: } return link | ({"칸": item["참조비목"]} if item.get("참조비목") else {}), "" key = str(item.get("이음") or "") + if not key and (item.get("자원") or {}).get("조합식"): + return None, f"우리 조합 식 {item['자원']['조합식']} 에 듦(첫 줄) — 이 줄 값 0" if not key: return None, "" if item.get("종류") == "로직": @@ -233,12 +235,59 @@ def _link_of(item: dict) -> tuple[dict | None, str]: if key.startswith("EQ"): got = hourly_link(key, str(item.get("비목") or "")) return got, "" if got else f"기계 {key} 시간당 경비 자료 없음 — 엑셀 단가 그대로" + if key.startswith("MO") and (got := _oil(key)): + return got, "" link = {"갈래": "요소", "키": key} if key[:2] == dc.cv.mm.LINKED: # 품셈재료 = 줄 단위로 값이 갈림(M01 변환과 같음) link["단위"] = str(item.get("단위") or "") return link, "" +OPTION = "_옵션" # 연결이 부르는 옵션 변수(문서에 없으면 `_attach` 가 더함) + + +def _oil(key: str) -> dict | None: + """유가 · 전력 이음(814-1 · 요구 748) — 우리 기초 꼴: 유가 = `MO:경유:{S_지역}` + 지역 옵션(기본 + 전국평균) · 전력 = 전력량요금 계약종별 고르기 + 계약종별 옵션(기본 일반용(갑)Ⅰ 저압).""" + whole = imp._whole() + try: + row = whole.get(key) + except Exception: # noqa: BLE001 — 없는 키 = 보통 요소 연결 + return None + rows = list(whole.index.get("MO", {}).values()) + if row.get("구분") == "전력": + kinds = list(dict.fromkeys(str(r.get("상세구분")) for r in rows if r.get("구분") == "전력" and r.get("이름") == row.get("이름"))) # fmt: skip + var = _choice("전력 계약종별", "전력계약종별", kinds, str(row.get("상세구분"))) + link = { + "갈래": "요소", + "재료": {"이름": row.get("이름"), "계약종별": "입력"}, + "단위": "㎾h", + } + return link | {"계약종별": dc.md.var_id(var), OPTION: var} + head = str(row.get("원문번호") or "").split(":")[0] + if not head or row.get("구분") != "유류": + return None + places = [ + str(r.get("상세구분")) for r in rows if str(r.get("원문번호") or "").split(":")[0] == head + ] + places.sort(key=lambda x: x != "전국평균") + var = _choice("지역", "지역", places, str(row.get("상세구분"))) + return {"갈래": "요소", "키": f"MO:{head}:{{{dc.md.var_id(var)}}}", OPTION: var} + + +def _choice(key: str, below: str, items: list[str], value: str) -> dict: + link = {"갈래": "선택", "목록": items, "값": value} + return { + "열쇠": key, + "기호": "S", + "아래": below, + "위": "", + "이름": key, + "단위": "", + "연결": link, + } + + MACHINES = "_기계" # 문서 셈 동안 잠깐 — `derive` 가 머리 `가져온.기계` 로 옮김 @@ -280,7 +329,15 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None: if note: var["설명"] = note if link is None: + if not note and not item.get("이음"): # 못 이은 까닭(814-1 — 흠 아님 · 우리 정보 없음) + from M02_MasterTemplete import M02_San_Link as san_link + + var["설명"] = f"이음 없음 — {san_link.why(item)} · 엑셀 단가 그대로" continue + if (opt := link.pop(OPTION, None)) and opt["열쇠"] not in { + v.get("열쇠") for v in doc["변수"] + }: + doc["변수"].append(opt) # 유가 지역 · 전력 계약종별 옵션(문서에 하나) if item.get("참조비목") and isinstance(excel, dict): excel = excel[item["참조비목"]] line["단가"], line["비목"] = dc.md.var_id(var), item["참조비목"] diff --git a/M02_MasterTemplete/M02_Doc_Import_Shape.py b/M02_MasterTemplete/M02_Doc_Import_Shape.py index 063a16d14..84e980981 100644 --- a/M02_MasterTemplete/M02_Doc_Import_Shape.py +++ b/M02_MasterTemplete/M02_Doc_Import_Shape.py @@ -68,20 +68,26 @@ def _rename_steps(doc: dict, swap: dict[str, str]) -> None: st["줄"] = got -def _bundle_of(doc: dict, at: int) -> list[int] | None: - """at 에서 시작하는 기계 세 줄(노무비 · 재료비 · 경비) — 같은 기계 이음 · 같은 수량.""" +def _bundle_of(doc: dict, at: int, who: dict[str, str] | None = None) -> list[int] | None: + """at 에서 시작하는 기계 세 줄(노무비 · 재료비 · 경비 · 차례 아무렇게) — 같은 기계 이음 · 같은 수량 · + 시간당 경비 이음 없음(숫자)이면 같은 기계 자원(`who` 줄 이름 → 자원 · 814-1).""" money = doc.get("금액") or [] three = money[at : at + 3] - if len(three) < 3 or [x.get("비목") for x in three] != list(COSTS): + if len(three) < 3 or sorted(str(x.get("비목")) for x in three) != sorted(COSTS): return None vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []} links = [(vars_.get(str(x.get("단가"))) or {}).get("연결") or {} for x in three] - if any(x.get("갈래") != "단가산출" or "기계" not in (x.get("입력") or {}) for x in links): + if all(x.get("갈래") == "숫자" and not isinstance(x.get("값"), dict) for x in links): + mine = {(who or {}).get(str(x.get("이름"))) for x in three} + if len(mine) != 1 or None in mine: + return None + elif any(x.get("갈래") != "단가산출" or "기계" not in (x.get("입력") or {}) for x in links): return None - same = {(x.get("키"), str(x.get("입력"))) for x in links} - if len(same) != 1 or [x.get("칸") for x in links] != list(COSTS): + elif len({(x.get("키"), str(x.get("입력"))) for x in links}) != 1 or [ + x.get("칸") for x in links + ] != [x.get("비목") for x in three]: return None - for k in ("수량", "규격", "자리", "합밖"): + for k in ("수량", "규격", "자리", "합밖", "번지"): if len({str(x.get(k) or "") for x in three}) != 1: return None names = {str(x.get("이름")) for x in three} @@ -113,30 +119,70 @@ def _rewrite(text: Any, swap: dict[str, str]) -> Any: return pat.sub(lambda m: swap[m.group(0)], text) -def machines(doc: dict, title: dict[str, str]) -> None: - """② EST 기계 세 줄 → 한 기계 줄(비목 묶음) · 부르던 식 = 비목 칸. `title` = {줄 이름: 자원 이름}.""" +AFTER = "_뒤" # 옮긴 조합 다음 줄 — 차림이 원본 행 차례로 다시 섞지 않게(다듬기 사이 임시) + + +def regroup(doc: dict, who: dict[str, str], parts: set[str]) -> None: + """814-1 — 조합 기계 비목 세 줄 사이에 낀 다음 줄(값 0)을 세 줄 뒤로 · 같은 기계끼리 모음 + (첫 줄 · 다음 줄이 엇갈려 항목이 겹치지 않게 · 값 0 줄이라 셈 그대로). `parts` = 조합 다음 줄 이름.""" + money = doc.get("금액") or [] + at = 0 + while at < len(money): + head = who.get(str(money[at].get("이름"))) + if head is None or str(money[at].get("이름")) in parts: + at += 1 + continue + end, mains, rest = at, [], [] + while end < len(money): + name = str(money[end].get("이름")) + if name in parts: + rest.append(money[end]) + elif who.get(name) == head and len(mains) < 3: + mains.append(money[end]) + else: + break + end += 1 + if len(mains) > 1 and rest: + seen = list(dict.fromkeys(who.get(str(x.get("이름"))) for x in rest)) + rest.sort(key=lambda x: seen.index(who.get(str(x.get("이름"))))) # 같은 기계끼리 + for x in rest: + x[AFTER] = True # 차림(`NativeLayout.apply`) 원본 행 = 앞 줄 행 뒤 + money[at:end] = mains + rest + at = max(end, at + 1) + + +def machines(doc: dict, title: dict[str, str], who: dict[str, str] | None = None) -> None: + """② 기계 세 줄 → 한 기계 줄(비목 묶음) · 부르던 식 = 비목 칸. `title` = {줄 이름: 자원 이름} · + `who` = {줄 이름: 기계 자원 열쇠}(시간당 경비 이음 없는 세 줄 · 814-1).""" money = doc.get("금액") or [] vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []} out, swap, at, used = [], {}, 0, set() while at < len(money): - idx = _bundle_of(doc, at) + idx = _bundle_of(doc, at, who) if idx is None: out.append(money[at]) at += 1 continue - three = [money[i] for i in idx] - first = vars_[str(three[0]["단가"])] - link = {k: v for k, v in first["연결"].items() if k not in ("칸", "엑셀")} - excel = { - c: Decimal(str((vars_[str(x["단가"])]["연결"]).get("엑셀") or 0)) - for c, x in zip(COSTS, three) - } - link["엑셀"] = excel - name = title.get(str(three[0].get("이름"))) or str(three[0].get("이름")) + three = sorted((money[i] for i in idx), key=lambda x: COSTS.index(x["비목"])) + first = vars_[str(money[idx[0]]["단가"])] + if first["연결"].get("갈래") == "숫자": # 시간당 경비 이음 없음 = 엑셀 값 묶음 + link = { + "갈래": "숫자", + "값": {c: (vars_[str(x["단가"])]["연결"]).get("값") for c, x in zip(COSTS, three)}, + } + else: + link = {k: v for k, v in first["연결"].items() if k not in ("칸", "엑셀")} + link["엑셀"] = { + c: Decimal(str((vars_[str(x["단가"])]["연결"]).get("엑셀") or 0)) + for c, x in zip(COSTS, three) + } + lead = money[idx[0]] + name = title.get(str(lead.get("이름"))) or str(lead.get("이름")) first["연결"], first["이름"] = link, f"{name} 단가" if name in {str(x.get("이름")) for x in money} - {str(x.get("이름")) for x in three}: name = f"{name} ({at + 1})" # 같은 이름 줄이 있으면 겹치지 않게 - line = {k: v for k, v in three[0].items() if k != "기호"} | {"이름": name, "비목": "묶음"} + line = {k: v for k, v in lead.items() if k != "기호"} | {"이름": name, "비목": "묶음"} + line["단가"] = lead["단가"] _rename_steps(doc, {str(x.get("이름")): name for x in three}) if any(x.get("기호") for x in three): # 부르던 식 = 번지 칸(엔진 `번지.X.노무비`) place = _place(doc, name, used) @@ -147,7 +193,8 @@ def machines(doc: dict, title: dict[str, str]) -> None: doc["변수"] = [ v for v in doc["변수"] - if v is first or v.get("열쇠") not in {str(x["단가"]) for x in three[1:]} + if v is first + or v.get("열쇠") not in {str(x["단가"]) for x in three} - {str(lead["단가"])} ] out.append(line) at += 3 @@ -166,11 +213,18 @@ def shape(doc: dict, meta: dict) -> dict: src = meta.get("원본") or {} lay.tag(doc, meta) # 812-3 원파일 차림 — 금액 줄 ↔ 로직 줄(끝에 `apply`) names(doc, list(src.get("글줄") or [])) - title = {} + title, who, parts = {}, {}, set() for item in (meta.get("로직") or {}).get("호표") or []: res = item.get("자원") or {} if res.get("갈래") == "기계" and res.get("이름"): title[str(item.get("이름"))] = " ".join(x for x in (res["이름"], res.get("규격")) if x) - machines(doc, title) + key = (res.get("코드"), res.get("이름"), res.get("규격")) + who[str(item.get("이름"))] = repr(key) + if res.get("조합"): + parts.add(str(item.get("이름"))) + regroup(doc, who, parts) + machines(doc, title, who) lay.apply(doc, meta) + for line in doc.get("금액") or []: + line.pop(AFTER, None) return doc diff --git a/M02_MasterTemplete/M02_Doc_Match.py b/M02_MasterTemplete/M02_Doc_Match.py index 0ef8c1f75..e33fae82e 100644 --- a/M02_MasterTemplete/M02_Doc_Match.py +++ b/M02_MasterTemplete/M02_Doc_Match.py @@ -618,6 +618,7 @@ def _off(doc: dict, vid: str, value: Any, seen: set) -> dict: def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]: """가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 참고, 금액, 후보수}`.""" links = links if isinstance(links, Memo) else Memo(links) + imported = note.bare(imported) # 엑셀 이름 규격 · 단위 꼬리 뗌(814-1 · 맞대기만) got_imp = _calc(imported, links_imp, {}) if got_imp is None: return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0} diff --git a/M02_MasterTemplete/M02_Doc_MatchSk.py b/M02_MasterTemplete/M02_Doc_MatchSk.py index 67bb3280e..fa5cf795a 100644 --- a/M02_MasterTemplete/M02_Doc_MatchSk.py +++ b/M02_MasterTemplete/M02_Doc_MatchSk.py @@ -57,7 +57,9 @@ def judge(sk: dict) -> dict[str, Any]: doc = dim.of(sk) links_imp.learn(doc) got = dm.match(doc, links_imp, index(), dm.ml.Links(whole, dc.folder())) - return {**got, "때": utc_now_text()} + from M02_MasterTemplete import M02_San_Link as san_link # 우리 정보로 셈 줄 수(814-1 띠) + + return {**got, "우리정보": san_link.tally(sk[imp.KEY].get("로직") or {}), "때": utc_now_text()} def find(key: str, version: str) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Doc_Match_Note.py b/M02_MasterTemplete/M02_Doc_Match_Note.py index 9b9ee23d7..158b6add2 100644 --- a/M02_MasterTemplete/M02_Doc_Match_Note.py +++ b/M02_MasterTemplete/M02_Doc_Match_Note.py @@ -230,10 +230,27 @@ def rate_pairs(left: list[dict], right: list[dict], take: Any) -> tuple[list[dic for a, b in take(lambda a, b: same(a, b, "대상", "비목")): # 로직 같음 · 율 값만 다름 value(a, b) for a, b in take(lambda a, b: same(a, b, "대상")): - diffs.append({"항": "비목", "글": f"율 줄 {name(a, b)} 비목 다름"}) + tool = val.tool_cost(a, b) # 공구손료 비목만 다름 = 값(814-1) + (notes if tool else diffs).append( + tool or {"항": "비목", "글": f"율 줄 {name(a, b)} 비목 다름"} + ) value(a, b) for a, b in take(lambda a, b: same(a, b, "율")): diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"}) for a, b in take(is_rate): diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"}) return diffs, notes + + +# ── 이름 꼬리(814-1 · 요구 743-3) ───────────────────────────────────────── +def bare(doc: dict) -> dict: + """엑셀 단가산출 이름의 규격 · 단위 꼬리(「구조물터파기(인력) 경질토사 / M3」) 뗀 맞댈 문서 — + 보이는 · 저장 이름은 그대로(연결 찾기만 이 사본을 씀).""" + name = str(doc.get("이름") or "") + unit = re.sub(r"^원\s*/\s*", "", str(doc.get("결과단위") or "")).strip() + spec = str((doc.get("가져온") or {}).get("규격") or "").strip() + got = re.sub(rf"\s*/\s*{re.escape(unit)}\s*$", "", name) if unit else name + if got != name and spec and got.rstrip().endswith(spec) and got.rstrip() != spec: + got = got.rstrip()[: -len(spec)] + got = got.strip() + return doc if not got or got == name else {**doc, "이름": got} diff --git a/M02_MasterTemplete/M02_Doc_Match_Value.py b/M02_MasterTemplete/M02_Doc_Match_Value.py index 3b8487e9e..c902b2151 100644 --- a/M02_MasterTemplete/M02_Doc_Match_Value.py +++ b/M02_MasterTemplete/M02_Doc_Match_Value.py @@ -27,6 +27,19 @@ CHECK = (*VALUE, "옵션 확인") # 기계 작업 꼴이 확정으로 붙지 _NUMBER = re.compile(r"^\s*\d+(?:\.\d+)?\s*$") +# ── 공구손료 율 비목(814-1 · 요구 744 C1) ───────────────────────────────── +TOOL = "공구손료 비목" + + +def tool_cost(a: dict, b: dict) -> dict | None: + """공구손료 율 줄 짝 — 대상은 같고 비목만 다름(실무 재료비 ↔ 우리 경비) = 값(참고 · 확정 안 막음 · + [바꾸기] = 우리 기초 사본 = 우리 비목) · 공구손료 아님 = None.""" + if "공구" not in str(a.get("이름")) + str(b.get("이름")): + return None + say = f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])} — 바꾸면 우리 비목" + return {"항": TOOL, "글": say} + + # ── 기종 ────────────────────────────────────────────────────────────── def row_of(code: Any) -> Any: """원문번호 → 기계표 줄(`M02_Machine_Find.Row`) · 없음 None.""" diff --git a/M02_MasterTemplete/M02_Doc_NativeEst.py b/M02_MasterTemplete/M02_Doc_NativeEst.py index 7034bedb1..a5dbaa797 100644 --- a/M02_MasterTemplete/M02_Doc_NativeEst.py +++ b/M02_MasterTemplete/M02_Doc_NativeEst.py @@ -299,7 +299,7 @@ class Block: self.got["확정"] += bool(got) self.register(ln, f"줄.'{name}'") - def triple(self, rows: list[dict], total: dict | None) -> None: + def triple(self, rows: list[dict], total: dict | None, back: int = 0) -> None: """기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다.""" got = [self.money(ln) for ln in rows] parts = [ln["비목"] for ln in rows] @@ -307,8 +307,8 @@ class Block: if not same: for ln, g in zip(rows, got): self.machine_line(ln, g) - if total: - self.subtotal(total, [call for _, call, _ in self.done[-len(rows) :]]) + if total: # 소계 = 세 줄 + 바로 앞 같은 기계 낱 줄(`back`) + self.subtotal(total, [call for _, call, _ in self.done[-(len(rows) + back) :]]) return qty, place = got[0] prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows} @@ -325,8 +325,10 @@ class Block: title = head.get("품명") or r.get("원이름") or r.get("이름") or "줄" name = self.o.names.item(title) one = {"이름": name, "규격": r.get("규격") or "", "단위": r.get("단위") or "", "수량": qty} - link = em.hourly_text(parts[0]["키"]) if parts else None - one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": link, "자원": r} + one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": None, "자원": r} + em.first_spec(one, parts) + if parts: # 814-1 조합(굴착기 + 브레이커 · 집게) = 우리 조합 식 한 줄 + one["이음"] = em.combo_link(one, parts) or em.hourly_text(parts[0]["키"]) one |= {"자리": place} if place else {} value = total["결과"][-1] if total and total.get("결과") else None for ln in rows: # 노 · 재 · 경 글 = 펼침(맞댈 값은 한 줄 계 — 소계 글 · 없으면 첫 줄) @@ -362,6 +364,7 @@ class Block: r |= {"찾기": parts[0]} if parts else {} r |= {"조합첫": True} if len(parts) > 1 else {} one = em.resource(name, r.get("단위"), qty, r, parts[0]["키"] if parts else None) + em.first_spec(one, parts) one |= {"자리": place} if place else {} self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1]) self.got["확정"] += bool(got) @@ -548,8 +551,22 @@ class Block: rows.append(lines[i + len(rows)]) nxt = lines[i + len(rows)] if i + len(rows) < len(lines) else None total = nxt if nxt and nxt.get("연결") == link and nxt.get("비목") == "T" else None + back, seen = ( + 0, + {r.get("비목") for r in rows}, + ) # 바로 앞 같은 기계 낱 줄(얼린 줄 · 814-1) + while i - back > 0 and len(seen) < 3: + b = lines[i - back - 1] + if ( + (b.get("자원") or {}).get("코드") != code + or b.get("비목") in seen + or not b.get("결과") + ): + break + seen.add(b.get("비목")) + back += 1 if all(r.get("결과") for r in rows): - self.triple(rows, total) + self.triple(rows, total, back) else: for r in rows: self.mark(r, "결과 없는 세 줄") diff --git a/M02_MasterTemplete/M02_Doc_NativeLayout.py b/M02_MasterTemplete/M02_Doc_NativeLayout.py index b1d94b1f3..e33f940f8 100644 --- a/M02_MasterTemplete/M02_Doc_NativeLayout.py +++ b/M02_MasterTemplete/M02_Doc_NativeLayout.py @@ -85,6 +85,9 @@ def apply(doc: dict, meta: dict) -> None: if j is not None: at[j] = min(at.get(j, t["행"]), t["행"]) rows = _fill([at.get(j) for j in range(len(money))]) + for j in range(1, len(rows)): # 조합 다음 줄을 세 줄 뒤로 옮긴 것(814-1 `Import_Shape.regroup`) + if money[j].get("_뒤"): + rows[j] = max(rows[j], rows[j - 1]) order = sorted(range(len(money)), key=lambda j: (rows[j], j)) if order != list(range(len(money))) and _safe(doc, order): doc["금액"] = money = [money[j] for j in order] @@ -193,8 +196,9 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic cut = False else: one = mach.get(price_sym(money[val]) or "") if kind == "금액" else None - if one and one.get("조합"): + if one and one.get("조합") and not (cur and cur.get("_조합") == _mach_title(one)): cur = new(_mach_title(one), bool(cur and cur["가나다"])) + cur["_조합"] = _mach_title(one) # 같은 기계 다음 줄(비목 셋)은 한 항목 elif one and cur is not None and "+" in cur["제목"]: cur["제목"] = _mach_title(one) cur = cur if cur is not None else new() diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index d29041de6..db2191f58 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -82,13 +82,9 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: grid = hopyo_grid.read(name, data, parsed) except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None - row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) - cannot = from_hopyo.keep( - store.root(), row["키"], name, data - ) # 산근 시트(793-3b 가져온 식 열기) - return ( - {**row, "읽기불가": cannot} if cannot else row - ) # 797-1 가져온 식 못 씀 사유 → 화면 토스트 + # 산근 시트(793-3b 가져온 식 열기) = HP 를 목록에 보이기 전에 보관(814-1) · 못 씀 사유 → 화면 토스트(797-1) + keep = lambda key: from_hopyo.keep(store.root(), key, name, data) # noqa: E731 + return hopyo_store.save(store.root(), name, parsed, who, grid, mark, keep) @router.get("/layers/{layer}/hopyo") diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index 261347eb0..4d7a9856d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -124,8 +124,10 @@ def save( who: Any, grid: dict[str, Any] | None = None, mark: str | None = None, + first: Any = None, ) -> dict[str, Any]: - """읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문.""" + """읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문 · + `first(키)` = HP 를 목록에 보이기 전에 할 일(산근 보관 — 올린 직후 409 막음 · 814-1) · 돌려준 값 = 답 `읽기불가`.""" with _LOCK: have = [ int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem)) @@ -145,9 +147,14 @@ def save( if grid is not None: book = {**grid["책"], "열": key} atomic_write_json(_grid_path(folder, key), {**grid, "책": book}) + cannot = first(key) if first else None atomic_write_json(_path(folder, key), doc) _put_done(folder, log) - return {**brief(doc), **({"지문": mark} if mark else {})} + return { + **brief(doc), + **({"지문": mark} if mark else {}), + **({"읽기불가": cannot} if cannot else {}), + } def grid(folder: str | Path, key: str) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index 443a3770b..b5d0d5a65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -61,7 +61,7 @@ interface EditAnswer { 문서: ImportedDoc; 전?: Money4 | null; 후?: Money4 | null; - 이음없음?: { 자리: string; 이름: string }[]; + 이음없음?: { 자리: string; 이름: string; 까닭?: string }[]; /** qty 답(797-2) */ 줄?: QtyRow[]; 집계?: QtyTally; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 6b4409c90..48a17ca3e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -54,7 +54,7 @@ export const tx = (k: keyof typeof TEXT): string => export interface PanelState { doc: ImportedDoc; /** 이음 없는 자원(서버 price 답) */ - unlinked: { 자리: string; 이름: string }[]; + unlinked: { 자리: string; 이름: string; 까닭?: string }[]; /** 소요량 · 계수 이음 집계(서버 qty 답 · 797-2) */ qty: QtyTally | null; /** 마지막 단가 바꿈 전후 */ @@ -149,7 +149,8 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { const kids: HTMLElement[] = [createToolbar({ items: head })]; if (here && s.unlinked.length) { const items = s.unlinked.map((u) => { - const b = createButton({ label: `${u.이름} · ${tx("Link")}`, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore + const label = `${u.이름}${u.까닭 ? ` (${u.까닭})` : ""} · ${tx("Link")}`; // 못 이은 까닭(814-1) + const b = createButton({ label, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore b.dataset.importedLink = u.자리; return b; }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts index b492db6cb..66de8e82a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Side.ts @@ -91,7 +91,7 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement { { label: tx("Rows"), value: o.행 }, { label: tx("When"), value: o.때 }, { label: tx("Marks"), value: tx("MarksN", o.표지.length) }, - ...(o.확정 ? [{ label: tx("Sure"), value: o.확정 }] : []), + ...(o.확정 ? [{ label: o.확정이름 ?? tx("Sure"), value: o.확정 }] : []), ]), marks, el("strong", { text: tx("Clauses") }), diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index c462f8f08..ecf9ba6c5 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -28,6 +28,8 @@ from typing import Any from M02_MasterTemplete import M02_San_Eval as ev from M02_MasterTemplete import M02_San_Expr as ex +from M02_MasterTemplete.M02_San_Link import combo_link, hourly_text # noqa: F401 — 밖에서 em. 으로 부름 +from M02_MasterTemplete.M02_San_Link import m01 as _m01 COMP = {"L": "노무비", "M": "재료비", "S": "경비"} _CODE = {v: k for k, v in COMP.items()} @@ -175,61 +177,6 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int: return n -def _m01(kind: str, src: dict) -> tuple[str | None, str | None]: - """1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 · - 환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면 - 유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님).""" - if kind not in ("인력", "재료", "기계"): - return None, None - from M02_MasterTemplete import M02_Hopyo_Elem as elem - from M02_MasterTemplete import M02_Hopyo_Row as hrow - - line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} - if ( - kind == "기계" - ): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음) - from M02_MasterTemplete import M02_Machine_Find as mfind - - got = mfind.find(line["이름"], line["규격"]) - return got["키"], mfind.mark(got, line["이름"], line["규격"]) - if kind == "재료": - if elem.oil_rows(line["이름"]): - kind = "유가" - elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}: - kind = "환율" - if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만 - got = [ - {"키": r["키"], "규격": str(r.get("상세구분") or "")} - for r in elem.oil_rows(line["이름"]) - ] - spec = line["규격"].replace(" ", "") - got = ( - [x for x in got if x["규격"] == spec] - or [x for x in got if spec and spec in x["규격"]] - or got - ) - else: - got = hrow.m01_picks(kind, line) - keys = {x.get("키") for x in got if x.get("키")} - if len(keys) == 1: - return keys.pop(), None - if len(keys) > 1 and kind in ("유가", "환율"): - return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인" - return None, None - - -def hourly_text(key: str | None) -> str | None: - """기계 EQ → 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 키 · 자료 없음 = None.""" - if not key: - return None - from M02_MasterTemplete import M02_Doc_Import as dim - - got = dim.hourly_link(key, "노무비") - if not got: - return None - return f"로직({got['키']}{''.join(f', {k}={v}' for k, v in got['입력'].items())})" - - # ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ───────────────────────────────── def machine_parts(src: dict) -> list[dict]: """기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = [].""" @@ -240,6 +187,13 @@ def machine_parts(src: dict) -> list[dict]: return mfind.find_all(src.get("이름"), src.get("규격")) +def first_spec(one: dict, parts: list[dict]) -> dict: + """조합 기계 첫 줄 규격 = 그 기계 규격(칩 글 · 「0.7+브레이카」 아님 · 814-1) — 자원 원문은 그대로.""" + if len(parts) > 1 and parts[0].get("규격") is not None: + one["규격"] = parts[0]["규격"] + return one + + def part_line(main: dict, part: dict, names: "Names") -> dict: """조합 기계 다음 줄 — 같은 수량 식 · 값 0(파일 경비 = 한 덩이 = 첫 줄) · 제 이음.""" res = {**main["자원"], "이름": part["이름"], "규격": part["규격"], "찾기": part, "조합": True} @@ -248,8 +202,9 @@ def part_line(main: dict, part: dict, names: "Names") -> dict: title = part["후보"][0]["이름"] if part["키"] else part["이름"] one = {"이름": names.item(str(title)), "규격": part["규격"], "단위": main.get("단위") or ""} one |= {"수량": main["수량"], "자원": res} - if main.get("종류") == "로직": - one |= {"종류": "로직", "값묶음": bundle(res), "이음": hourly_text(part["키"])} + if main.get("종류") == "로직": # 우리 조합 식(814-1 `조합식`)에 든 다음 줄 = 이음 없음 · 값 0 + link = None if res.get("조합식") else hourly_text(part["키"]) + one |= {"종류": "로직", "값묶음": bundle(res), "이음": link} else: one |= {"종류": main["종류"], "비목": main["비목"], "단가": Decimal(0), "이음": part["키"]} return one | ({"자리": main["자리"]} if main.get("자리") else {}) @@ -439,6 +394,8 @@ class _Stm: unt = (pr.get("meta") or {}).get("UNT") one_, to = node["자원"]["비목"], node["자원"]["옮김"] name = _label(node) or str(pr.get("이름") or code) + if node.get(_MERGED): # 814-1 — 기계 세 줄(노 · 재 · 경) = 기계 한 줄 · 이름 = 기계 + name = str(pr.get("이름") or code) if unt in ev.SPECIAL: # 특수 % — 처음부터 비목 누계 · 앞 계표시 dst = to or {"L": "L", "M": "M", "S": "S"}.get(code[0]) if unt == "T%": @@ -463,11 +420,13 @@ class _Stm: res |= {"조합첫": True} if len(parts) > 1 else {} else: link, many = _m01(res["갈래"], res) if self.keys else (None, None) - one = resource(o.names.item(name), pr.get("단위"), cf, res, link) + one = first_spec(resource(o.names.item(name), pr.get("단위"), cf, res, link), parts) if parts and one["종류"] == "로직": - one["이음"] = hourly_text(link) + one["이음"] = combo_link(one, parts) or hourly_text(link) self.line(one, node, True, src) - bare = {k: v for k, v in node.items() if k != "번지"} + for at, glyph in node.get(_MERGED) or []: # 묶은 다른 두 줄 원문 = 같은 자리 + o.glyph(at, glyph, one["이름"], self.rows.get(at, {}).get("C")) + bare = {k: v for k, v in node.items() if k not in ("번지", _MERGED)} for p in parts[1:]: self.line(part_line(one, p, o.names), bare, True, src) seen = (res.get("이름"), res.get("규격")) @@ -552,11 +511,84 @@ def _ratios(meta: dict) -> str: return " · ".join(f"{n} {v}" for n, v in pairs if v is not None and abs(_num(v) - 1) > 1e-9) +_MERGED = "_묶음줄" # 세 줄을 묶은 줄 나무 — 나머지 두 줄 [(행, 글)] +_BAD = ("셈 못 함", "엔진 꼴 없음") + + +def triples(nodes: list[dict]) -> list[list[int]]: + """814-1 — 같은 기계(X 코드)의 노 · 재 · 경 세 참조 줄(같은 수량 식 · 번지 · 빼기 · 옮김 없음) 자리 + [[i, j, k], …] — 사이 빈 줄은 건너뜀.""" + out, k = [], 0 + while k < len(nodes): + at, j = [k], k + 1 + while len(at) < 3 and j < len(nodes): + if nodes[j].get("종류") == "빈" and not str(nodes[j].get("글") or "").strip(): + j += 1 + continue + at.append(j) + j += 1 + if len(at) == 3 and _same_machine([nodes[i] for i in at]): + out.append(at) + k = j + else: + k += 1 + return out + + +def merge(nodes: list[dict], groups: list[list[int]]) -> list[dict]: + """세 줄 자리 → 비목 없는 한 줄(값묶음 · 조합이면 그 뒤 기계마다 줄) · 나머지 두 줄 원문은 같은 자리.""" + first = {g[0]: g for g in groups} + drop = {i for g in groups for i in g[1:]} + out = [] + for i, node in enumerate(nodes): + if i in first: + res = {**node["자원"], "비목": None, "옮김": None} + rest = [(nodes[j].get("행"), nodes[j].get("글") or "") for j in first[i][1:]] + out.append({**node, "자원": res, _MERGED: rest}) + elif i not in drop: + out.append(node) + return out + + +def _same_machine(three: list[dict]) -> bool: + if any(x.get("종류") != "참조" or x.get("오류") or not x.get("자원") for x in three): + return False + res = [x["자원"] for x in three] + if not str(res[0].get("코드") or "").startswith("X") or len({r["코드"] for r in res}) != 1: + return False + if sorted(str(r.get("비목")) for r in res) != ["L", "M", "S"] or any( + r.get("옮김") for r in res + ): + return False + return all(len({repr(x.get(k)) for x in three}) == 1 for k in ("식", "번지", "빼기")) + + def stmate( block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool = False, file: str = "" ) -> dict: """STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → {로직, 원본}. - rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기.""" + rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기 · + 기계 세 줄 = 한 줄(`triples` · 814-1) — 묶어서 셈 못 하는 줄이 생기면 묶지 않은 꼴.""" + groups = triples(nodes) + out = _stmate(block, merge(nodes, groups), prices, rule, keys, file) + bad = _bad(out) + if not groups or not bad: + return out + took: list[ + list[int] + ] = [] # 묶어서 셈 못 하는 세 줄만 그대로(율 범위 C · 누계 % 가 비목 하나를 부름) + base = _bad(_stmate(block, nodes, prices, rule, keys, file)) + for g in groups: + if _bad(_stmate(block, merge(nodes, [*took, g]), prices, rule, keys, file)) <= base: + took.append(g) + return _stmate(block, merge(nodes, took), prices, rule, keys, file) + + +def _bad(out: dict) -> int: + return sum(1 for m in out["원본"]["표지"] if any(b in m for b in _BAD)) + + +def _stmate(block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool, file: str) -> dict: me = prices.get(block["코드"]) or {} meta = me.get("meta") or {} o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"]) diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py new file mode 100644 index 000000000..843b27ced --- /dev/null +++ b/M02_MasterTemplete/M02_San_Link.py @@ -0,0 +1,167 @@ +"""경쟁사 산근 줄 → M01 요소 이음(PLAN 814-1 · `M02_San_Emit` 에서 뗌 — 셈 꼴 글 · 표지 그대로). + +`m01` = 1차 줄 판정 유일 짝(인력 · 재료 · 기계 · 유가 · 환율) · `hourly_text` = 기계 시간당 경비 부름 글 · +`combo_link` = 조합 기계(굴착기 + 브레이커 · 집게) 우리 조합 식 부름 글. +""" + +from __future__ import annotations + +import re +import unicodedata + +NOTE = "참고 · " # `M02_San_Emit.NOTE` 와 같음(사람 확인 표지 머리) +OIL_BASE = ("전국평균", "일반용(갑)Ⅰ 저압") # 유가 · 전력 기본(요구 748 · 우리 기초 기본값) +BASE_REGION = "서울" # 같은 이름 · 규격 자재가 지역마다 있을 때 기본(우리 기초 검색어 꼴) +_REGION = re.compile(r"\s*,\s*([가-힣]{2,4})\s*$") # 자재 이름 지역 꼬리 「, 서울」 · 「,서울」 + + +def m01(kind: str, src: dict) -> tuple[str | None, str | None]: + """1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 · + 환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면 + 유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님).""" + if kind not in ("인력", "재료", "기계"): + return None, None + from M02_MasterTemplete import M02_Hopyo_Elem as elem + from M02_MasterTemplete import M02_Hopyo_Row as hrow + + line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} + if ( + kind == "기계" + ): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음) + from M02_MasterTemplete import M02_Machine_Find as mfind + + got = mfind.find(line["이름"], line["규격"]) + # 찾기 판정 없는 기계 줄 막음(814-1 · 줄 쌓기 표지 · 별칭) + src.setdefault("찾기", {**got, "이름": got.get("이름") or line["이름"], "규격": got.get("규격") or line["규격"]}) # fmt: skip + return got["키"], mfind.mark(got, line["이름"], line["규격"]) + if kind == "재료": + if elem.oil_rows(line["이름"]): + kind = "유가" + elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}: + kind = "환율" + if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만 + got = [ + {"키": r["키"], "규격": str(r.get("상세구분") or "")} + for r in elem.oil_rows(line["이름"]) + ] + spec = line["규격"].replace(" ", "") + got = ( + [x for x in got if x["규격"] == spec] + or [x for x in got if spec and spec in x["규격"]] + or got + ) + else: + got = hrow.m01_picks(kind, line) + 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 len(keys) == 1: + return keys.pop(), None + if len(keys) > 1 and kind in ("유가", "환율"): + return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인" + return None, None + + +def _flat(text: object) -> str: + return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or ""))).lower() + + +def region_of(name: object) -> tuple[str, str]: + """자재 이름 → (지역 꼬리 뗀 이름, 지역 | 빈 글).""" + text = str(name or "") + m = _REGION.search(text) + return (text[: m.start()], m.group(1)) if m else (text, "") + + +def exact(line: dict, got: list[dict]) -> set[str]: + """재료 후보 가운데 지역 꼬리 뗀 이름 · 규격이 줄과 정확히 같은 것(빈칸 · 전각 · 대소문자 정규화) — + 지역마다 여럿이면 줄 지역 › 서울 › 같은 단위 › 키 차례 첫째 하나 · 없음 = 빈 모음(사람 확인).""" + name, mine = region_of(line.get("이름")) + want = (_flat(name), _flat(line.get("규격"))) + hits = [x for x in got if x.get("키") and (_flat(region_of(x["이름"])[0]), _flat(x.get("규격"))) == want] # fmt: skip + if not hits: + return set() + unit = _flat(line.get("단위")) + + def rank(x: dict) -> tuple: + where = region_of(x["이름"])[1] + return (where != mine if mine else True, where != BASE_REGION, _flat(x.get("단위")) != unit, str(x["키"])) # fmt: skip + + return {min(hits, key=rank)["키"]} + + +def hourly_text(key: str | None) -> str | None: + """기계 EQ → 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 키 · 자료 없음 = None.""" + if not key: + return None + from M02_MasterTemplete import M02_Doc_Import as dim + + got = dim.hourly_link(key, "노무비") + if not got: + return None + return f"로직({got['키']}{''.join(f', {k}={v}' for k, v in got['입력'].items())})" + + +def combo_link(one: dict, parts: list[dict]) -> str | None: + """조합 기계 둘이 우리 조합 식(GC000270 · 271) 고르기에 있으면 그 부름 글 · 줄 자원에 `조합식` 키.""" + from M02_MasterTemplete import M02_Machine_Hourly as mh + + got = mh.combo(parts[0]["키"], parts[1]["키"]) if len(parts) == 2 and all(p["키"] for p in parts) else None # fmt: skip + if got: + one["자원"]["조합식"] = got[0] + return got[1] if got else None + + +# ── 못 이은 줄 까닭(814-1 · 요구 745 C2) ───────────────────────────────── +FREIGHT = ("운임", "화물", "운반비", "하차") + + +def why(item: dict) -> str: + """이음 없는 가져온 줄 → 까닭 한 마디(문서 설명 · 띠 · 원문 카드) — 「없음」은 흠이 아니라 우리 정보로 + 못 이은 까닭(자재 없음 · 운임표 없음 · 기계 표에 없음 · 별칭 대기 · 후보 여럿 · 시간당 경비 자료 없음 · 갈래 밖).""" + res = item.get("자원") or {} + kind = res.get("갈래") or item.get("종류") + name = str(res.get("이름") or item.get("이름") or "") + if res.get("조합식"): + return f"조합 — 우리 조합 식 {res['조합식']} 에 듦" + if kind == "기계": + found = res.get("찾기") or {} + verdict = found.get("판정") + if verdict == "못 찾음": + return ( + "별칭 대기" + if str(found.get("까닭") or "").startswith("기종 모름") + else "기계 표에 없음" + ) + if verdict == "후보 여럿": + return "기계 후보 여럿 — 고름" + return "시간당 경비 자료 없음" if found.get("키") else "기계 찾기 없음" + if kind == "재료": + from M02_MasterTemplete import M02_Hopyo_Elem as elem + from M02_MasterTemplete import M02_Hopyo_Row as hrow + + if elem.oil_rows(name): + return "유가 후보 여럿 — 고름" + line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""} + if hrow.m01_picks("재료", line): + return "자재 후보 여럿 — 고름" + return "운임표 없음" if any(w in name for w in FREIGHT) else "자재 없음(우리 자재표)" + if kind == "인력": + return "노임 없음" + if kind == "단가산출": + return "안쪽 단가산출 못 이음" + return "갈래 밖(견적 · 고정 값)" + + +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("조합식") + ) + 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_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 6ea2aedd0..9aed1d97d 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -94,10 +94,13 @@ def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[ native = fn.read(name, data) except fn.NativeError as e: raise store.StoreError(400, str(e)) from e - row = hopyo_store.save(folder, name, fn.hopyo(native), who, fn.grid(native), mark) - path = _path(folder, row["키"]) - path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json(path, {"파일": name, "원파일": native}) + + def first(key: str) -> None: # HP 를 목록에 보이기 전에 산근 보관(814-1) + path = _path(folder, key) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, {"파일": name, "원파일": native}) + + row = hopyo_store.save(folder, name, fn.hopyo(native), who, fn.grid(native), mark, first) return {**row, "원파일": True} diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index 0ecc5c8e3..cda813d77 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -304,11 +304,13 @@ def same_part(doc: Any) -> dict | None: def unlinked(doc: dict) -> list[dict]: - """이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름}]`.""" + """이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭}]`(까닭 = 814-1 `M02_San_Link.why`).""" + from M02_MasterTemplete import M02_San_Link as link + row = row_of(doc) or {} ids = sd.line_ids(row) return [ - {"자리": ids[n], "이름": one.get("이름") or ""} + {"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)} for n, one in enumerate(doc[KEY]["로직"].get("호표") or []) if not one.get("이음") and not one.get("참조") ] diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Match.ts b/M02_MasterTemplete/logic/M02_Sangeun_Match.ts index f655d15a2..c1f61d34b 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Match.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Match.ts @@ -22,6 +22,8 @@ export interface MatchFound { 금액?: { 가져온?: Record; 우리?: Record }; 후보수?: number; 가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] }; + /** 우리 정보로 셈(814-1) — 자원 줄 이음 수 · 형제 단가산출 부름뿐 = 조합 */ + 우리정보?: { 됨?: number; 줄?: number; 조합?: boolean }; } export type MatchAction = "찾기" | "바꾸기" | "되돌리기" | null; @@ -114,9 +116,13 @@ export function matchBand(sk: SkLike | null | undefined): MatchBand | null { }; } const near = f.가까움; + const mine = f.우리정보; // 「없음」 = 흠 아님(814-1 · 요구 745) — 우리 정보로 다시 만든 정도 + const made = mine?.줄 ? ` — 우리 정보로 셈 ${mine.됨 ?? 0}/${mine.줄} 줄` : ""; return { tone: "info", - text: `없음 — 우리 기초 후보 ${f.후보수 ?? 0} 개와 맞지 않음`, + text: mine?.조합 + ? `조합 — 다른 단가산출 ${mine.줄 ?? 0} 개 묶음(안의 단가산출로 셈)` + : `우리 기초 없음${made}`, details: near ? [`가장 가까운 ${near.키 ?? ""} ${near.이름 ?? ""}`, ...lines(near.다른)] : [], action: null, }; diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts index 7e0995116..aaefade58 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Origin.ts @@ -42,6 +42,8 @@ export interface Origin { 요약?: string; /** 원파일 확정 「확정 N · 표지 N · 추정 N · 확정률 N %」 */ 확정?: string; + /** 확정 칸 이름 — 원파일 = 「원파일 확정」 · 엑셀 = 「엑셀 읽기 확정」(814-1) */ + 확정이름?: string; } const BOOKS: [RegExp, string][] = [ @@ -111,6 +113,7 @@ export function originOf(meta: OriginMeta | null | undefined): Origin | null { ...(sure ? { 확정: `확정 ${sure.확정 ?? 0} · 표지 ${sure.표지 ?? 0} · 추정 ${sure.추정 ?? 0} · 확정률 ${(Math.floor(rate * 1000) / 10).toFixed(1)} %`, + 확정이름: src.원파일 ? "원파일 확정" : "엑셀 읽기 확정", } : {}), }; diff --git a/resources/tester/helper_810_4_sweep.py b/resources/tester/helper_810_4_sweep.py index 69b3dcdb5..e11dff597 100644 --- a/resources/tester/helper_810_4_sweep.py +++ b/resources/tester/helper_810_4_sweep.py @@ -63,7 +63,10 @@ def file_docs(t, dim, dm, whole, kind: str, tag: str) -> dict[int, dict]: continue for v in doc["변수"]: if dm.md.var_id(v) == line["단가"] and (v.get("연결") or {}).get("갈래") == "숫자": - v["연결"] = {"갈래": "숫자", "값": {k: str(got[k]) for k in dm.md.COSTS}} + v["연결"] = { + "갈래": "숫자", + "값": {k: got[k] for k in dm.md.COSTS}, + } # 수 그대로(814-1) made[no] = doc ours[no] = dm._calc(doc, links, {}) diff --git a/resources/tester/test_810_4_match.py b/resources/tester/test_810_4_match.py index a50d6f3f6..90dc120fb 100644 --- a/resources/tester/test_810_4_match.py +++ b/resources/tester/test_810_4_match.py @@ -65,13 +65,17 @@ def test_sure_one_block() -> None: assert got["금액"]["가져온"] == got["금액"]["우리"] -def test_near_formwork_lists_differences() -> None: - got = judge(41) # 합판거푸집 4회 - assert got["판정"] == dm.NEAR and got["키"] == "GC000211" +def test_sure_formwork_tool_cost_is_value() -> None: + got = judge( + 41 + ) # 합판거푸집 4회 — 공구손료 재료비 ↔ 경비 = 값(814-1 · 요구 744 C1 · 옛 812-2 = 로직) + assert got["판정"] == dm.SURE and got["키"] == "GC000211" assert got["옵션"]["사용횟수"] == "4회" and got["옵션"]["유형"] == "보통" - kinds = {d["항"] for d in got["다른"]} - assert kinds == {"비목"} # 공구손료 1 % 재료비 ↔ 경비 = 로직(812-2) - assert [d["항"] for d in got["참고"]] == ["단가 차이"] # 대전 합판 단가 = 값(참고) + assert not got["다른"] + assert [d["항"] for d in got["참고"]] == [ + "단가 차이", + "공구손료 비목", + ] # 대전 합판 단가 · 비목 = 값 def test_req704_pipe_is_near_with_unit_rate_machine() -> None: @@ -155,7 +159,7 @@ def test_swap_only_when_sure_and_back(world) -> None: with store.at(folder): hp = fh.upload_native(folder, est.name, est.read_bytes(), t2.WHO)["키"] sure = fh.make(folder, hp, 33)["이름"] - near = fh.make(folder, hp, 41)["이름"] + near = fh.make(folder, hp, 19)["이름"] # 파형강관(근접 — 41 은 814-1 로 확정) old = store.read("sangeun", sure) money0 = _money(sure) found = msk.find(sure, old["판"]) diff --git a/resources/tester/test_812_2_match_logic.py b/resources/tester/test_812_2_match_logic.py index 36501fbec..e10095f46 100644 --- a/resources/tester/test_812_2_match_logic.py +++ b/resources/tester/test_812_2_match_logic.py @@ -122,10 +122,10 @@ def _judge(no: int) -> dict: def test_formwork_rate_ratio_pairs() -> None: - got = _judge(41) # 합판거푸집 4회 ↔ GC000211 - assert got["판정"] == dm.NEAR and got["키"] == "GC000211" - assert [d["항"] for d in got["다른"]] == ["비목"] and "1 %" in got["다른"][0]["글"] - assert [d["항"] for d in got["참고"]] == ["단가 차이"] + got = _judge(41) # 합판거푸집 4회 ↔ GC000211 — 공구손료 비목 차이 = 값(814-1 · 요구 744 C1) + assert got["판정"] == dm.SURE and got["키"] == "GC000211" and not got["다른"] + assert [d["항"] for d in got["참고"]] == ["단가 차이", "공구손료 비목"] + assert "1 %" not in got["참고"][1]["글"] and "재료비" in got["참고"][1]["글"] def test_req704_still_near() -> None: diff --git a/resources/tester/test_812_9_machine_link.py b/resources/tester/test_812_9_machine_link.py index 7e3940e75..854e71477 100644 --- a/resources/tester/test_812_9_machine_link.py +++ b/resources/tester/test_812_9_machine_link.py @@ -198,7 +198,10 @@ def test_combo_money_same() -> None: blk = fn.numbers(saved)[2] sk, doc, money = doc_and_money(fn.emit(saved, 2)) names = [(m["이름"], m.get("규격")) for m in doc["금액"]] - assert ("굴삭기(무한궤도)", "0.7+브레이카") in names and ("대형 브레이커", "0.7㎥") in names + assert ("굴삭기(무한궤도)", "0.7") in names and ( + "대형 브레이커", + "0.7㎥", + ) in names # 첫 줄 규격 = 그 기계(814-1) assert float(money["계"]) == pytest.approx(float(blk["저장"]["T"]), abs=1) found = {x["원래"]["이름"]: x for x in doc["가져온"]["기계"]} assert found["브레이카"]["조합"] is True and found["브레이카"]["찾기어"] == "대형 브레이커" diff --git a/resources/tester/test_814_1_import_decisions.py b/resources/tester/test_814_1_import_decisions.py new file mode 100644 index 000000000..b4911e398 --- /dev/null +++ b/resources/tester/test_814_1_import_decisions.py @@ -0,0 +1,260 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""814-1 지킴 — 불러오기 결정 열 가지(방향 장부 `_방향_불러오기.md` E 표 · 요구 742 ~ 750). + +① 파일 하나(같은 공사 붙이기 걷음) ② 확장자 파일 = 단가산출표 판(산출근거 머리) · 일위대가 없음 · +③ 엑셀 이름 규격 · 단위 꼬리 뗀 맞대기 ④ 기계 세 줄 = 한 줄(STC · EST · 엑셀) · 조합 다음 줄 기계마다 한 항목 · +⑤ 계산식 기계 목록(빠진 기계 더함 · 기본값 그대로) · 조합 = 우리 조합 식 ⑥ 공구손료 비목 = 참고 · +⑦ 못 이은 줄 까닭 · 「없음」 띠 ⑧ 유가 = 전국평균 + 지역 옵션 · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 · +⑨ 재료 지역 꼬리 뗀 정확 일치 ⑩ 가벼움(조합 첫 줄 규격 · 소계 검산 · 기계 찾기 판정 · 409 · 확정 글 · 전수 도우미). +원파일 · 실무 엑셀 없으면 그 시험만 skip(다른 PC). +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for p in (ROOT, HERE): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +import test_797_1a_import_flags as practice # noqa: E402 +import test_810_2_native_doc as t # noqa: E402 + +from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402 +from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402 +from M02_MasterTemplete import M02_Doc_MatchSk as msk # noqa: E402 +from M02_MasterTemplete import M02_Doc_Match_Note as note # noqa: E402 +from M02_MasterTemplete import M02_Doc_NativeLayout as lay # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Store as hs # noqa: E402 +from M02_MasterTemplete import M02_Machine_Hourly as mh # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_San_Emit as em # noqa: E402 +from M02_MasterTemplete import M02_San_Link as link # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Imported as imp # noqa: E402 +from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층 + +SCRIPT = ROOT / "resources/master_data/scripts/build_기계목록_빠진기계_814_1.py" +FAMILY = [f"GC0002{n}" for n in range(67, 77)] + + +def excel(tag: str) -> dict: + path = practice.FILES.get(tag) + if not path or not path.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + from M02_MasterTemplete import M02_San_Xlsx as sx + + return fn.pack(sx.read(path.name, path.read_bytes()), path.name) + + +def doc_of(saved: dict, no: int) -> tuple[dict, dict, dict]: + out = fn.emit(saved, no) + sk, doc, got = t.doc_and_money(out) + return out, doc, got + + +def same_money(saved: dict, no: int, got: dict) -> bool: + want = fn.numbers(saved)[no].get("저장") or {} + return all(abs(float(got[k]) - float(want.get(c) or 0)) < 0.5 for c, k in t.COST[:3]) + + +# ── ① ② 파일 하나 · 단가산출표 판 ────────────────────────────────────────── +def test_one_file_and_list_sheet(world) -> None: + assert not hasattr(fn, "same") and not hasattr(fh, "_natives") # 붙이기 길 걷음 + stc = t._file(t.STC["봉화"]) + folder = world["personal"] + with store.at(folder): + got = fh.upload_native(folder, stc.name, stc.read_bytes(), t.WHO) + assert got["원파일"] and got["호표수"] == 0 and "붙음" not in got + grid = hs.grid(folder, got["키"]) + blocks = fn.numbers(t.native("STC", "봉화")) + assert grid["보기"] == {"단가산출표": "s1"} and len(grid["단산"]) == len(blocks) + head = grid["책"]["시트"][0]["칸"] + assert [head[f"{c}1"]["값"] for c in "ABCDEFGH"] == list(fn._LIST_HEAD) + first = next(iter(blocks.values())) + assert head["A2"]["값"] == first["코드"] and head["H2"]["값"] == first["저장"]["T"] + est = fn.hopyo(t.native("EST", "거창")) + assert est["호표"] == [] and len(est["단산"]) == len(fn.numbers(t.native("EST", "거창"))) + + +# ── ③ 꼬리 뗀 맞대기 ───────────────────────────────────────────────────── +def test_bare_name_tail() -> None: + doc = { + "이름": "구조물터파기(인력) 경질토사 / M3", + "결과단위": "원/M3", + "가져온": {"규격": "경질토사"}, + } + assert note.bare(doc)["이름"] == "구조물터파기(인력)" and doc["이름"].endswith("/ M3") + plain = {"이름": "측구터파기", "결과단위": "원/㎥", "가져온": {"규격": "토사"}} + assert note.bare(plain) is plain + + +# ── ④ 세 줄 묶기 · 조합 그룹 ───────────────────────────────────────────── +def _machine_split(doc: dict, out: dict) -> int: + """문서에 남은 기계 비목 낱 줄(같은 기계 · 같은 수량 · 비목 셋이 따로) 묶음 수.""" + names = {h["이름"]: h for h in out["로직"]["호표"]} + seen: dict[tuple, set] = {} + for m in doc["금액"]: + res = (names.get(m.get("이름")) or {}).get("자원") or {} + if res.get("갈래") == "기계" and m.get("비목") in ("노무비", "재료비", "경비"): + seen.setdefault((res.get("코드"), str(m.get("수량"))), set()).add(m["비목"]) + return sum(1 for v in seen.values() if len(v) == 3) + + +def test_three_lines_one_machine_stc() -> None: + saved = t.native("STC", "봉화") + out, doc, got = doc_of(saved, 55) + assert same_money(saved, 55, got) and _machine_split(doc, out) == 0 + crawl = next(m for m in doc["금액"] if str(m["이름"]).startswith("크롤러드릴")) + assert crawl["비목"] == "묶음" # 「노 무 비 · 재 료 비 · 경 비」 → 기계 한 줄 + assert not any(str(h["이름"]).replace(" ", "") == "노무비" for h in out["로직"]["호표"]) + + +def test_three_lines_excel_stmate_and_est_combo_group() -> None: + saved = excel("봉화") + out, doc, got = doc_of(saved, 55) + assert same_money(saved, 55, got) and _machine_split(doc, out) == 0 + est = t.native("EST", "main") + out, doc, got = doc_of(est, 360) + assert same_money(est, 360, got) + titles = [it["제목"] for it in lay.stack_of(doc)] + assert titles.count("덤프트럭 자동덮개시설 15톤용") == 1 # 다음 줄 셋 = 한 항목(엇갈림 없음) + + +# ── ⑤ 계산식 기계 목록 · 조합 식 ───────────────────────────────────────── +def _build(): + spec = importlib.util.spec_from_file_location("build_814_1", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_machine_lists_complete_and_defaults_kept() -> None: + build = _build() + add, skipped = build.plan() + assert add == {} # 빠진 기계 모두 더함(다시 돌려도 같음) + assert all(miss for *_, miss in skipped) # 못 넣음 = 기계표 빈 값(지어 넣지 않음) + docs = json.loads(build.DOC.read_text("utf-8"))["줄"] + for d in docs: + if d["키"] in FAMILY: + var = next(v for v in d["변수"] if v["열쇠"] == "기계") + assert var["연결"]["값"] == var["연결"]["목록"][0] # 기본값 = 첫 기계 그대로 + whole = imp._whole() + eq = {r.get("원문번호"): k for k, r in whole.index["EQ"].items()} + for code in ("0230-0007", "0211-0060", "6630-0005", "0213-0060"): # 브레이커 · 굴착기(타이어) … + if code in eq: + assert mh.hourly(eq[code])["ok"], code + + +def test_combo_uses_our_combo_formula() -> None: + saved = excel("거창") + out, doc, got = doc_of(saved, 2) + assert same_money(saved, 2, got) + main = next(h for h in out["로직"]["호표"] if (h.get("자원") or {}).get("조합첫")) + part = next(h for h in out["로직"]["호표"] if (h.get("자원") or {}).get("조합")) + assert str(main["이음"]).startswith("로직(GC000271") and part["이음"] is None + assert "조합" in link.why(part) + assert main["규격"] != (main["자원"] or {}).get("규격") # 칩 글 = 그 기계 규격(조합 글 아님) + + +# ── ⑥ 공구손료 비목 = 참고 ─────────────────────────────────────────────── +def test_tool_rate_cost_is_value() -> None: + a = { + "갈래": "율", + "이름": "공구손료", + "율": "3", + "대상": {("인력", "노무비")}, + "비목": ["재료비"], + } + b = {**a, "비목": ["경비"]} + left, right = [a], [b] + + def take(test): + hits = [(x, y) for x in list(left) for y in list(right) if test(x, y)][:1] + for x, y in hits: + left.remove(x), right.remove(y) + return hits + + diffs, notes = note.rate_pairs(left, right, take) + assert not diffs and notes[0]["항"] == "공구손료 비목" + + +# ── ⑧ ⑨ 유가 · 전력 · 재료 ─────────────────────────────────────────────── +def test_oil_power_material_links() -> None: + oil, _ = link.m01("재료", {"이름": "경 유", "규격": "", "단위": "ℓ"}) + power, _ = link.m01("재료", {"이름": "전 력", "규격": "", "단위": "KWH"}) + whole = imp._whole() + assert whole.get(oil)["상세구분"] == "전국평균" + assert whole.get(power)["상세구분"] == "일반용(갑)Ⅰ 저압" + got = dim._oil(oil) + assert got["키"] == "MO:경유:{S_지역}" and got[dim.OPTION]["연결"]["값"] == "전국평균" + got = dim._oil(power) + assert got["계약종별"] == "S_전력계약종별" + assert got[dim.OPTION]["연결"]["값"] == "일반용(갑)Ⅰ 저압" + ply, _ = link.m01("재료", {"이름": "합판(내수)", "규격": "12t×1220×2440mm", "단위": "㎡"}) + nail, _ = link.m01("재료", {"이름": "철못", "규격": "N 25", "단위": "kg"}) + assert whole.get(ply)["이름"] == "합판(내수), 서울" and whole.get(nail)["규격"] == "N 25" + assert link.m01("재료", {"이름": "접착제", "규격": "", "단위": "kg"}) == (None, None) + + +def test_oil_doc_region_option() -> None: + saved = excel("2016사방") + out, doc, got = doc_of(saved, 326) + assert same_money(saved, 326, got) + region = next(v for v in doc["변수"] if v["열쇠"] == "지역") + assert region["연결"]["값"] == "전국평균" + oil = next(v for v in doc["변수"] if str((v.get("연결") or {}).get("키", "")).startswith("MO:")) + assert "엑셀" in oil["연결"] # 엑셀 값 = 검산용 + + +# ── ⑦ 까닭 · 띠 ────────────────────────────────────────────────────────── +def test_unlinked_reasons_and_tally(monkeypatch) -> None: + from M02_MasterTemplete import M02_Hopyo_Row as hrow + + monkeypatch.setattr(hrow, "m01_picks", lambda kind, line: []) # 우리 자재표에 후보 없음 + assert link.why({"자원": {"갈래": "재료", "이름": "없는자재글자", "규격": ""}}).startswith( + "자재 없음" + ) + assert ( + link.why({"자원": {"갈래": "재료", "이름": "구역화물 운임", "규격": ""}}) == "운임표 없음" + ) + gone = {"자원": {"갈래": "기계", "찾기": {"판정": "못 찾음", "까닭": "기종 모름"}}} + assert link.why(gone) == "별칭 대기" + sib = {"호표": [{"이름": "a", "참조": {"키": "SK-1"}, "자원": {"갈래": "단가산출"}}]} + assert link.tally(sib) == {"됨": 1, "줄": 1, "조합": True} + src = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Match.ts").read_text("utf-8") + assert "우리 기초 없음" in src and "우리 정보로 셈" in src + assert "우리정보" in Path(msk.__file__).read_text("utf-8") + + +# ── ⑩ 가벼움 ───────────────────────────────────────────────────────────── +def test_light_fixes(world) -> None: + saved = excel("북부청임도") + out = fn.emit(saved, 33) + chk = imp.check(t.sk_of(out), t.whole()) + assert chk["같음"] and all(x["같음"] for x in chk["줄"]) # 얼린 줄도 소계 식에 + src = {"이름": "진동 로울러(핸드가이드식)", "규격": "0.7Ton", "단위": "hr"} + link.m01("기계", src) + assert src["찾기"]["판정"] and src["찾기"]["이름"] # 기계 찾기 판정 붙음 + seen = [] + folder = world["personal"] + with store.at(folder): + + def first(key: str) -> None: # HP 를 목록에 보이기 전에 산근 보관(409 막음) + seen.append(any(r["키"] == key for r in hs.rows(folder))) + + hs.save(folder, "가.xlsx", {"호표": [], "단산": []}, t.WHO, None, "지문814", first) + assert seen == [False] + origin = (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Origin.ts").read_text("utf-8") + assert "엑셀 읽기 확정" in origin + helper = (HERE / "helper_810_4_sweep.py").read_text("utf-8") + assert "str(got[k])" not in helper + one = em.first_spec({"규격": "0.7+브레이카"}, [{"규격": "0.7㎥"}, {"규격": "0.7"}]) + assert one["규격"] == "0.7㎥"