"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. ⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집). 실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔): 1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액) 항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`) 4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료 단가 = 호표(대가 · 산근 — 145-1 · 2 가 `inputs.hopyo` 로 줌) · 자재 = 대비표 적용 단가 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 합계 = 세 비목 합 · 조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(율 = 사용자 입력 · 빈칸 시작) 제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈) 빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록. 단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 · logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) · 그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴). 자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) · 사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면): {"v": 2, "rows": [표 차례 전체(order · 부모 먼저 · 자손 이어서) { "key": 줄 키, "parent": 묶음 줄 키(`sec:*` · `hand:*`) | 없음 = 맨 위, 자동 줄(`sec:*` · `earth:` · `st:` · `haul:` · `mat:`) = 고친 칸만 — 칸 없음 = 자동(B08 따라감) · 값 = 지킴 · null = 일부러 빈칸 · 칸 = name · spec · unit · qty · ref (제목 `sec:*` = name 만 · `mat:` = ref 없음 · `fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정) 새 줄(`hand:*`) = 모든 칸 + "kind": "group"(묶음 · 수량 · 연결 없음) | 없음 = 항목 · "cost": "direct"(맨 위 새 묶음 · 없음도 direct)}], "gone": [지운 자동 줄 키 — `sec:G` · `fee` · `mat:` 는 안 받음(자재 줄 주인 = 대비표 표시)]} 덮개 없음 = 기본 표 그대로 · [초기화] = `bill` 지움 · `rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) = 기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) · 원천이 안 낸 키 = 안 보임 · 부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 · 관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래. 옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` · `after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2). 셈 출력 줄 칸(계약) = kind("title" 자동 묶음 · "group" 새 묶음 · "item") · key · name(번호 뺀 이름) · parent(실제 부모 키 · None = 맨 위) · level(1~5 = 깊이) · no(레벨 1 · 2 묶음 차례 번호 「1」 · 「1.1」 · 관급 · 항목 = "") · cost("direct" | "supply" = 맨 위 조상 갈래 · `sec:G` 만 supply) · auto(True = 자동 줄) | hand(True = 새 줄) · auto_{name|spec|unit|qty|ref}(고친 칸만 = 자동값) · section(가장 가까운 제목 공종 · 없으면 None). 묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림). 원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 · supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료). """ from __future__ import annotations from collections.abc import Callable from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import compute_base, gap, num, round_half, rounddown #: 공종 나무 — (번호, 이름, 부모) · 엑셀 기번3 공통 1공구 꼴(공구 안 나눔) SECTIONS: list[tuple[str, str, str | None]] = [ ("1", "토공사", None), ("1.1", "준비공", "1"), ("1.2", "절토", "1"), ("1.3", "구조물터파기 및 되메우기", "1"), ("1.4", "측구파기", "1"), ("1.5", "유용성토", "1"), ("1.6", "사토 및 다짐공", "1"), ("2", "구조물공", None), ("2.1", "돌기슭막이공", "2"), ("2.2", "배수공", "2"), ("2.3", "기타구조물", "2"), ("3", "운반공", None), ("4", "자재대", None), ("G", "관급자재대", None), ] PARTS = ("labor", "material", "expense") #: 184 덮개로 고칠 칸 · 묶음 깊이 한도 FIELDS = ("name", "spec", "unit", "qty", "ref") DEPTH = 5 NEW_AUTO = "새 자동 줄" def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]: if prices is None: unit = {p: None for p in PARTS} else: unit = {p: num(prices.get(p)) for p in PARTS} out: dict[str, Any] = {f"price_{p}": unit[p] for p in PARTS} known = all(unit[p] is not None for p in PARTS) out["price_sum"] = unit["labor"] + unit["material"] + unit["expense"] if known else None for p in PARTS: out[p] = rounddown(qty * unit[p], 0) if qty is not None and unit[p] is not None else None filled = all(out[p] is not None for p in PARTS) out["sum"] = out["labor"] + out["material"] + out["expense"] if filled else None return out def _item_row(item: dict[str, Any], prices: dict[str, Any] | None) -> dict[str, Any]: """항목 줄 — 「연결 없음」은 `_gaps` 에 담아 둠(보이는 줄만 표 차례로 모음).""" qty = num(item.get("qty")) row = { "kind": "item", "key": item["key"], "section": item.get("section"), "name": item.get("name") or "", "spec": item.get("spec") or "", "unit": item.get("unit") or "", "qty": qty, "note": item.get("note") or "", "ref": item.get("ref"), **{k: v for k, v in item.items() if k.startswith("auto_")}, **({"hand": True} if item.get("hand") else {"auto": True}), **_amounts(qty, prices), } row["_gaps"] = [] if qty is None: row["_gaps"].append(gap("bill", item["key"], row["name"], "수량 연결 없음")) if prices is None: why = item.get("why") or "단가 연결 없음" row["_gaps"].append(gap("bill", item["key"], row["name"], why)) return row def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> list[dict]: """대비표에서 「별도」 · 「관급」 표시한 수량 있는 자재 → 4 자재대 · 관급자재대 항목.""" qty_of = {m["key"]: m.get("qty") for m in inputs.get("materials") or [] if "qty" in m} out = [] for row in compare: if row["supply"] is None or row["key"] not in qty_of: continue out.append( { "key": f"mat:{row['key']}", "section": "G" if row["supply"] == "관급" else "4", "name": row["name"], "spec": row["spec"], "unit": row["unit"], "qty": qty_of[row["key"]], "note": f"자재 {row['no']}", "ref": {"tab": "material", "key": row["key"]}, "_price": row["applied"], } ) return out PriceOf = Callable[[str], dict[str, Any] | None] WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])" WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)" def ref_price( ref: dict[str, Any] | None, books: dict[str, Any], hopyo: dict[str, Any], row_key: str = "" ) -> dict[str, Any] | None: """연결 → 1 단위 단가 `{no, key, labor, material, expense, sum}` — 없으면 None.""" ref = ref or {} tab, key = ref.get("tab"), ref.get("key") if tab == "logic": tab, key = "sg", (books.get("sg_hand") or {}).get(row_key) if tab in ("dg", "sg") and key: listing = books.get("daega" if tab == "dg" else "sg_list") or [] row = next((r for r in listing if r["key"] == key), None) if row is None: return None no = f"대가{row['no']}" if tab == "dg" else f"산근 {row['no']}호표" return {"no": no, "tab": tab, "key": key, **{p: row.get(p) for p in (*PARTS, "sum")}} return hopyo.get(key) if key else None def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]: """새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠).""" out = [] for hand in (user.get("bill") or {}).get("rows") or []: if not str(hand.get("key") or "").startswith("hand:"): continue price = ref_price(hand.get("ref"), books, hopyo, hand["key"]) ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND item = {**hand, "hand": True, "note": (price or {}).get("no") or "", "ref": ref} out.append((item, price, why)) return out def _covered( item: dict[str, Any], prices: Any, cover: dict | None, books: dict, hopyo: dict ) -> tuple[dict[str, Any], Any]: """자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · `auto_{칸}` = 자동값 · 연결 덮개 = 그 단가(자재 줄 = 단가 주인 대비표라 연결 안 받음).""" if not cover: return item, prices out = dict(item) for field in FIELDS: if field not in cover or (field == "ref" and item["key"].startswith("mat:")): continue if field == "qty": out["auto_qty"] = num(item.get("qty")) elif field == "ref": out["auto_ref"] = item.get("ref") else: out[f"auto_{field}"] = item.get(field) or "" out[field] = cover[field] if "auto_ref" in out: prices = ref_price(cover["ref"], books, hopyo, item["key"]) tab = (prices or {}).get("tab") out["ref"] = {"tab": tab, "key": prices["key"]} if tab else None out["note"] = (prices or {}).get("no") or "" if (cover["ref"] or {}).get("tab") == "logic": out["why"] = WHY_LOGIC return out, prices def _title_row(code: str, name: str, parent: str | None, cover: dict | None) -> dict[str, Any]: """기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 `no`(차례 자동).""" row = {"kind": "title", "key": f"sec:{code}", "section": code, "name": name, "auto": True} if cover and "name" in cover: row["auto_name"] = name row["name"] = cover["name"] or "" row["parent"] = f"sec:{parent}" if parent else None row["_home"] = row["parent"] return row def _group_row(item: dict[str, Any]) -> dict[str, Any]: """새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합.""" return { "kind": "group", "key": item["key"], "section": item.get("section"), "name": item.get("name") or "", "spec": item.get("spec") or "", "unit": item.get("unit") or "", "qty": None, "note": "", "ref": None, "hand": True, } def _hand_row(item: dict[str, Any], prices: Any, why: str) -> dict[str, Any]: if item.get("kind") == "group": return _group_row(item) return _item_row({**item, "why": why}, prices) def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None: """옛 꼴 손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝.""" at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None) if at is None and after == f"sec:{hand['section']}": at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None) items.insert(len(items) if at is None else at, hand) def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]: """구조물 열 → 대가 블록마다 한 줄 — 수량 = 그 블록 사본 주인 값 합(ROUND 3) · 대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례 · 줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄).""" groups: dict[str, dict[str, Any]] = {} for owner, value in (item.get("owners") or {}).items(): price = price_of(owner) tag = str(price.get("key") or price.get("no") or "") if price else "" group = groups.setdefault(tag, {"qty": 0.0, "price": price, "owners": []}) group["qty"] += num(value) or 0 group["owners"].append(owner) out = [] for tag, group in groups.items(): no = str((group["price"] or {}).get("no") or "") part = { **item, "key": f"{item['key']}#{tag}" if tag else item["key"], "qty": round_half(group["qty"], 3), "note": " · ".join(t for t in (no, item.get("note") or "") if t), "owners": {o: item["owners"][o] for o in group["owners"]}, } if group["price"] and group["price"].get("key"): # 대가 블록(145-2) — 이동 · 집계표 역전개 part["ref"] = {"tab": "dg", "key": group["price"]["key"]} out.append((part, group["price"])) return out def _order_old(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list: """옛 꼴(166-2 · 182) — 손 줄 = `after` 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목).""" groups = {i["key"]: i for i, _p, _w in hands if i.get("kind") == "group"} for item, prices, why in hands: row = _hand_row(item, prices, why) up = groups.get(item.get("parent") or "") row["parent"] = up["key"] if up and up.get("section") == item.get("section") else None _place(items, row, item.get("after") or "") out = [] for title in titles: out.append(title) for row in items: if row["section"] == title["section"]: row["_home"] = title["key"] row["parent"] = row.get("parent") or title["key"] out.append(row) return out def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list: """184 v2 — 저장 차례 · 부모 · `gone` 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」.""" auto = {row["key"]: row for row in titles} for row in items: row["_home"] = f"sec:{row['section']}" auto[row["key"]] = row gone = {k for k in bill.get("gone") or [] if k != "sec:G" and not k.startswith("mat:")} new = {item["key"]: (item, prices, why) for item, prices, why in hands} out, seen = [], set() for saved in bill.get("rows") or []: key = saved["key"] if key in seen: continue if key in new: row = _hand_row(*new[key]) row["_home"] = None elif key in auto and key not in gone: row = auto[key] else: continue seen.add(key) row["parent"] = saved.get("parent") out.append(row) for row in [*titles, *items]: if row["key"] in seen or row["key"] in gone: continue row["parent"] = row["_home"] if row["kind"] == "item": row["note"] = " · ".join(t for t in (row["note"], NEW_AUTO) if t) out.append(row) return out def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]: """부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 · 관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section (깊이 5 넘음 = 레벨 4 조상 아래).""" by_key = {row["key"]: row for row in order} def loops(row: dict[str, Any]) -> bool: at, n = by_key.get(row["parent"] or ""), 0 while at is not None and n <= len(order): if at is row: return True at, n = by_key.get(at["parent"] or ""), n + 1 return False def root(row: dict[str, Any]) -> dict[str, Any]: while row["parent"]: row = by_key[row["parent"]] return row for row in order: up = by_key.get(row.get("parent") or "") if row["key"] == "sec:G": row["parent"] = None elif up is None or up["kind"] == "item" or up is row: row["parent"] = row["_home"] if row["_home"] in by_key else None for row in order: if loops(row): row["parent"] = row["_home"] if row["_home"] in by_key else None if loops(row): row["parent"] = None for row in order: if ( row["key"].startswith("mat:") and row["_home"] == "sec:G" and root(row)["key"] != "sec:G" ): row["parent"] = "sec:G" kids: dict[Any, list[dict[str, Any]]] = {} for row in order: kids.setdefault(row["parent"], []).append(row) rows: list[dict[str, Any]] = [] stack: list[tuple[dict, list]] = [(row, []) for row in reversed(kids.get(None, []))] while stack: row, chain = stack.pop() if len(chain) >= DEPTH: chain = chain[: DEPTH - 1] row["parent"] = chain[-1]["key"] row["level"] = len(chain) + 1 row["cost"] = "supply" if (chain[0] if chain else row)["key"] == "sec:G" else "direct" if row["kind"] != "title": row["section"] = chain[-1]["section"] if chain else None rows.append(row) stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], []))) return rows def compute_bill( inputs: dict[str, Any], user: dict[str, Any], base: dict[str, Any], price_of: PriceOf | None = None, books: dict[str, Any] | None = None, ) -> dict[str, Any]: """`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) — 주면 구조물 열을 블록마다 나눔 · `books` = 호표 목록(`daega` · `sg_list` · `sg_hand` · 166-2).""" hopyo = inputs.get("hopyo") or {} books = books or {} bill = user.get("bill") or {} v2 = bill.get("v") == 2 if v2: cover = {r["key"]: r for r in bill.get("rows") or [] if not r["key"].startswith("hand:")} else: cover = {k: {"qty": v} for k, v in (bill.get("qty") or {}).items()} items: list[dict[str, Any]] = [] for item in (inputs.get("bill") or {}).get("items") or []: if price_of is not None and item.get("owners"): parts = split_by_block(item, price_of) else: parts = [(item, ref_price(item.get("ref"), books, hopyo))] for part, prices in parts: items.append(_item_row(*_covered(part, prices, cover.get(part["key"]), books, hopyo))) for item in _material_items(inputs, base["compare"]): price = item["_price"] prices = None if price is None else {"labor": 0, "material": price, "expense": 0} items.append(_item_row(*_covered(item, prices, cover.get(item["key"]), books, hopyo))) titles = [_title_row(c, n, p, cover.get(f"sec:{c}")) for c, n, p in SECTIONS] hands = hand_items(user, books, hopyo) rows = _tree((_order_v2 if v2 else _order_old)(bill, titles, items, hands)) owner = [row for row in rows if row["kind"] == "item" and row["cost"] == "supply"] net = 0.0 for row in owner: net += row["material"] or 0 pct = num(((user.get("estimate") or {}).get("procurement_fee_pct"))) fee = rounddown(net * pct / 100, 0) if pct is not None else None fee_row = { "kind": "item", "key": "fee", "section": "G", "name": "조달수수료", "spec": "관급 재료비 × 율", "unit": "%", "qty": pct, "note": "관급 순자재대 밑수", "ref": None, "auto": True, "price_labor": 0, "price_material": net, "price_expense": 0, "price_sum": net, "labor": 0 if pct is not None else None, "material": fee, "expense": 0 if pct is not None else None, "sum": fee, "parent": "sec:G", "level": 2, "cost": "supply", "_gaps": [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")] if pct is None else [], } if owner: # 조달수수료 = `sec:G` 끝 고정 at = next(n for n, row in enumerate(rows) if row["key"] == "sec:G") + 1 while at < len(rows) and rows[at]["level"] > 1: at += 1 rows.insert(at, fee_row) by_key = {row["key"]: row for row in rows} for row in rows: if row["kind"] != "item": row.update({"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}) direct = {p: 0.0 for p in ("sum", *PARTS)} blank = 0 for row in rows: if row["kind"] != "item": continue at = by_key.get(row["parent"] or "") while at is not None: for p in ("sum", *PARTS): at[p] += row[p] or 0 if row["sum"] is None: at["blank"] += 1 at = by_key.get(at["parent"] or "") if row["cost"] == "direct": for p in direct: direct[p] += row[p] or 0 if row["sum"] is None: blank += 1 gaps: list[dict[str, str]] = [] seq: dict[Any, int] = {} for row in rows: gaps.extend(row.pop("_gaps", [])) row.pop("_home", None) row["no"] = "" if row["kind"] == "title": empty = f" 빈 줄 {row['blank']}" if row["blank"] else "" row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty elif row["kind"] == "group": row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else "" if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2: up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음) seq[up] = seq.get(up, 0) + 1 row["no"] = f"{by_key[up]['no']}.{seq[up]}" if up else str(seq[up]) totals = { "direct": {**direct, "blank": blank}, "owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)}, } # 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None known = blank == 0 owner_known = all(row["sum"] is not None for row in owner) sums_out = { "material": direct["material"] if known else None, "labor": direct["labor"] if known else None, "expense": direct["expense"] if known else None, "supply_net": net if owner_known else None, } return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps} def compute_estimation( inputs: dict[str, Any], user: dict[str, Any], price_of: PriceOf | None = None, books: dict[str, Any] | None = None, ) -> dict[str, Any]: """144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음.""" base = compute_base(inputs, user) bill = compute_bill(inputs, user, base, price_of, books) return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}