feat(B09): 설계내역서 구조물 줄 = 대가 블록마다 나눔 — B08 열 owners 잇기(145-3)

- 항목 짜기 = 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 15:24:46 +09:00
co-authored by Claude Opus 5.5
parent e359bef714
commit 03fdde07fb
4 changed files with 141 additions and 7 deletions
@@ -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,
}
)
+42 -4
View File
@@ -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"]}
+42 -3
View File
@@ -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<string, Row>();
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] };
}
+55
View File
@@ -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)