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:
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user