"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} · 만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만: 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값, 수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조 (같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 · 아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조 (순환 = 빠진 줄). 같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도). 빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가. 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). """ from __future__ import annotations import json from decimal import Decimal from pathlib import Path from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Match as match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store ORIGIN = "실무자료" WHY = { "%": "% 줄 — 우리 로직 덧줄이 셈", "빼기": "뺌(확정)", "후보": "못 맞춤 — 후보 확정 필요", "없음": "못 맞춤", } def mark(key: str, no: int, at: int | None = None) -> str: """본뜬 키 — 호표 「HP-000001 제3호표」 · 감싼 줄 「… 2째 줄」.""" head = f"{key} 제{no}호표" return head if at is None else f"{head} {at + 1}째 줄" def _found(origin: str) -> tuple[str, str] | None: """지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판).""" for path in sorted((store.root() / "precombo").glob("SC-*.json")): try: doc = json.loads(path.read_bytes()) except (OSError, ValueError): continue got = doc.get("본뜬") if isinstance(doc, dict) else None if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin: return path.stem, store.version_of(path.read_bytes()) return None def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]: """(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키.""" at = refs.here() have = _found(doc["본뜬"]["키"]) if have and not fresh: return {"층": at[0], "주인": at[1], "키": have[0]}, True if have: store.write("precombo", have[0], have[1], doc) key = have[0] else: key = refs.create("precombo", doc)["이름"] return {"층": at[0], "주인": at[1], "키": key}, bool(have) def _num(value: Decimal) -> int | float: value = value.normalize() return int(value) if value == value.to_integral_value() else float(value) def _memo(line: dict[str, Any], times: Any) -> str: qty = line.get("수량") spec = str(line.get("규격") or "").strip() head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}" return f"{head} · {spec}" if spec else head def _missing(line: dict[str, Any], why: str) -> dict[str, Any]: keep = ("차례", "이름", "규격", "수량", "단위") return { **{k: line.get(k) for k in keep}, "금액": (line.get("금액") or {}).get("계"), "까닭": why, } class Builder: """한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억.""" def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None: self.doc = hopyo_store.read(source, key) self.key, self.fresh = key, fresh self.memo, self.units = match.memories(folder), match.units_of(self.doc) self.got = match.targets() self.made: dict[int, dict[str, Any]] = {} self.missing: dict[int, list[dict[str, Any]]] = {} def hopyo(self, no: int) -> dict[str, Any]: found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None) if found is None: raise store.StoreError(404, f"없는 호표 「제{no}호표」") return found def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]: """호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄).""" if no in self.made: return self.made[no] hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got) lines, mats, missing = [], {}, [] used: set[str] = set() for line in hopyo["줄"]: got = line["맞춤"] if not got["들어감"]: conv = got.get("환산") or {} why = WHY.get(got["상태"]) or ( "추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요" ) missing.append(_missing(line, why)) continue times = got["환산"]["배"] qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times)) target = got["대상"] alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None memo = _memo(line, times) if "자재" in target: code = target["자재"] apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" have = mats.get(code) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart} continue if "호표" in target: child = int(target["호표"]) if child in (*trail, no): missing.append(_missing(line, "순환 — 호표가 서로 담음")) continue ref = self.build(child, (*trail, no)) lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) continue one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo} if target["로직"] not in used: used.add(target["로직"]) lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}}) continue wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈 "이름": f"{hopyo['이름']} — {line.get('이름')}", "단위": target.get("단위"), "구분": ORIGIN, "상세구분": self.doc.get("파일") or "", "담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}], "자재": [], "본뜬": self._origin(mark(self.key, no, line["차례"])), } ref, _ = _save(wrap, self.fresh) lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) doc = { "이름": name, "단위": hopyo.get("단위") or None, "구분": ORIGIN, "상세구분": self.doc.get("파일") or "", "담은로직": lines, "자재": list(mats.values()), "본뜬": self._origin(mark(self.key, no)), } ref, again = _save(doc, self.fresh) self.made[no] = {**ref, "다시씀": again} self.missing[no] = missing return self.made[no] def _origin(self, origin: str) -> dict[str, Any]: return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")} def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가.""" got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) base = got.get("기준") or {} ours = float(base["계"]) if "계" in base else None excel = (hopyo.get("금액") or {}).get("계") gap = round(ours - excel, 1) if ours is not None and excel is not None else None return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")} def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]: """실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 — `{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`).""" maker = Builder(source, folder, key, fresh) made = maker.build(int(no)) hopyo = maker.hopyo(int(no)) ref = {k: made[k] for k in ("층", "주인", "키")} return { "참조": ref, "이름": hopyo.get("이름"), "다시씀": made["다시씀"], "빠진줄": maker.missing.get(int(no), []), "비교": compare(ref, hopyo), }