diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py new file mode 100644 index 000000000..be841b2c0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -0,0 +1,152 @@ +"""B09 설계내역서 항목 짜기(PLAN 145-3) — 서버 단독 · B08 수량 → 내역 줄(수량 · 단가 자리). + +공종 나무 = 엑셀 기번3 공통 꼴 고정(`B09_Estimation_Calc_Bill.SECTIONS`) · 공구 안 나눔. + 토공(1.1~1.6) = B08 토공집계표 줄 짝(`EARTH` — 엑셀 항목 이름 ↔ 토공집계표 구분 · 종류 · 규격) · + 단가 = 조합 밖 토공 산근 · 준비공 대가 — 원천 없음 → 빈칸 「연결 없음」(지시) + 구조물(2.1~2.3) = B08 구조물 집계표 열(사본 있을 때만 · 143) · + 구조물군 → 공종 짝(`STRUCTURE_SECTION`) + · 커플링 열은 자재(관급자재대)라 뺌 · 단가 = 대가 호표(145-2 가 `hopyo` 로 줌 · 그전엔 빈칸) + 운반(3) = 엑셀 운반공 줄 · 수량 = B08 총괄자재 짝 · 거리 · 단가 = 운반 산근(원천 없음 → 빈칸) + 자재대 · 관급자재대 = 화면 짝이 대비표 「별도」 · 「관급」 표시로 짬(사용자 조작 즉시) +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Material_Calc import _structure_row + +WHY_EARTH = "조합 밖 토공 산근 — 연결 없음" +WHY_READY = "준비공 대가 — 연결 없음" +WHY_HAUL = "운반 산근 — 연결 없음(운반거리 원천 없음)" +WHY_STRUCTURE = "대가 호표 — 연결 없음(일위대가 145-2)" + +#: 토공 항목 — (공종, 엑셀 명칭, 단위, 토공집계표 (구분, 종류, 규격 | None) | None) +EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [ + ("1.1", "규준틀설치 종단규준틀", "개소", None), + ("1.1", "규준틀설치 횡단규준틀", "개소", None), + ("1.1", "제근", "㎡", None), + ("1.2", "절토(토사)", "㎥", ("흙깍기", "토사", None)), + ("1.2", "절토(연암)", "㎥", ("흙깍기", "연암", None)), + ("1.2", "암발파", "㎥", ("흙깍기", "경암", None)), + ("1.3", "구조물터파기(토사)", "㎥", ("구조물터파기", "토사", None)), + ("1.3", "구조물터파기(연암)", "㎥", ("구조물터파기", "연암", None)), + ("1.3", "구조물 되메우기", "㎥", ("되메우기", "", None)), + ("1.4", "측구파기(토사)", "㎥", ("측구 터파기", "토사", None)), + ("1.4", "측구파기(연암)", "㎥", ("측구 터파기", "연암", None)), + ("1.5", "유용토운반", "㎥", ("유용토 소계", "", None)), + ("1.5", "도자운반", "㎥", ("토사운반", "", "19 TON B/D")), + ("1.5", "덤프운반", "㎥", ("토사운반", "", "BH+DT+BH")), + ("1.6", "사토처리", "㎥", ("잔토처리", "", None)), + ("1.6", "성토사면다짐", "㎡", ("성토면다짐", "", None)), + ("1.6", "노면정리", "㎡", None), +] + +#: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌 +STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"} +SKIP_COLUMNS = ("pipe:cp|",) + +#: 운반공 — (명칭, 단위, B08 총괄자재 줄 키(합) | None) +HAUL: list[tuple[str, str, tuple[str, ...] | None]] = [ + ("중기운반", "대", None), + ("기계운반", "대", None), + ("모래운반", "㎥", ("sand",)), + ("자갈운반", "㎥", ("gravel",)), + ("시멘트운반", "대", ("cement",)), + ("철근운반", "ton", ("rebar_d13", "rebar_d16", "rebar_d19", "rebar_d22", "rebar_d25")), +] + + +def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool: + group, kind, spec = want + same = row.get("group") == group and (row.get("kind") or "") == kind + return same and (spec is None or row.get("spec") == spec) + + +def earth_items(earthwork: dict[str, Any] | None) -> list[dict[str, Any]]: + rows = (earthwork or {}).get("summary") or [] + out = [] + for n, (section, name, unit, want) in enumerate(EARTH, 1): + source = next((r for r in rows if want and _same(r, want)), None) + out.append( + { + "key": f"earth:{n}", + "section": section, + "name": name, + "spec": (source or {}).get("spec") or "", + "unit": unit, + "qty": source.get("value") if source else None, + "note": "B08 토공집계표" if source else "", + "ref": None, + "why": WHY_READY if section == "1.1" else WHY_EARTH, + } + ) + return out + + +def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: + columns = (summary or {}).get("columns") or [] + total = (summary or {}).get("total") or {} + out = [] + for column in columns: + cid = str(column.get("id") or "") + if cid.startswith(SKIP_COLUMNS): + continue + section = next((s for head, s in STRUCTURE_SECTION.items() if cid.startswith(head)), "2.2") + name, spec, unit = _structure_row(column) + combos = [c for c in column.get("combos") or [] if c] + out.append( + { + "key": f"st:{cid}", + "section": section, + "name": name, + "spec": spec, + "unit": unit, + "qty": total.get(cid), + "note": " · ".join(combos), + "ref": {"tab": "dg_list", "key": cid}, + "why": WHY_STRUCTURE, + } + ) + return out + + +def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]: + rows = ((materials or {}).get("material") or {}).get("rows") or [] + amount = {row.get("key"): row.get("amount") for row in rows} + out = [] + for n, (name, unit, keys) in enumerate(HAUL, 1): + qty = None + if keys: + found = [amount[k] for k in keys if amount.get(k) is not None] + qty = round(sum(found), 6) if found else None + out.append( + { + "key": f"haul:{n}", + "section": "3", + "name": name, + "spec": "", + "unit": unit, + "qty": qty, + "note": "B08 총괄자재" if qty is not None else "", + "ref": None, + "why": WHY_HAUL, + } + ) + return out + + +def bill_items(b08: dict[str, Any]) -> dict[str, Any]: + return { + "items": [ + *earth_items(b08.get("earthwork")), + *structure_items(b08.get("summary")), + *haul_items(b08.get("materials")), + ] + } + + +def hopyo_prices(project_root: Any) -> dict[str, dict[str, float]]: + """호표 단가 `{호표 자리 키: {labor, material, expense}}` — + 145-1 · 2(산근 · 대가)가 들어오면 채움 · 그전엔 빈 묶음(구조물 단가 = 빈칸 「연결 없음」).""" + return {} diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py new file mode 100644 index 000000000..c6b44eabc --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -0,0 +1,208 @@ +"""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 typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import compute_base, gap, num, 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 + + +def compute_bill( + inputs: dict[str, Any], user: dict[str, Any], base: dict[str, Any] +) -> dict[str, Any]: + 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 []: + 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]) -> dict[str, Any]: + """144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음.""" + base = compute_base(inputs, user) + bill = compute_bill(inputs, user, base) + return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]} diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts new file mode 100644 index 000000000..c09597a27 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -0,0 +1,197 @@ +/* ============================================================================= + * B09_Estimation_Calc_Bill.ts + * B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임). + * + * ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_145_3_b09_bill.py`. + * + * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 + * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 + * 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0) + * 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null) + * ========================================================================== */ + +import { + computeBase, + gap, + num, + rounddown, + type Gap, + type Num, + type Row, +} from "./B09_Estimation_Calc_Base"; + +/** 공종 나무 — [번호, 이름, 부모] */ +export const SECTIONS: Array<[string, string, string | null]> = [ + ["1", "토공사", null], + ["1.1", "준비공", "1"], + ["1.2", "절토", "1"], + ["1.3", "구조물터파기 및 되메우기", "1"], + ["1.4", "측구파기", "1"], + ["1.5", "유용성토", "1"], + ["1.6", "사토 및 다짐공", "1"], + ["2", "구조물공", null], + ["2.1", "돌기슭막이공", "2"], + ["2.2", "배수공", "2"], + ["2.3", "기타구조물", "2"], + ["3", "운반공", null], + ["4", "자재대", null], + ["G", "관급자재대", null], +]; +const DIRECT = ["1", "2", "3", "4"]; +const PARTS = ["labor", "material", "expense"]; + +function amounts(qty: Num, prices: Row | null): Row { + const unit: Record = {}; + for (const p of PARTS) unit[p] = prices === null ? null : num(prices[p]); + const out: Row = {}; + for (const p of PARTS) out[`price_${p}`] = unit[p]; + const known = PARTS.every((p) => unit[p] !== null); + out.price_sum = known ? unit.labor! + unit.material! + unit.expense! : null; + for (const p of PARTS) + out[p] = qty !== null && unit[p] !== null ? rounddown(qty * unit[p]!, 0) : null; + const filled = PARTS.every((p) => out[p] !== null); + out.sum = filled ? out.labor + out.material + out.expense : null; + return out; +} + +function itemRow(item: Row, prices: Row | null, gaps: Gap[]): Row { + const qty = num(item.qty); + const row: Row = { + kind: "item", + key: item.key, + section: item.section, + name: item.name || "", + spec: item.spec || "", + unit: item.unit || "", + qty, + note: item.note || "", + ref: item.ref ?? null, + ...amounts(qty, prices), + }; + if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음")); + if (prices === null) gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음")); + return row; +} + +function materialItems(inputs: Row, compare: Row[]): Row[] { + const qtyOf: Row = {}; + for (const m of inputs.materials || []) if ("qty" in m) qtyOf[m.key] = m.qty; + const out: Row[] = []; + for (const row of compare) { + if (row.supply === null || !(row.key in qtyOf)) continue; + out.push({ + key: `mat:${row.key}`, + section: row.supply === "관급" ? "G" : "4", + name: row.name, + spec: row.spec, + unit: row.unit, + qty: qtyOf[row.key], + note: `자재 ${row.no}`, + ref: { tab: "material", key: row.key }, + _price: row.applied, + }); + } + return out; +} + +export function computeBill(inputs: Row, user: Row, base: Row): Row { + const gaps: Gap[] = []; + const hopyo: Row = inputs.hopyo || {}; + const items: Row[] = []; + for (const item of (inputs.bill || {}).items || []) { + const ref: Row = item.ref || {}; + const prices = ref.key ? (hopyo[ref.key] ?? null) : null; + items.push(itemRow(item, prices, gaps)); + } + for (const item of materialItems(inputs, base.compare)) { + const price: Num = item._price; + const prices = price === null ? null : { labor: 0, material: price, expense: 0 }; + items.push(itemRow(item, prices, gaps)); + } + const owner = items.filter((row) => row.section === "G"); + let net = 0; + for (const row of owner) net += row.material || 0; + const pct = num((user.estimate || {}).procurement_fee_pct); + const fee = pct !== null ? rounddown((net * pct) / 100, 0) : null; + const feeRow: Row = { + kind: "item", + key: "fee", + section: "G", + name: "조달수수료", + spec: "관급 재료비 × 율", + unit: "%", + qty: pct, + note: "관급 순자재대 밑수", + ref: null, + price_labor: 0, + price_material: net, + price_expense: 0, + price_sum: net, + labor: pct !== null ? 0 : null, + material: fee, + expense: pct !== null ? 0 : null, + sum: fee, + }; + if (owner.length) { + items.push(feeRow); + if (pct === null) gaps.push(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")); + } + const sums: Record = {}; + for (const [code] of SECTIONS) + sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 }; + for (const row of items) { + const code = row.section; + const chain = [code]; + const parent = SECTIONS.find(([c]) => c === code)![2]; + if (parent) chain.push(parent); + for (const c of chain) { + for (const p of ["sum", ...PARTS]) sums[c][p] += row[p] || 0; + if (row.sum === null) sums[c].blank += 1; + } + } + const rows: Row[] = []; + for (const [code, name] of SECTIONS) { + const s = sums[code]; + rows.push({ + kind: "title", + key: `sec:${code}`, + section: code, + name: code !== "G" ? `${code} ${name}` : name, + sum: s.sum, + labor: s.labor, + material: s.material, + expense: s.expense, + blank: s.blank, + note: (code === "G" ? "(제외금액)" : "") + (s.blank ? ` 빈 줄 ${s.blank}` : ""), + }); + rows.push(...items.filter((row) => row.section === code)); + } + const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 }; + let blank = 0; + for (const code of DIRECT) { + for (const p of Object.keys(direct)) direct[p] += sums[code][p]; + blank += sums[code].blank; + } + const totals = { + direct: { ...direct, blank }, + owner: { net_material: net, fee, total: net + (fee || 0) }, + }; + // 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null + const known = blank === 0; + const ownerKnown = owner.every((row) => row.sum !== null); + const sumsOut = { + material: known ? direct.material : null, + labor: known ? direct.labor : null, + expense: known ? direct.expense : null, + supply_net: ownerKnown ? net : null, + }; + return { rows, totals, sums: sumsOut, gaps }; +} + +/** 144 바탕 + 145-3 설계내역서 */ +export function computeEstimation(inputs: Row, user: Row): Row { + const base = computeBase(inputs, user); + const bill = computeBill(inputs, user, base); + return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] }; +} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index aee09e9dd..245b82f36 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -21,14 +21,17 @@ from uuid import UUID from fastapi import APIRouter, Body +from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Store as store -from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation from B09_Estimation.B09_Estimation_Calc_Total import compute_total from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates +from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -36,11 +39,17 @@ router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: """B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음).""" out: dict[str, Any] = {} - try: - out["materials"] = await material_sheets(project_id) - except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림 - notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}") - out["materials"] = None + for key, name, read in ( + ("materials", "자재 집계표", material_sheets), + ("earthwork", "토공집계표", earthwork_table), + ): + try: + out[key] = await read(project_id) + except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림 + notes.append(f"B08 {name} 못 읽음 — {getattr(error, 'detail', error)}") + out[key] = None + # 구조물 집계표 = 자재 집계표가 이미 부른 값(열 · 계 · 조합) — 두 번 안 부름 + out["summary"] = (out["materials"] or {}).get("source") return out @@ -54,8 +63,10 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: ) book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) + inputs["bill"] = bill_items(b08) + inputs["hopyo"] = hopyo_prices(root) user = await asyncio.to_thread(store.read_user, root) - sheets = compute_base(inputs, user) + sheets = compute_estimation(inputs, user) # 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate")) return { @@ -65,6 +76,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "sheets": sheets, "rate_candidates": rate_candidates(), "notes": notes, + # 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜 + "summary": compute_summary(sheets, hopyo_lines(root)), } diff --git a/B09_Estimation/B09_Estimation_Summary.py b/B09_Estimation/B09_Estimation_Summary.py new file mode 100644 index 000000000..2812af643 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Summary.py @@ -0,0 +1,124 @@ +"""B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표 +(서버 단독). + +역전개 = 설계내역서 항목 수량 × 호표 구성(산근 · 대가 줄 · 겹 호표 끝까지) → 자원마다 총수량. + 호표 구성 = `hopyo_lines` — 145-1 · 2(산근 · 대가)가 `{호표 키: {"lines": [{kind, key, qty}]}}` + (호표 1 단위당 · kind = labor · material · expense · machine · hopyo)로 줌 · 그전엔 빈 묶음 + 4 자재대 · 관급자재대 자재 줄 = 그 자재 그대로(호표 없음) +끝수 = 엑셀 자리 그대로 — 총수량 ROUND 2 · 금액 = ROUND(수량 × 단가, 0)(여기만 반올림) · + 중기 = 노무 · 재료 · 경비 각 ROUND(시간 × 시간당, 0) · 합계 = 셋 합. +단가 = 목록표(노무비 · 재료비 · 경비 · 중기목록표) — 없으면 금액 빈칸(수량은 둠). +결과는 GET 때 셈(저장 안 함) · 조작 중엔 다시 안 셈([저장] 뒤 서버 정본). +""" + +from __future__ import annotations + +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num, round_half + +#: 겹 호표 깊이 한도(엑셀 최대 대가 → 대가 → 산근 → 중기 = 4) — 돌고 도는 구성 막음 +MAX_DEPTH = 8 +KINDS = ("material", "labor", "expense", "machine") + + +def expand( + lines_of: dict[str, Any], key: str, qty: float, out: dict[str, dict[str, float]], depth: int = 0 +) -> None: + """호표 하나 × 수량 → 자원 총수량에 더함(겹 호표는 끝까지 · 깊이 한도 넘으면 멈춤).""" + if depth > MAX_DEPTH: + return + for line in (lines_of.get(key) or {}).get("lines") or []: + q = num(line.get("qty")) + if q is None: + continue + kind = line.get("kind") + if kind == "hopyo": + expand(lines_of, str(line.get("key")), qty * q, out, depth + 1) + elif kind in KINDS: + bucket = out.setdefault(kind, {}) + bucket[str(line.get("key"))] = bucket.get(str(line.get("key")), 0.0) + qty * q + + +def totals(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, dict[str, float]]: + """설계내역서 항목 → 자원 총수량(내역 차례 · 관급 포함).""" + out: dict[str, dict[str, float]] = {} + for row in (sheets.get("bill") or {}).get("rows") or []: + qty = num(row.get("qty")) + ref = row.get("ref") or {} + if row.get("kind") != "item" or qty is None or not ref.get("key"): + continue + if ref.get("tab") == "material": + bucket = out.setdefault("material", {}) + bucket[ref["key"]] = bucket.get(ref["key"], 0.0) + qty + else: + expand(lines_of, str(ref["key"]), qty, out) + return out + + +def _list(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {row["key"]: row for row in rows} + + +def _resource_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]], unit_default: str): + rows = [] + for no, (key, qty) in enumerate(used.items(), 1): + src = listed.get(key) or {} + q = round_half(qty, 2) + price = num(src.get("price")) + rows.append( + { + "no": no, + "key": key, + "name": src.get("name") or key, + "spec": src.get("spec") or "", + "unit": src.get("unit") or unit_default, + "qty": q, + "price": price, + "amount": round_half(q * price, 0) if price is not None else None, + "note": "" if src else "목록표에 없음", + } + ) + return rows + + +def _machine_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]]): + rows = [] + for no, (key, qty) in enumerate(used.items(), 1): + src = listed.get(key) or {} + q = round_half(qty, 2) + parts = {} + for part in ("labor", "material", "expense"): + unit = num(src.get(part)) + parts[part] = round_half(q * unit, 0) if unit is not None else None + known = all(v is not None for v in parts.values()) + rows.append( + { + "no": no, + "key": key, + "name": src.get("name") or key, + "spec": src.get("spec") or "", + "unit": "시간", + "qty": q, + **parts, + "sum": parts["labor"] + parts["material"] + parts["expense"] if known else None, + "note": "" if src else "목록표에 없음", + } + ) + return rows + + +def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, Any]: + """집계표 넷 — `sheets` = 짝 셈 결과(바탕 + 설계내역서).""" + used = totals(sheets, lines_of) + return { + "sum_material": _resource_sheet(used.get("material", {}), _list(sheets["material"]), ""), + "sum_labor": _resource_sheet(used.get("labor", {}), _list(sheets["labor"]), "인"), + "sum_expense": _resource_sheet(used.get("expense", {}), _list(sheets["expense"]), ""), + "sum_machine": _machine_sheet(used.get("machine", {}), _list(sheets["machine"])), + } + + +def hopyo_lines(project_root: Any) -> dict[str, Any]: + """호표 구성(1 단위당 자원 · 겹 호표) — 145-1 · 2 가 들어오면 채움 · 그전엔 빈 묶음.""" + return {} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index cdda00fb7..e66ae68fb 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -24,7 +24,8 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; -import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { buildCheck, @@ -44,6 +45,13 @@ import { buildMachineBlocks, buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; +import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; +import { + buildSumExpense, + buildSumLabor, + buildSumMachine, + buildSumMaterial, +} from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; function L(key: keyof typeof ui_locales): string { @@ -59,6 +67,7 @@ const LATER: Record = { const BUILDERS: Partial> = { check: buildCheck, + bill: buildBill, basis: buildBasis, machine: buildMachine, machine_blocks: buildMachineBlocks, @@ -68,6 +77,10 @@ const BUILDERS: Partial> = { compare: buildCompare, cost: buildCost, total: buildTotal, + sum_material: buildSumMaterial, + sum_labor: buildSumLabor, + sum_expense: buildSumExpense, + sum_machine: buildSumMachine, }; async function request(path: string, init: RequestInit = {}): Promise { @@ -93,7 +106,7 @@ function note(textValue: string): HTMLElement { /** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */ export function computeSheets(payload: Row, user: Row): Row { - const sheets = computeBase(payload.inputs, user); + const sheets = computeEstimation(payload.inputs, user); // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 return { ...sheets, total: computeTotal(sheets.bill?.sums, user.estimate) }; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts new file mode 100644 index 000000000..020e0c3f5 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill.ts + * B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고). + * + * 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본) + * 고칠 칸 = 관급자재대 조달수수료 율(%) · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음) + * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { + money, + parseNumber, + qty, + text, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => { + const bill: Row = sheets.bill || { rows: [], totals: null }; + const rows: DataRow[] = bill.rows.map((r: Row) => ({ + ...r, + name: r.kind === "title" ? r.name : ` ${r.name}`, + })); + const direct: Row = bill.totals?.direct || {}; + const pair = (key: string, label: string) => [ + money(`price_${key}`, "단가", [label]), + money(key, "금액", [label]), + ]; + return { + columns: [ + text("name", "공종 · 명칭", 220), + text("spec", "규격", 160), + qty("qty", "수량", 3), + text("unit", "단위", 56), + ...pair("sum", "합계"), + ...pair("labor", "노무비"), + ...pair("material", "재료비"), + ...pair("expense", "경비"), + text("note", "비고", 200), + ], + rows, + total: { name: "순공사비 계(관급 제외)", ...direct }, + frozen: 1, + hint: + "공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " + + `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, + editable: (i, key) => key === "qty" && rows[i]?.key === "fee", + onEdit: (i, key, value) => { + if (key !== "qty" || rows[i]?.key !== "fee") return; + const v = parseNumber(value); + if (v === undefined) return; + setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } }); + }, + link: (i) => { + const ref = rows[i]?.ref as Row | null | undefined; + return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null; + }, + }; +}; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts new file mode 100644 index 000000000..d659be555 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -0,0 +1,82 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Summary.ts + * B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표(실무 엑셀 꼴). + * + * 값 = 서버 단독 역전개(`B09_Estimation_Summary` · GET 응답 `summary`) — 조작 중엔 안 바뀜 · [저장] 뒤 정본 + * 끝수 = 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 줄 누름 = 단가를 낸 목록표 줄 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { + money, + qty, + text, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +const HINT = + "총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표"; + +function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild { + return ({ payload }): TabTable => { + const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r })); + const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 140), + qty("qty", "수량", 2), + text("unit", "단위", 56), + money("price", unitPrice), + money("amount", "금액"), + text("note", "비고", 120), + ], + rows, + total: rows.length ? { name: "계", amount: sum } : null, + frozen: 2, + hint: HINT, + link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null), + }; + }; +} + +export const buildSumMaterial = resourceTab("sum_material", "material", "단가"); +export const buildSumLabor = resourceTab("sum_labor", "labor", "단가"); +export const buildSumExpense = resourceTab("sum_expense", "expense", "단가"); + +export const buildSumMachine: TabBuild = ({ payload }): TabTable => { + const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r })); + const add = (key: string) => + rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 200), + text("spec", "규격", 120), + qty("qty", "수량", 2), + text("unit", "단위", 56), + money("sum", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + text("note", "비고", 120), + ], + rows, + total: rows.length + ? { + name: "계", + sum: add("sum"), + labor: add("labor"), + material: add("material"), + expense: add("expense"), + } + : null, + frozen: 2, + hint: `${HINT} · 비목마다 ROUND(시간 × 시간당, 0)`, + link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null), + }; +}; diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index 23cfd7ec7..c50dd1a30 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -106,7 +106,7 @@ def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch): assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == [] assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만 assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님) - assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [ + assert [(g["tab"], g["key"]) for g in sheets["gaps"] if g["tab"] == "compare"] == [ ("compare", "b08:sand"), ("compare", "b08:cement"), ] diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py new file mode 100644 index 000000000..0705f0bbd --- /dev/null +++ b/resources/tester/test_145_3_b09_bill.py @@ -0,0 +1,199 @@ +"""145-3 B09 설계내역서 — 엑셀 검산 대조 · 항목 짜기 · 원가계산서 이음 꼴 · +거울(`Calc_Bill.py` ↔ `.ts`). + +견본 = 기번3 공통 엑셀 설계내역서: + 9 · 10줄 규준틀(대가 5 · 8) · 14줄 절토(토사)(산근 13) — 같은 호표 단가면 같은 금액 + 246~253줄 관급자재대 — 순자재대 69,474,220 · 조달수수료 0.54 % = 375,160 · 계 69,849,380 +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Bill_Items import bill_items # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +#: 관급 자재(엑셀 246~252 · 재료비목록표 단가) — [키, 품명, 규격, 단위, 수량, 단가] +OWNER = [ + ("b08:remicon_25_18_80", "레미콘", "25-18-80", "M3", 32, 102970), + ("b08:pipe_800", "파형강관", "Φ800m/m, 2.7t", "M", 212, 157000), + ("b08:pipe_1000", "파형강관", "Φ1000m/m, 2.7t", "M", 132, 196270), + ("b08:pipe_1200", "파형강관", "Φ1200m/m, 3.2t", "M", 22, 273370), + ("b08:cp_800", "파형강관이음관", "Φ800m/m", "개", 22, 21200), + ("b08:cp_1000", "파형강관이음관", "Φ1000m/m", "개", 12, 32200), + ("b08:cp_1200", "파형강관이음관", "Φ1200m/m", "개", 3, 40200), +] +MATERIALS = [ + {"key": k, "name": n, "spec": s, "unit": u, "origin": "B08", "prices": {"관급": p}, "qty": q} + for k, n, s, u, q, p in OWNER +] + [ + {"key": "b08:sand", "name": "골재", "spec": "모래", "unit": "㎥", "origin": "B08", + "prices": {"물가정보": 30000}, "qty": 16}, + {"key": "b08:rubble", "name": "골재", "spec": "막자갈", "unit": "㎥", "origin": "B08", + "prices": {}, "qty": 5}, +] # fmt: skip +ITEMS = [ + {"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14, + "ref": {"tab": "dg_list", "key": "B00194"}}, + {"key": "earth:2", "section": "1.1", "name": "규준틀설치 횡단규준틀", "unit": "개소", "qty": 28, + "ref": {"tab": "dg_list", "key": "B00467"}}, + {"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675, + "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972, + "ref": None, + "why": "조합 밖 토공 산근 — 연결 없음"}, +] # fmt: skip +HOPYO = { + "B00194": {"labor": 21936, "material": 3885, "expense": 0}, # 일위대가목록표 F8~H8 + "B00467": {"labor": 33032, "material": 6942, "expense": 0}, + "D01341": {"labor": 1116, "material": 360, "expense": 463}, # 단가산출근거목록표 16줄 +} +INPUTS = {"materials": MATERIALS, "bill": {"items": ITEMS}, "hopyo": HOPYO} +USER = { + "materials": {**{k: {"supply": "관급"} for k, *_ in OWNER}, "b08:sand": {"supply": "별도"}, + "b08:rubble": {"supply": "별도"}}, + "estimate": {"procurement_fee_pct": 0.54}, +} # fmt: skip + + +def _run_ts(inputs, user): + files = ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"] + source = "".join( + IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8")) for f in files + ) + body = ( + f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};" + f"\nconst u = {json.dumps(user, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(computeEstimation(i, u)));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", source + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _rows(sheets): + return {row["key"]: row for row in sheets["bill"]["rows"]} + + +def test_호표_단가_줄_엑셀_금액(): + rows = _rows(compute_estimation(INPUTS, USER)) + row = rows["earth:1"] + assert (row["price_sum"], row["labor"], row["material"], row["expense"], row["sum"]) == ( + 25821, 307104, 54390, 0, 361494, + ) # fmt: skip # 설계내역서 F9 · I9 · K9 · M9 · G9 + assert rows["earth:2"]["sum"] == 1119272 # G10 + row = rows["earth:4"] + assert (row["labor"], row["material"], row["expense"], row["sum"]) == ( + 6333300, 2043000, 2627525, 11003825, + ) # fmt: skip # 설계내역서 I14 · K14 · M14 · G14 + assert rows["earth:5"]["sum"] is None and rows["earth:5"]["qty"] == 972 # 단가 연결 없음 + + +def test_관급자재대_조달수수료_엑셀(): + sheets = compute_estimation(INPUTS, USER) + rows = _rows(sheets) + assert rows["mat:b08:pipe_800"]["material"] == 33284000 # K247 + assert rows["mat:b08:remicon_25_18_80"]["section"] == "G" + fee = rows["fee"] + assert (fee["qty"], fee["price_material"], fee["material"]) == (0.54, 69474220, 375160) # 253줄 + assert rows["sec:G"]["material"] == 69849380 # K245 관급자재대 + assert "(제외금액)" in rows["sec:G"]["note"] + owner = sheets["bill"]["totals"]["owner"] + assert owner == {"net_material": 69474220, "fee": 375160, "total": 69849380} + # 「별도」 = 4 자재대 · 단가 없는 별도 자재 = 빈 줄 + assert rows["mat:b08:sand"]["section"] == "4" and rows["mat:b08:sand"]["material"] == 480000 + assert rows["mat:b08:rubble"]["sum"] is None and rows["sec:4"]["blank"] == 1 + + +def test_제목_합_빈_줄과_원가계산서_이음(): + sheets = compute_estimation(INPUTS, USER) + rows = _rows(sheets) + keys = [row["key"] for row in sheets["bill"]["rows"] if row["kind"] == "title"] + assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G" + assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0 + assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수 + assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"] + # 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None + assert sheets["bill"]["sums"] == { + "material": None, + "labor": None, + "expense": None, + "supply_net": 69474220, + } + full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]} + sums = compute_estimation(full, USER)["bill"]["sums"] + assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14 + assert sums["material"] == 54390 + 194376 + 2043000 + 480000 + assert sums["expense"] == 2627525 + + +def test_조달수수료_율_빈칸이면_빈칸(): + user = {**USER, "estimate": {}} + sheets = compute_estimation(INPUTS, user) + fee = _rows(sheets)["fee"] + assert fee["material"] is None and fee["qty"] is None + assert ("bill", "fee") in {(g["tab"], g["key"]) for g in sheets["gaps"]} + assert sheets["bill"]["totals"]["owner"]["total"] == 69474220 + + +def test_항목_짜기_B08_수량(): + earthwork = {"summary": [ + {"group": "흙깍기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥", "value": 5675}, + {"group": "흙깍기", "kind": "연암", "spec": "굴삭기 + 브레카", "unit": "㎥", "value": 972}, + {"group": "토사운반", "kind": "", "spec": "BH+DT+BH", "unit": "㎥", "value": 120}, + {"group": "토사운반", "kind": "", "spec": "19 TON B/D", "unit": "㎥", "value": 80}, + {"group": "구조물터파기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥", + "value": None}, + ]} # fmt: skip + summary = { + "columns": [ + {"id": "pipe:revet|돌쌓기(찰)|2.0", "group": ["흙막이", "찰쌓기", "H=2.0"], + "label": "m", "combos": ["기슭막이 찰"]}, + {"id": "pipe:len|800", "group": ["관 공", "Φ800m/m", "관매설"], "label": "m"}, + {"id": "pipe:cp|800", "group": ["관 공", "Φ800m/m", "커플링"], "label": "ea"}, + {"id": "pipe:chute|찰붙임", "group": ["돌붙임", "찰붙임"], "label": "㎡"}, + ], + "total": {"pipe:revet|돌쌓기(찰)|2.0": 20, "pipe:len|800": 84, "pipe:cp|800": 9}, + } # fmt: skip + materials = {"material": {"rows": [ + {"key": "sand", "amount": 16}, {"key": "rebar_d13", "amount": 0.5}, + {"key": "rebar_d16", "amount": 0.25}, {"key": "gravel", "amount": None}, + ]}} # fmt: skip + items = {i["key"]: i for i in bill_items({"earthwork": earthwork, "summary": summary, + "materials": materials})["items"]} # fmt: skip + assert items["earth:4"]["qty"] == 5675 and items["earth:4"]["name"] == "절토(토사)" + assert items["earth:5"]["qty"] == 972 and items["earth:6"]["qty"] is None # 경암 줄 없음 + assert items["earth:13"]["qty"] == 80 and items["earth:14"]["qty"] == 120 # 도자 · 덤프 + assert items["earth:7"]["qty"] is None # 구조물터파기 = B08 도 빈칸 + assert items["earth:1"]["why"].startswith("준비공") and items["earth:4"]["ref"] is None + st = {k: v for k, v in items.items() if k.startswith("st:")} + assert [(v["section"], v["qty"]) for v in st.values()] == [ + ("2.1", 20), + ("2.2", 84), + ("2.3", None), + ] + assert "st:pipe:cp|800" not in items # 커플링 = 관급 자재 + assert items["st:pipe:revet|돌쌓기(찰)|2.0"]["ref"] == { + "tab": "dg_list", + "key": "pipe:revet|돌쌓기(찰)|2.0", + } + haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")} + assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None, + "시멘트운반": None, "철근운반": 0.75} # fmt: skip + + +def test_거울_TS_가_같은_값(): + for user in (USER, {**USER, "estimate": {}}, {}): + assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user) diff --git a/resources/tester/test_145_5_b09_summary.py b/resources/tester/test_145_5_b09_summary.py new file mode 100644 index 000000000..28c08fa71 --- /dev/null +++ b/resources/tester/test_145_5_b09_summary.py @@ -0,0 +1,97 @@ +"""145-5 B09 집계표 넷 — 역전개(겹 호표) · 엑셀 끝수 · 검산 대조(서버 단독 · 거울 없음). + +견본 = 기번3 공통 엑셀 집계표: + 중기시간금액집계표 4줄 불도우저19ton 42.95 시간 → 2,392,315 · 1,584,340 · 1,435,045 = 5,411,700 + 노무비수량금액집계표 4줄 건축목공 8.29 인 × 268,058 = 2,222,201 + 재료비수량금액집계표 각재 0.56 × 500,661 = 280,370 · 못 3.54 × 1,503 = 5,321 + 경비수량금액집계표 구역화물자동차 11.03 회 × 61,500 = 678,345 +""" + +import asyncio +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 +from B09_Estimation.B09_Estimation_Summary import compute_summary # noqa: E402 + +SHEETS = { + "labor": [{"key": "L1", "name": "건축목공", "spec": "", "unit": "인", "price": 268058}], + "material": [ + {"key": "M3", "name": "각재", "spec": "외송", "unit": "M3", "price": 500661}, + {"key": "M4", "name": "못", "spec": "N 50", "unit": "KG", "price": 1503}, + ], + "expense": [ + {"key": "E16", "name": "구역화물자동차", "spec": "5톤", "unit": "회", "price": 61500} + ], + "machine": [ + { + "key": "X1", + "name": "불도우저19ton", + "spec": "(토사)", + "unit": "시간", + "sum": 126000, + "labor": 55700, + "material": 36888, + "expense": 33412, + }, + ], # fmt: skip + "bill": { + "rows": [ + {"kind": "title", "key": "sec:1", "qty": None}, + {"kind": "item", "key": "a", "qty": 10, "ref": {"tab": "sg_list", "key": "A"}}, + {"kind": "item", "key": "m", "qty": 0.56, "ref": {"tab": "material", "key": "M3"}}, + {"kind": "item", "key": "none", "qty": None, "ref": {"tab": "sg_list", "key": "A"}}, + {"kind": "item", "key": "loop", "qty": 1, "ref": {"tab": "dg_list", "key": "C"}}, + ] + }, +} +LINES = { + "A": {"lines": [ + {"kind": "machine", "key": "X1", "qty": 4.295}, + {"kind": "hopyo", "key": "B", "qty": 0.5}, + {"kind": "material", "key": "M4", "qty": 0.354}, + {"kind": "expense", "key": "E16", "qty": 1.103}, + ]}, + "B": {"lines": [{"kind": "labor", "key": "L1", "qty": 1.658}, + {"kind": "labor", "key": "L9", "qty": 1}]}, + "C": {"lines": [{"kind": "hopyo", "key": "C", "qty": 1}]}, # 스스로 부름 — 깊이 한도에서 멈춤 +} # fmt: skip + + +def test_역전개_엑셀_금액(): + s = compute_summary(SHEETS, LINES) + machine = s["sum_machine"][0] + assert (machine["qty"], machine["labor"], machine["material"], machine["expense"]) == ( + 42.95, 2392315, 1584340, 1435045, + ) # fmt: skip + assert machine["sum"] == 5411700 + labor = {r["key"]: r for r in s["sum_labor"]} + assert (labor["L1"]["qty"], labor["L1"]["amount"]) == ( + 8.29, + 2222201, + ) # 겹 호표 10 × 0.5 × 1.658 + assert labor["L9"]["amount"] is None and labor["L9"]["note"] == "목록표에 없음" + material = {r["key"]: (r["qty"], r["amount"]) for r in s["sum_material"]} + assert material == {"M4": (3.54, 5321), "M3": (0.56, 280370)} # 내역 자재 줄 그대로 + assert [(r["qty"], r["amount"]) for r in s["sum_expense"]] == [(11.03, 678345)] + + +def test_호표_구성_없으면_자재_줄만(): + s = compute_summary(SHEETS, {}) + assert s["sum_labor"] == [] and s["sum_machine"] == [] and s["sum_expense"] == [] + assert [r["key"] for r in s["sum_material"]] == ["M3"] + + +def test_GET_응답에_집계표(tmp_path, monkeypatch): + async def fake_materials(_project_id): + return {"material": {"rows": [{"key": "sand", "group": "골재", "spec": "모래", + "unit": "㎥", "amount": 16}]}} # fmt: skip + + monkeypatch.setattr(router_mod, "material_sheets", fake_materials) + body = asyncio.run(router_mod.payload("p", tmp_path)) + assert set(body["summary"]) == {"sum_material", "sum_labor", "sum_expense", "sum_machine"} + assert body["summary"]["sum_material"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음