"""연결 찾기 — 로직 차이(다른 항) 대 값 차이(참고) 가르기(PLAN 812-2 · 요구 712) — `M02_Doc_Match` 가 부름. 확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기계 규격 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 · 율 구조(있고 없음 · 대상 비목 · 율 = 비율) · 수량(옵션 풀이 뒤 · 원 단위 같아야 함). 「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 그로 인한 비목 금액 · 계. 율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 · 대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님. """ from __future__ import annotations import re from decimal import Decimal, InvalidOperation from typing import Any from M02_MasterTemplete import M02_Doc_Copy as dc md = dc.md PRICED = ("노임", "재료", "기계") # 시간당 · 단위 단가 = 값(참고) # ── 율 구조 ─────────────────────────────────────────────────────────── def _names(text: Any) -> set[str]: if text in (None, ""): return set() try: return set(md.names(md.parse(text))) except md.DocError: return set() def _reach(doc: dict) -> dict[str, set[str]]: """기호 → 바로 부르는 이름(변수 연결 · 식 줄 · 금액 줄 수량 · 단가).""" direct: dict[str, set[str]] = {} for v in doc.get("변수") or []: direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {})) for line in doc.get("식") or []: direct[str(line.get("기호"))] = _names(line.get("식")) for line in doc.get("금액") or []: if line.get("기호"): direct[str(line["기호"])] = _names(line.get("수량")) | _names(line.get("단가")) return direct def _close(start: set[str], direct: dict[str, set[str]]) -> set[str]: seen, todo = set(start), list(start) while todo: for n in direct.get(todo.pop(), ()): if n not in seen: seen.add(n) todo.append(n) return seen def _short(r: Decimal) -> Decimal: return Decimal(f"{r:.6g}").normalize() def rates(doc: dict, got: dict, facts: list[dict]) -> None: """율 사실에 `율`(비율 · 못 셈 None) · `대상`({(자원 갈래, 비목)})을 채움(제자리).""" money, rows = doc.get("금액") or [], got.get("줄") or [] direct = _reach(doc) core = [f for f in facts if f["갈래"] != "율" and not f["합밖"]] for f in facts: if f["갈래"] != "율": continue line, row = money[f["번"]], rows[f["번"]] reach = _close(_names(line.get("수량")) | _names(line.get("단가")), direct) costs = reach & set(md.COSTS) 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(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)) try: return Decimal(str(v)) if v is not None else Decimal(0) except InvalidOperation: return Decimal(0) def _mul(q: Any, p: Any) -> Decimal | None: if q is None or p is None: return None return _num(q) * _num(p) def pct(r: Any) -> str: """비율 → 「9 %」.""" if not isinstance(r, Decimal): return "?" return f"{(r * 100).normalize():f} %" # ── 짝 줄 ───────────────────────────────────────────────────────────── def txt(v: Any) -> str: """단가 글 — 수 = 원 단위 쉼표 · 기계 묶음 = 노 · 재 · 경.""" if isinstance(v, tuple): return " · ".join(f"{k[0]} {txt(x)}" for k, x in zip(md.COSTS, v) if x) if isinstance(v, Decimal): return f"{v.normalize():,f}" return str(v) _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['이름']}"}) 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) or loose: return diffs, [{"항": "단가 차이", "글": say}] return [*diffs, {"항": "산출 단가", "글": say}], [] def _same_stuff(a: dict, b: dict) -> bool: """자재 종류 같음 — 두 코드가 있으면 코드 · 없으면 이름 낱말(한글 두 글자) 겹침.""" if a["코드"] and b["코드"] and a["코드"] == b["코드"]: return True return bool(_grams(a["이름"]) & _grams(b["이름"])) def rate_pairs(left: list[dict], right: list[dict], take: Any) -> list[dict]: """율 줄 짝 — 같은 구조(율 · 대상 · 비목) 먼저 · 그다음 비목만 · 대상만 · 율만 다름 순.""" diffs: list[dict] = [] def is_rate(a: dict, b: dict) -> bool: return a["갈래"] == b["갈래"] == "율" def same(a: dict, b: dict, *keys: str) -> bool: return is_rate(a, b) and all( (set(a[k]) == set(b[k])) if k == "비목" else a.get(k) == b.get(k) for k in keys ) def name(a: dict, b: dict) -> str: return f"{a['이름']} {pct(a.get('율'))} ↔ {b['이름']} {pct(b.get('율'))}" def target(x: dict) -> str: 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, "율", "대상")): diffs.append({"항": "비목", "글": f"율 줄 {name(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(lambda a, b: same(a, b, "대상") or is_rate(a, b)): diffs.append({"항": "율 값", "글": f"{name(a, b)} · 대상 {target(a)} ↔ {target(b)}"}) return diffs