"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3 · 338-3 · 설계 3-6 · 3-7). [확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 — `줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`. 몸이 비면 400(확인 없이 들어가는 길 없음). 서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = 돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · 호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인], 확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층). 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값, 수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄 사전조합으로 감싸 참조(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조. 같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). 빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). """ from __future__ import annotations import json from datetime import datetime 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_Ledger as ledger 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 = "실무자료" LINES_MAX = 500 UNCHECKED = "확인 안 함" PERCENT = "% 줄 — 우리 로직 덧줄이 셈" WHOLE = "호표 통째로 대신" 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 _origins(folder: Path): """층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판).""" for path in sorted((Path(folder) / "precombo").glob("SC-*.json")): try: raw = path.read_bytes() doc = json.loads(raw) except (OSError, ValueError): continue got = doc.get("본뜬") if isinstance(doc, dict) else None if isinstance(got, dict) and got.get("층") == ORIGIN: yield path.stem, got, raw def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool: """본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음.""" have = got.get("원본") return source is None or not have or tuple(have) == tuple(source) def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]: """층에 확인된 실무 사전조합 `{본뜬 키: SC 키}`(`source` = 파일 층 (층, 주인)으로 거름).""" return { got["키"]: key for key, got, _ in _origins(Path(folder)) if got.get("확인") and _same_source(got, source) } def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]: """(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키.""" at = refs.here() want = doc["본뜬"] have = next( ( (key, store.version_of(raw)) for key, got, raw in _origins(store.root()) if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"])) ), None, ) 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], one: dict[str, Any]) -> str: qty = line.get("수량") spec = str(line.get("규격") or "").strip() head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {one['배']}" head += f"(몫 {one['몫']})" if one.get("몫") is not None else "" 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, } def _bad(text: str, status: int = 400) -> store.StoreError: return store.StoreError(status, text) class Builder: """한 번 만들기 — 파일 · 우리 쪽 목록 · 확인된 호표를 한 번 읽고 몸을 검사한 뒤 씀.""" def __init__(self, folder: Path, source: Path, key: str, no: int, who: Any) -> None: self.folder = Path(folder) self.doc = hopyo_store.read(source, key) self.key, self.no, self.who = key, int(no), who self.source = refs.where(source) self.done = confirmed(folder, self.source) self.known = ledger._known() self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None) if self.hopyo is None: raise _bad(f"없는 호표 「제{no}호표」", 404) # ── 재검사 ─────────────────────────────────────────────────────── def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]: if raw.get("호표") is None: return ledger.check_pair( {k: v for k, v in raw.items() if k != "호표"}, where, self.known ) if raw.get("로직") or raw.get("자재") or raw.get("입력값"): raise _bad(f"호표 대상은 호표 · 배만 「{where}」") child = int(raw["호표"]) if child == self.no: raise _bad(f"호표가 자기를 담음 「{where}」") if mark(self.key, child) not in self.done: raise _bad(f"제{child}호표 먼저 확인", 409) times, part = ledger.times_of(raw, where) return {"호표": child, "배": times, "몫": part} def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]: """몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409.""" if whole is not None and lines: raise _bad("통째 · 줄 중 하나만") if not lines and not (whole or {}).get("대상"): raise _bad("확인한 줄이 없음") if len(lines) > LINES_MAX: raise _bad(f"줄은 {LINES_MAX} 개 안") have = self.hopyo.get("줄") or [] out: dict[int, dict[str, Any]] = {} for raw in lines: at = int(raw.get("차례", -1)) if not 0 <= at < len(have) or at in out: raise _bad(f"없는 줄 · 겹친 줄 「제{self.no}호표 {at}」") where = f"제{self.no}호표 {at + 1}째 줄" targets = raw.get("대상") or [] if bool(raw.get("뺌")) == bool(targets): raise _bad(f"대상 · 뺌 중 하나만 「{where}」") if raw.get("뺌") and raw["뺌"] not in ledger.KINDS[1:]: raise _bad(f"뺌 까닭이 틀림 「{where}」") if len(targets) > 20: raise _bad(f"대상은 스물 이하 「{where}」") out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else { "대상": [self._target(t, where) for t in targets] } # fmt: skip pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []] if any("호표" in p for p in pairs) or len(pairs) > 20: raise _bad("통째 대상은 로직 · 자재만") return out, pairs # ── 쓰기 ───────────────────────────────────────────────────────── def _origin(self, origin: str, stamp: str) -> dict[str, Any]: sure = {"누가": self.who, "때": stamp} file = self.doc.get("파일") return { "층": ORIGIN, "주인": None, "키": origin, "파일": file, "원본": list(self.source), "확인": sure, } def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: stamp = datetime.now().isoformat(timespec="seconds") hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used = [], {}, [], set() def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) memo = _memo(line, one) if "자재" in one: apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" have = mats.get(one["자재"]) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty mats[one["자재"]] = { "코드": one["자재"], "수량": {"손": _num(hand)}, "별도계상": apart, } return if "호표" in one: ref = { "층": refs.here()[0], "주인": refs.here()[1], "키": self.done[mark(self.key, one["호표"])], } lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) return row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo} if one["로직"] not in used: used.add(one["로직"]) lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}}) return at = int(line.get("차례", 0)) wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈 "이름": f"{hopyo['이름']} — {line.get('이름')}", "단위": line.get("단위") or None, "구분": ORIGIN, "상세구분": file, "담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}], "자재": [], "본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp), } ref, _ = _save(wrap) lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) book: dict[str, dict[str, Any]] = {} if whole: for one in whole: put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None) book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole) for at, line in enumerate(hopyo.get("줄") or []): got = checked.get(at) if line.get("갈래") == "%": missing.append(_missing(line, PERCENT)) continue if whole or got is None: missing.append(_missing(line, WHOLE if whole else UNCHECKED)) continue if got.get("뺌"): missing.append(_missing(line, f"뺌 — {got['뺌']}")) book[ledger.key(line)] = self._row(line, got["뺌"], []) continue alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None for one in got["대상"]: put(line, one, alias) pairs = [p for p in got["대상"] if "호표" not in p] if pairs: book[ledger.key(line)] = self._row(line, "짝", pairs) name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) doc = { "이름": name, "단위": hopyo.get("단위") or None, "구분": ORIGIN, "상세구분": file, "담은로직": lines, "자재": list(mats.values()), "본뜬": self._origin(mark(self.key, self.no), stamp), } ref, again = _save(doc) wrote = ledger.write(self.folder, book, self.who, file or None) return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]: """장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면).""" origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")} clauses = (line.get("근거") or {}).get("출처") or [] return { "원래": {k: str(v or "") for k, v in origin.items()}, "갈래": kind, "짝": pairs, "엑셀조항": clauses if isinstance(clauses, list) else [], } 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, lines: list[dict[str, Any]] | None = None, whole: dict[str, Any] | None = None, who: Any = None, ) -> dict[str, Any]: """실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 · 별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`).""" maker = Builder(folder, source, key, no, who) checked, pairs = maker.check(list(lines or []), whole) made = maker.build(checked, pairs) return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)}