"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3 · 338-3 · 설계 3-6 · 3-7). [확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 — `줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`. 몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]). 서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = 돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · 호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인], 확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층). 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값, 수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄 사전조합으로 감싸 참조(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks` 별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고 세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조 · 「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만 받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 · 지역은 프로젝트에서) · 장부엔 안 남김. 같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). 빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자 그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 · 통째 대신 · 로직 자재 겹침 · 전국만. `본뜬.품셈` = 품셈으로 넣은 엑셀 줄 차례([품셈으로 바꾸기] 첫 체크) · `줄수 {품셈, 고정, 호표, 빠짐, 이음, 이음후보}` · 만든 값 ↔ 엑셀 호표 단가(`비교` · 같음 = 비목마다 1 원 안). 엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). """ from __future__ import annotations import json from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal from pathlib import Path from typing import Any from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Finger as finger from M02_MasterTemplete import M02_Hopyo_Fixed as hfix from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Material as hmat 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 = "호표 통째로 대신" TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)" NATIONAL = "전국 — 지역은 프로젝트에서(자재 키 · 단가 없음 · 본뜬.전국자재)" 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.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]} 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, line: dict | None = None) -> dict[str, Any]: if raw.get("전국"): return self._national(raw, where, line) 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 _national(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]: """「전국」 대상 — 줄에서 서버가 다시 셈(이름 · 규격 · 단위 = 서버 것).""" if set(raw) - {"전국", "배", "몫", "입력값"} or raw.get("입력값"): raise _bad(f"전국 대상은 전국 · 배 · 몫만 「{where}」") one = hmat.national(line) if line is not None else None if one is None: raise _bad(f"전국 자재 없음 — 지역 변형만 있는 자재 줄만 「{where}」") times, part = ledger.times_of(raw, where) return {**{k: one[k] for k in ("전국", "이름", "규격", "단위")}, "배": 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 whole is not None and not whole.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, have[at]) 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 = utc_now_text() hopyo, file = self.hopyo, self.doc.get("파일") or "" lines, mats, missing, used, mat_lines = [], {}, [], set(), {} nation: dict[tuple, dict[str, Any]] = {} taken, linker, poomsem, kids, kid_n = set(), hfix.Linker(), [], set(), 0 where = {"파일": file, "호표": self.no} rows = hopyo.get("줄") or [] 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: at = (one["이름"], one["규격"], one["단위"]) have = nation.get(at) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty head = {"이름": at[0], "규격": at[1], "단위": at[2], "수량": {"손": _num(hand)}} nation[at] = {**head, **(hmat.marks(line, at[0]) if not have else { k: have[k] for k in labor.MARKS if k in have })} # fmt: skip missing.append(_missing(line, NATIONAL)) return if "자재" in one: have = mats.get(one["자재"]) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty name = self.mat_names.get(one["자재"], "") mats[one["자재"]] = { "코드": one["자재"], "수량": {"손": _num(hand)}, **( have and {k: have[k] for k in labor.MARKS if k in have} or hmat.marks(line, name) ), } if not have: mat_lines[one["자재"]] = line return if "호표" in one: kids.add(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(rows): got = checked.get(at) if whole: missing.append(_missing(line, WHOLE)) continue if got and got.get("뺌"): book[ledger.key(line)] = self._row(line, got["뺌"], []) if got and got.get("뺌") == "안 씀": missing.append(_missing(line, f"뺌 — {got['뺌']}")) continue if not got or got.get("뺌"): # 빠짐 0(705-3) — 하위 호표 참조 · 아니면 고정 줄 kid = self._kid(line, kids) kid_n += bool(kid) others = [x for x in rows if x is not line] lines.append(kid or hfix.line_of(line, others, where, taken, linker)) continue poomsem.append(at) 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 and "전국" not in p] if pairs: book[ledger.key(line)] = self._row(line, "짝", pairs) for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로 missing.append(_missing(mat_lines[code], TWICE)) 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), } if nation: doc["본뜬"]["전국자재"] = list(nation.values()) doc["본뜬"]["품셈"] = poomsem ref, again = _save(doc) wrote = ledger.write(self.folder, book, self.who, file or None) fixed_n = sum(1 for x in lines if "고정" in x) links = sum(1 for x in lines if any(((x.get("고정") or {}).get("이음") or {}).values())) count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n} count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links} out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} return {**out, "줄수": {**count, "이음후보": linker.near}} def _kid(self, line: dict[str, Any], kids: set[int]) -> dict[str, Any] | None: """확인 안 한 호표 줄 → 확인된 하위 사전조합 참조(단위 같음 · 같은 하위 한 번) · 아니면 None.""" ref = line.get("참조") or {} if finger.kind_of(line) != "호표" and ref.get("종류") != "일위": return None no = int(ref.get("번") or 0) made = self.done.get(mark(self.key, no)) unit = next((h.get("단위") for h in self.doc["호표"] if int(h["번"]) == no), None) times = hopyo_match.factor(line.get("단위"), unit, line.get("규격"))["배"] if not made or no in kids or no == self.no or times != 1: return None kids.add(no) qty = Decimal(str(line.get("수량") or 0)) head = {"층": refs.here()[0], "주인": refs.here()[1], "키": made} return {"참조": head, "별칭": str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None, "메모": f"실무 {qty}{line.get('단위') or ''} · 제{no}호표", "수량": {"손": _num(qty)}} # fmt: skip def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]: """로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고 세 칸을 그 로직 줄 `자재표시 {재료 줄 차례: 세 칸}` 으로(수량 = 로직 셈) → 옮긴 코드.""" whole, moved = m01.loaded()[1], [] for row in (r for r in lines if r.get("로직")): got = labor.run_line(whole, row["로직"], row.get("입력값"), None, None) for m in got.get("재료") or []: code = (m.get("품목") or {}).get("키") if code in mats and code not in moved: marks = {k: mats[code][k] for k in labor.MARKS if k in mats[code]} row.setdefault("자재표시", {})[str(m["차례"])] = marks moved.append(code) for code in moved: mats.pop(code) return moved 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 [], } # 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 · # STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로) CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False)) def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None: """엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 · 합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None.""" parts = dict.fromkeys(labor.COSTS, Decimal(0)) for one in got.get("안") or []: if one.get("기준") is None: return None cut = 100 if (one.get("고정") or {}).get("비율") else 1 for k in labor.COSTS: unit = Decimal(one["기준"][k]) unit = unit.to_integral_value(how) if "참조" in one else unit parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how) if by_line: return sum(parts.values()).to_integral_value(ROUND_FLOOR) return sum(v.to_integral_value(how) for v in parts.values()) def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 · 반올림 중 엑셀과 같은 쪽 · `_excel_round`).""" got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) base = got.get("기준") or {} excel = (hopyo.get("금액") or {}).get("계") cuts = [_excel_round(got, *one) for one in CUTS] if base else [] cuts = [c for c in cuts if c is not None] cut = next( (c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None ) ours = float(cut if cut is not None else base["계"]) if "계" in base else None gap = round(ours - excel, 1) if ours is not None and excel is not None else None same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0 return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": 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)} def _order(units: dict[int, dict[str, Any]]) -> list[int]: """하위 호표 먼저(호표 줄 참조) · 고리는 끊음.""" out: list[int] = [] seen: set[int] = set() def visit(no: int) -> None: if no in seen or no not in units: return seen.add(no) for line in units[no].get("줄") or []: ref = line.get("참조") or {} if finger.kind_of(line) == "호표" or ref.get("종류") == "일위": visit(int(ref.get("번") or 0)) out.append(no) for no in sorted(units): visit(no) return out def build_all(folder: Path, source: Path, key: str, who: Any = None) -> dict[str, Any]: """[모두 고정으로 들여오기](705-3) — 시트 호표 모두 · 하위 먼저 · 줄 = 고정(하위 = 참조) · 이미 확인된 호표는 덮지 않음(`그대로`) → `{호표, 줄, 품셈, 고정, 호표참조, 빠짐, 이음, 이음후보, 같음, 그대로, 다름 [{번, 이름, 엑셀, 우리, 차이}], 실패 [{번, 까닭}]}`.""" units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []} sums = dict.fromkeys(("품셈", "고정", "호표", "빠짐", "이음", "이음후보"), 0) out: dict[str, Any] = { "호표": len(units), "줄": 0, "같음": 0, "그대로": 0, "다름": [], "실패": [], } for no in _order(units): out["줄"] += len(units[no].get("줄") or []) try: maker = Builder(folder, source, key, no, who) have = maker.done.get(mark(key, no)) if have: out["그대로"] += 1 ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": have} else: made = maker.build({}, []) ref = made["참조"] for k in sums: sums[k] += made["줄수"][k] got = compare(ref, maker.hopyo) except store.StoreError as e: out["실패"].append({"번": no, "까닭": str(e.detail)}) continue if got["같음"]: out["같음"] += 1 else: out["다름"].append({"번": no, "이름": units[no].get("이름"), **got}) return {**out, **{("호표참조" if k == "호표" else k): v for k, v in sums.items()}}