From 03fdde07fbf7e462a27a56a9cb5a46a3f8b2224f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:24:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EB=82=B4?= =?UTF-8?q?=EC=97=AD=EC=84=9C=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A4=84=20?= =?UTF-8?q?=3D=20=EB=8C=80=EA=B0=80=20=EB=B8=94=EB=A1=9D=EB=A7=88=EB=8B=A4?= =?UTF-8?q?=20=EB=82=98=EB=88=94=20=E2=80=94=20B08=20=EC=97=B4=20owners=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0(145-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항목 짜기 = B08 구조물 집계표 열 owners {사본 주인 id: 값}(sub8 덧칸) 를 싣고 - 짝 셈 split_by_block / splitByBlock = 주인마다 대가 단가(price_of = 145-2 daega_price 자리) → 같은 대가 블록끼리 한 줄(수량 합 ROUND 3) · 대가 없는 주인 = 한 줄 「연결 없음」 - price_of 없으면 열 하나 그대로(단가 빈칸) - 시험 = test_145_3_b09_bill 더함(대가17 × 20 = 2,491,880 · 대가24 × 12.5 = 2,078,274 · 거울) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Bill_Items.py | 2 + B09_Estimation/B09_Estimation_Calc_Bill.py | 46 +++++++++++++++-- B09_Estimation/B09_Estimation_Calc_Bill.ts | 45 +++++++++++++++-- resources/tester/test_145_3_b09_bill.py | 55 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 7 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index be841b2c0..29891ac95 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -105,6 +105,8 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: "qty": total.get(cid), "note": " · ".join(combos), "ref": {"tab": "dg_list", "key": cid}, + # 사본 주인마다 값(B08 145-3 덧칸) — 대가 블록마다 줄을 나눌 때 씀 + "owners": dict(column.get("owners") or {}), "why": WHY_STRUCTURE, } ) diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index c6b44eabc..efce2981a 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -18,9 +18,10 @@ 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, rounddown +from B09_Estimation.B09_Estimation_Calc_Base import compute_base, gap, num, round_half, rounddown #: 공종 나무 — (번호, 이름, 부모) · 엑셀 기번3 공통 1공구 꼴(공구 안 나눔) SECTIONS: list[tuple[str, str, str | None]] = [ @@ -103,13 +104,48 @@ def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> li 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"]}, + } + out.append((part, group["price"])) + return out + + def compute_bill( - inputs: dict[str, Any], user: dict[str, Any], base: dict[str, Any] + 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)) @@ -201,8 +237,10 @@ def compute_bill( return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps} -def compute_estimation(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: +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) + bill = compute_bill(inputs, user, base, price_of) 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 index c09597a27..0661db921 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -15,6 +15,7 @@ import { computeBase, gap, num, + roundHalf, rounddown, type Gap, type Num, @@ -95,11 +96,49 @@ function materialItems(inputs: Row, compare: Row[]): Row[] { return out; } -export function computeBill(inputs: Row, user: Row, base: Row): Row { +/** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */ +export type PriceOf = (owner: string) => Row | null; + +/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */ +export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> { + const groups = new Map(); + for (const [owner, value] of Object.entries(item.owners || {})) { + const price = priceOf(owner); + const tag = price ? String(price.no || "") : ""; + if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] }); + const group = groups.get(tag)!; + group.qty += num(value) || 0; + group.owners.push(owner); + } + const out: Array<[Row, Row | null]> = []; + for (const [tag, group] of groups) { + const part: Row = { + ...item, + key: tag ? `${item.key}#${tag}` : item.key, + qty: roundHalf(group.qty, 3), + note: [tag, item.note || ""].filter((t) => t).join(" · "), + owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])), + }; + out.push([part, group.price]); + } + return out; +} + +export function computeBill( + inputs: Row, + user: Row, + base: Row, + priceOf: PriceOf | null = null, +): Row { const gaps: Gap[] = []; const hopyo: Row = inputs.hopyo || {}; const items: Row[] = []; for (const item of (inputs.bill || {}).items || []) { + if (priceOf !== null && item.owners && Object.keys(item.owners).length) { + for (const [part, prices] of splitByBlock(item, priceOf)) + items.push(itemRow(part, prices, gaps)); + continue; + } const ref: Row = item.ref || {}; const prices = ref.key ? (hopyo[ref.key] ?? null) : null; items.push(itemRow(item, prices, gaps)); @@ -190,8 +229,8 @@ export function computeBill(inputs: Row, user: Row, base: Row): Row { } /** 144 바탕 + 145-3 설계내역서 */ -export function computeEstimation(inputs: Row, user: Row): Row { +export function computeEstimation(inputs: Row, user: Row, priceOf: PriceOf | null = null): Row { const base = computeBase(inputs, user); - const bill = computeBill(inputs, user, base); + const bill = computeBill(inputs, user, base, priceOf); return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] }; } diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index 0705f0bbd..942782b65 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -197,3 +197,58 @@ def test_항목_짜기_B08_수량(): def test_거울_TS_가_같은_값(): for user in (USER, {**USER, "estimate": {}}, {}): assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user) + + +#: 사본 주인 → 대가 1 단위(145-2 `daega_price` 자리 견본) · 엑셀 일위대가목록표 대가17 · 24 +DAEGA = { + "pipe@100.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417}, + "pipe@200.0__gsoiladd1": {"no": "대가17", "labor": 96868, "material": 11309, "expense": 16417}, + "pipe@300.0__gsoiladd1": {"no": "대가24", "labor": 129370, "material": 15059, "expense": 21833}, +} +REVET = { + "key": "st:pipe:revet|돌쌓기(찰)|2.0", "section": "2.1", "name": "흙막이", + "spec": "찰쌓기 H=2.0", "unit": "m", "qty": 42.5, "note": "", + "ref": {"tab": "dg_list", "key": "pipe:revet|돌쌓기(찰)|2.0"}, + "owners": {"pipe@100.0__gsoiladd1": 10, "pipe@300.0__gsoiladd1": 12.5, + "pipe@200.0__gsoiladd1": 10, "pipe@400.0__gsoiladd1": 10}, +} # fmt: skip +SPLIT_INPUTS = {**INPUTS, "bill": {"items": [REVET]}} + + +def test_구조물_열_대가_블록마다_나눔(): + sheets = compute_estimation(SPLIT_INPUTS, USER, DAEGA.get) + rows = [r for r in sheets["bill"]["rows"] if r["key"].startswith("st:")] + got = [(r["key"].split("#")[-1], r["qty"], r["sum"], r["note"]) for r in rows] + assert got == [ + ("대가17", 20, 2491880, "대가17"), # 124,594 × 20 — 비목마다 ROUNDDOWN + ("대가24", 12.5, 2078274, "대가24"), # 129,370 · 15,059 · 21,833 × 12.5 + ("st:pipe:revet|돌쌓기(찰)|2.0", 10, None, ""), # 대가 없는 주인 = 「연결 없음」 + ] + assert rows[0]["labor"] == 1937360 and rows[1]["material"] == 188237 # 15,059 × 12.5 버림 + assert ("bill", "st:pipe:revet|돌쌓기(찰)|2.0") in { + (g["tab"], g["key"]) for g in sheets["gaps"] + } + # 대가 잇기 전(price_of 없음) = 열 하나 그대로 · 단가 빈칸 + plain = compute_estimation(SPLIT_INPUTS, USER) + one = [r for r in plain["bill"]["rows"] if r["key"].startswith("st:")] + assert [(r["qty"], r["sum"]) for r in one] == [(42.5, None)] + + +def test_거울_블록_나누기(): + calc = "".join( + IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8")) + for f in ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"] + ) + body = ( + f"\nconst d = {json.dumps(DAEGA, ensure_ascii=False)};" + f"\nconst i = {json.dumps(SPLIT_INPUTS, ensure_ascii=False)};" + f"\nconst u = {json.dumps(USER, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(computeEstimation(i, u, (o) => d[o] ?? null)));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", calc + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)