From f1abfc2fb1df866c71b559c735d32bd37c848a1d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 9 Nov 2026 09:38:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(812-2):=20=EC=97=B0=EA=B2=B0=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EB=8C=80=EC=A1=B0=20=EB=8B=A4?= =?UTF-8?q?=EB=93=AC=EC=9D=8C=20=E2=80=94=20=EC=9B=90=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=EA=B2=BD=EB=B9=84=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=E2=86=94=20=EC=9A=B0=EB=A6=AC=20=EA=B8=B0=EA=B3=84=20=3D=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A2=85=EB=A5=98=20=EC=A7=9D=20=C2=B7=20?= =?UTF-8?q?=EC=9C=A8=20=EB=8C=80=EC=83=81=20=3D=20(=EC=9E=90=EC=9B=90=20?= =?UTF-8?q?=EA=B0=88=EB=9E=98,=20=EB=B9=84=EB=AA=A9)=20=C2=B7=20=EB=B2=88?= =?UTF-8?q?=EC=A7=80=20=ED=95=A9=20=C2=B7=20=EB=B9=84=EB=AA=A9=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=AA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기계경비 숫자 묶음(코드 없음) ↔ 우리 기계 = 이름 머리로 같은 기계(로울러 = 롤러 · 물탱크 = 살수차 등) · 시간당 단가 · 구성 = 참고 - 로직 같은 짝을 먼저 지음(같은 수량 줄 엇갈림 막음) · 한쪽 단가 없음(0) 비목 = 값 - 율 = 번지 합이면 핵심 줄 전부 · 「노무비 × n %」 = 그 비목 몫(기계 운전 노무비 포함) · 대상 = (갈래, 비목) 묶음 - test_812_2 — 기계 이름 짝 · 율 대상 범위 시험 더함 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Doc_Match.py | 9 ++- M02_MasterTemplete/M02_Doc_Match_Note.py | 81 +++++++++++++++------- resources/tester/test_812_2_match_logic.py | 62 ++++++++++++++++- 3 files changed, 124 insertions(+), 28 deletions(-) diff --git a/M02_MasterTemplete/M02_Doc_Match.py b/M02_MasterTemplete/M02_Doc_Match.py index d100ed069..b17f70508 100644 --- a/M02_MasterTemplete/M02_Doc_Match.py +++ b/M02_MasterTemplete/M02_Doc_Match.py @@ -440,7 +440,14 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]: ) ) same += len(exact) - for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["수량"] == b["수량"]): + + def twin(a: dict, b: dict) -> bool: + return core(a, b) and cls(a, b) and a["수량"] == b["수량"] + + for a, b in take(lambda a, b: twin(a, b) and not note.pair(a, b)[0]): # 로직 같은 짝 먼저 + same += 1 + notes += note.pair(a, b)[1] + for a, b in take(twin): bad, ref = note.pair(a, b) same, paired = (same + 1, paired) if not bad else (same, paired + 1) diffs += bad diff --git a/M02_MasterTemplete/M02_Doc_Match_Note.py b/M02_MasterTemplete/M02_Doc_Match_Note.py index a7024b0d0..8b72ba8db 100644 --- a/M02_MasterTemplete/M02_Doc_Match_Note.py +++ b/M02_MasterTemplete/M02_Doc_Match_Note.py @@ -3,7 +3,7 @@ 확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기계 규격 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 · 율 구조(있고 없음 · 대상 비목 · 율 = 비율) · 수량(옵션 풀이 뒤 · 원 단위 같아야 함). 「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 그로 인한 비목 금액 · 계. -율 = 율 줄 셈값 ÷ 대상 줄 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 줄 · +율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 · 대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님. """ @@ -57,7 +57,7 @@ def _short(r: Decimal) -> Decimal: def rates(doc: dict, got: dict, facts: list[dict]) -> None: - """율 사실에 `율`(비율 · 못 셈 None) · `대상`(대상 비목 묶음)을 채움(제자리).""" + """율 사실에 `율`(비율 · 못 셈 None) · `대상`({(자원 갈래, 비목)})을 채움(제자리).""" money, rows = doc.get("금액") or [], got.get("줄") or [] direct = _reach(doc) core = [f for f in facts if f["갈래"] != "율" and not f["합밖"]] @@ -67,33 +67,37 @@ def rates(doc: dict, got: dict, facts: list[dict]) -> None: line, row = money[f["번"]], rows[f["번"]] reach = _close(_names(line.get("수량")) | _names(line.get("단가")), direct) costs = reach & set(md.COSTS) - hits = [] - for c in core: - mine = money[c["번"]] - parts = _names(mine.get("수량")) | _names(mine.get("단가")) - if ( - (mine.get("기호") and str(mine["기호"]) in reach) - or (parts and parts <= reach) - or (set(c["비목"]) & costs) - ): - hits.append(c) - f["대상"] = frozenset(k for c in hits for k in c["비목"]) + hits = [c for c in core if _hit(money[c["번"]], reach)] + if hits: # 줄 기호 · 줄 수량 · 단가 이름 = 그 줄 금액 전부 + parts = [(c, tuple(c["비목"])) for c in hits] + elif costs: # 비목 이름(「노무비 × 4 %」) = 그 비목 몫만 + parts = [(c, tuple(k for k in c["비목"] if k in costs)) for c in core] + else: # 번지 합 · 못 찾음 = 블록 핵심 줄 전부 + parts = [(c, tuple(c["비목"])) for c in core] + parts = [(c, ks) for c, ks in parts if ks] + f["대상"] = frozenset((_cls(c["갈래"]), k) for c, ks in parts for k in ks) f["율"] = None amount = _mul(row.get("수량"), row.get("단가")) if amount is None: continue - bases = [ - sum((_num(rows[c["번"]].get("금액")) for c in hits), Decimal(0)), - sum( - (_mul(rows[c["번"]].get("수량"), rows[c["번"]].get("단가")) or 0 for c in hits), - Decimal(0), - ), - ] + bases = [sum((_num(c["비목"][k]) for c, ks in parts for k in ks), Decimal(0))] + if hits: + each = (_mul(rows[c["번"]].get("수량"), rows[c["번"]].get("단가")) for c in hits) + bases.append(sum((x or 0 for x in each), Decimal(0))) found = [_short(amount / b) for b in bases if b] if found: f["율"] = min(found, key=lambda r: len(r.as_tuple().digits)) +def _hit(line: dict, reach: set[str]) -> bool: + parts = _names(line.get("수량")) | _names(line.get("단가")) + return bool(line.get("기호") and str(line["기호"]) in reach) or bool(parts and parts <= reach) + + +def _cls(kind: str) -> str: + return "기계" if kind == "산출" else kind # 기계경비 묶음(코드 없음) = 기계 + + def _num(v: Any) -> Decimal: if isinstance(v, dict): return sum((_num(v.get(k)) for k in md.COSTS), Decimal(0)) @@ -126,28 +130,57 @@ def txt(v: Any) -> str: return str(v) -def _grams(text: str) -> set[str]: +_ALIAS = (("로울러", "롤러"), ("굴삭", "굴착"), ("브레이카", "브레이커"), ("로우더", "로더")) +_ALIAS += (("물탱크", "살수차"), ("아스팔트페이버", "아스팔트피니셔")) # 같은 기계 다른 이름 + + +def _plain(text: str) -> str: t = re.sub(r"\s", "", text) + for old, new in _ALIAS: + t = t.replace(old, new) + return t + + +def _head(text: str) -> str: + """기계 이름 머리(괄호 · 규격 앞 한글) — 「머캐덤로울러(자주식)」 → 「머캐덤롤러」.""" + m = re.match(r"[가-힣]+", _plain(text)) + return m.group(0) if m else "" + + +def _same_machine(a: str, b: str) -> bool: + x, y = _head(a), _head(b) + return bool(x and y) and (x in y or y in x) + + +def _grams(text: str) -> set[str]: + t = _plain(text) return {run[i : i + 2] for run in re.findall(r"[가-힣]+", t) for i in range(len(run) - 1)} def pair(a: dict, b: dict) -> tuple[list[dict], list[dict]]: """수량 같은 짝 줄 → (다른 항, 참고) — 단가만 다르면 참고 · 자원 · 비목이 다르면 다른 항.""" diffs: list[dict] = [] + kinds = {a["갈래"], b["갈래"]} + # 기계경비 숫자 묶음(코드 없음 · 원파일 값) ↔ 우리 기계 = 이름 종류로 같은 기계인지 + loose = kinds == {"기계", "산출"} and not (a if a["갈래"] == "산출" else b)["코드"] if a["갈래"] == b["갈래"] == "기계" and a["코드"] != b["코드"]: diffs.append({"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']}"}) + elif loose and not _same_machine(a["이름"], b["이름"]): + diffs.append({"항": "기계 규격", "글": f"{a['이름']} ↔ {b['이름']} {b['코드']}"}) elif a["갈래"] == b["갈래"] == "노임" and a["코드"] != b["코드"]: diffs.append({"항": "자원", "글": f"{a['이름']} {a['코드']} ↔ {b['이름']} {b['코드']}"}) elif a["갈래"] == b["갈래"] == "재료" and not _same_stuff(a, b): diffs.append({"항": "자원", "글": f"{a['이름']} ↔ {b['이름']}"}) - if set(a["비목"]) != set(b["비목"]): + machine = a["갈래"] == b["갈래"] == "기계" or loose # 시간당 단가 구성(운전 노무비 등) = 값 + priced = a["비목"] and b["비목"] # 한쪽 단가 없음(0) = 값 + if priced and not machine and set(a["비목"]) != set(b["비목"]): diffs.append( {"항": "비목", "글": f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])}"} ) if a["단가"] == b["단가"]: return diffs, [] say = f"{a['이름']} 단가 {txt(a['단가'])} ↔ {txt(b['단가'])}" - if a["갈래"] == b["갈래"] and a["갈래"] in PRICED: + if (a["갈래"] == b["갈래"] and a["갈래"] in PRICED) or loose: return diffs, [{"항": "단가 차이", "글": say}] return [*diffs, {"항": "산출 단가", "글": say}], [] @@ -175,7 +208,7 @@ def rate_pairs(left: list[dict], right: list[dict], take: Any) -> list[dict]: return f"{a['이름']} {pct(a.get('율'))} ↔ {b['이름']} {pct(b.get('율'))}" def target(x: dict) -> str: - return "·".join(sorted(x.get("대상") or ())) or "없음" + return "·".join(f"{k} {c}" for k, c in sorted(x.get("대상") or ())) or "없음" take(lambda a, b: same(a, b, "율", "대상", "비목")) # 로직 같음 for a, b in take(lambda a, b: same(a, b, "율", "대상")): diff --git a/resources/tester/test_812_2_match_logic.py b/resources/tester/test_812_2_match_logic.py index 0d357b7db..cb5ee9703 100644 --- a/resources/tester/test_812_2_match_logic.py +++ b/resources/tester/test_812_2_match_logic.py @@ -1,6 +1,7 @@ """812-2 지킴 — 연결 찾기 확정 = 로직(요구 712) · 값 차이 = 참고(`M02_Doc_Match_Note`). -(a) 단가만 다름 = 확정 + 참고 「단가 차이」 · 끝수만 다름 = 확정 + 참고 「끝수」 +(a) 단가만 다름 = 확정 + 참고 「단가 차이」(원파일 기계경비 묶음 ↔ 우리 기계 = 이름 종류 같을 때) · + 끝수만 다름 = 확정 + 참고 「끝수」 (b) 로직 차이 = 다른 항 — 율 비율 다름(「율 값」) · 기계 규격 · 노임 종류(「자원」) · 산출 단가 · 비목 (c) 율 = 비율 — 거창 합판거푸집(블록 41): 가져온 「주자재소계 × 9 %」 = M01 소모자재 9 % 짝 · 율_2 1 % 재료비 ↔ 공구손료 1 % 경비 = 「비목」 · 대전 합판 단가 = 참고 @@ -37,7 +38,7 @@ def _line(name, kind, qty, price, cost="노무비", code=""): "단가": dm._key(p), "비목": {cost: dm._key(q * p)}, "금액": dm._key(q * p), "합밖": False} # fmt: skip -def _rate(name, rate, target=("노무비",), cost="경비"): +def _rate(name, rate, target=(("노임", "노무비"),), cost="경비"): return {"번": 0, "이름": name, "갈래": "율", "코드": "", "수량": None, "단가": None, "비목": {cost: D(1)}, "금액": D(1), "합밖": False, "율": D(rate), "대상": frozenset(target)} # fmt: skip @@ -54,6 +55,24 @@ def test_price_only_is_sure_with_note() -> None: mach = [_line("굴삭기", "기계", "0.1", "500", "경비", "0201-0070")] other = [_line("굴삭기", "기계", "0.1", "530", "경비", "0201-0070")] assert dm.verdict(mach, other, TOT, TOT)["판정"] == dm.SURE # 기계 시간당 단가 = 참고 + file = [ + _line("머캐덤로울러(자주식)", "산출", "0.1", "530", "경비") + ] # 원파일 기계경비 묶음(코드 없음) + ours = [_line("탠덤롤러", "기계", "0.1", "500", "경비", "1206-0008")] + ours.append(_line("머캐덤롤러 10∼12ton", "기계", "0.1", "500", "경비", "1106-0012")) + res = dm.verdict(file, ours, TOT, TOT) # 이름 종류 같은 기계와 짝 · 단가 = 참고 + assert ( + not [d for d in res["다른"] if d["항"] != "줄 없음"] and res["참고"][0]["항"] == "단가 차이" + ) + run = [{**_line("드릴", "산출", "0.1", "530", "경비"), "비목": {"노무비": D(1), "경비": D(2)}}] + res = dm.verdict(run, [_line("드릴", "기계", "0.1", "500", "경비", "0901-0010")], TOT, TOT) + assert res["판정"] == dm.SURE # 기계경비 구성(운전 노무비 포함) 차이 = 시간당 단가 = 참고 + none = [{**_line("모래", "재료", "1", "0", "재료비"), "비목": {}}] + assert ( + dm.verdict([_line("모래", "재료", "1", "9", "재료비")], none, TOT, TOT)["판정"] == dm.SURE + ) + res = dm.verdict(file, ours[:1], TOT, TOT) + assert [d["항"] for d in res["다른"]] == ["기계 규격"] def test_rounding_only_is_sure_with_note() -> None: @@ -67,7 +86,9 @@ def test_rate_ratio_differs_blocks() -> None: assert [d["항"] for d in res["다른"]] == ["율 값"] and res["판정"] == dm.NEAR same = dm.verdict([*A, _rate("율", "0.02")], [*A, _rate("공구손료", "0.02")], TOT, TOT) assert same["판정"] == dm.SURE # 율 · 대상 · 비목 같음 = 이름 달라도 같은 율 줄 - tgt = dm.verdict([*A, _rate("율", "0.02", ("재료비",))], [*A, _rate("율", "0.02")], TOT, TOT) + tgt = dm.verdict( + [*A, _rate("율", "0.02", (("재료", "재료비"),))], [*A, _rate("율", "0.02")], TOT, TOT + ) assert [d["항"] for d in tgt["다른"]] == ["율 대상"] cost = dm.verdict([*A, _rate("율", "0.02", cost="재료비")], [*A, _rate("율", "0.02")], TOT, TOT) assert [d["항"] for d in cost["다른"]] == ["비목"] @@ -120,3 +141,38 @@ def test_every_block_logic() -> None: assert got["블록"] == 2220 and got["오류"] == 0 and got["확정다시셈다름"] == 0 assert not {"단가 차이", "끝수"} & set(got["근접항"]) assert got["확정참고블록"] >= 1 and got["판정"][dm.SURE] >= 98 + + +def test_rate_target_scope() -> None: + """율 대상 — 줄 기호 = 그 줄 · 비목 이름 = 그 비목 몫(기계 운전 노무비 포함) · 번지 합 = 핵심 줄 전부.""" + from M02_MasterTemplete import M02_Doc_Match_Note as note + + def run(rate_text: str) -> dict: + money = [ + {"이름": "인부", "수량": "1", "단가": "100", "기호": "C_인부"}, + {"이름": "굴삭기", "수량": "1", "단가": "300"}, + {"이름": "율", "단가": rate_text}, + ] + rows = [ + {"수량": D(1), "단가": D(100), "금액": D(100)}, + {"수량": D(1), "단가": D(300), "금액": D(300)}, + {"수량": D(1), "단가": D(0), "금액": D(0)}, + ] + facts = [ + {"번": 0, "갈래": "노임", "비목": {"노무비": D(100)}, "합밖": False}, + {"번": 1, "갈래": "산출", "비목": {"노무비": D(100), "경비": D(200)}, "합밖": False}, + {"번": 2, "갈래": "율", "비목": {"경비": D(4)}, "합밖": False}, + ] + doc = {"변수": [], "식": [], "금액": money} + rows[2]["단가"] = {"C_인부 × 4%": D(4), "노무비 × 2%": D(4), "번지.A01.계 × 1%": D(4)}[ + rate_text + ] + note.rates(doc, {"줄": rows}, facts) + return facts[2] + + one = run("C_인부 × 4%") + assert one["율"] == D("0.04") and one["대상"] == {("노임", "노무비")} + labor = run("노무비 × 2%") # 노무비 200(인부 100 + 굴삭기 운전 100) + assert labor["율"] == D("0.02") and labor["대상"] == {("노임", "노무비"), ("기계", "노무비")} + whole = run("번지.A01.계 × 1%") # 400 × 1 % + assert whole["율"] == D("0.01") and ("기계", "경비") in whole["대상"]