"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. ⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · 거울 시험 `resources/tester/test_145_3_b09_bill.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 + 「연결 없음」 목록. 원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 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), ] #: 순공사비에 드는 맨 위 공종(관급자재대 = 제외금액) DIRECT = ("1", "2", "3", "4") PARTS = ("labor", "material", "expense") 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, gaps: list) -> dict[str, Any]: qty = num(item.get("qty")) row = { "kind": "item", "key": item["key"], "section": item["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"), **_amounts(qty, prices), } if qty is None: gaps.append(gap("bill", item["key"], row["name"], "수량 연결 없음")) if prices is None: gaps.append(gap("bill", item["key"], row["name"], item.get("why") or "단가 연결 없음")) 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] def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]: """구조물 열 → 대가 블록마다 한 줄 — 수량 = 그 블록 사본 주인 값 합(ROUND 3) · 대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례.""" groups: dict[str, dict[str, Any]] = {} for owner, value in (item.get("owners") or {}).items(): price = price_of(owner) tag = str(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(): part = { **item, "key": f"{item['key']}#{tag}" if tag else item["key"], "qty": round_half(group["qty"], 3), "note": " · ".join(t for t in (tag, 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 compute_bill( inputs: dict[str, Any], user: dict[str, Any], base: dict[str, Any], price_of: PriceOf | None = None, ) -> dict[str, Any]: """`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) — 주면 구조물 열을 블록마다 나눔.""" gaps: list[dict[str, str]] = [] hopyo = inputs.get("hopyo") or {} 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"): for part, prices in split_by_block(item, price_of): items.append(_item_row(part, prices, gaps)) continue ref = item.get("ref") or {} prices = hopyo.get(ref.get("key")) if ref.get("key") else None items.append(_item_row(item, prices, gaps)) 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(item, prices, gaps)) owner = [row for row in items if row["section"] == "G"] 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, "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, } if owner: items.append(fee_row) if pct is None: gaps.append(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")) rows: list[dict[str, Any]] = [] sums: dict[str, dict[str, Any]] = {} for code, name, parent in SECTIONS: sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0} for row in items: code = row["section"] chain = [code] parent = next(p for c, _n, p in SECTIONS if c == code) if parent: chain.append(parent) for c in chain: for p in ("sum", *PARTS): sums[c][p] += row[p] or 0 if row["sum"] is None: sums[c]["blank"] += 1 for code, name, parent in SECTIONS: s = sums[code] rows.append( { "kind": "title", "key": f"sec:{code}", "section": code, "name": f"{code} {name}" if code != "G" else name, "sum": s["sum"], "labor": s["labor"], "material": s["material"], "expense": s["expense"], "blank": s["blank"], "note": ("(제외금액)" if code == "G" else "") + (f" 빈 줄 {s['blank']}" if s["blank"] else ""), } ) rows.extend(row for row in items if row["section"] == code) direct = {p: 0.0 for p in ("sum", *PARTS)} blank = 0 for code in DIRECT: for p in direct: direct[p] += sums[code][p] blank += sums[code]["blank"] 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 ) -> dict[str, Any]: """144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음.""" base = compute_base(inputs, user) bill = compute_bill(inputs, user, base, price_of) return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}