Files
Aislo/B09_Estimation/B09_Estimation_Calc_Bill.py
T
eomsangdonandClaude Opus 5.5 8b64d794da feat(b09): 설계내역서 손 줄 레벨 · 묶음 줄 금액 상위 합계 · 레벨 메뉴 (182-1)
- 손 줄 kind(group = 묶음 줄) · parent(묶음 손 줄 키) 저장 · 다듬기
- 셈 짝(Calc_Bill ts ↔ py) — 줄마다 level · 묶음 금액 = 자손 항목 합 + 빈 줄 n · 공종 합 · sums = 항목만
- 화면 — is-lvN 글자 크기(16 · 14 · 13px) · 첫 칸 들여쓰기 · 제목 · 묶음 줄 굵게
- 우클릭 — 아래에 하위 줄 추가 · 묶음 줄로 · 항목으로 · 들여쓰기 · 내어쓰기(손 묶음 깊이 2)
- 시험 test_182_b09_bill_level(레벨 · 합계 · 거울 · 저장 왕복 · 메뉴) · test_166_2 메뉴 판정 손봄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-10 11:53:24 +09:00

378 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.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 + 「연결 없음」 목록.
손질(166-2) = 사용자 값 `user.bill` — `qty{줄 키: 수}` 자동 줄 수량 덮어쓰기(키 지우면 자동) ·
`rows[]` 손 줄(`hand:` · 공종 · `after` 앞 줄 키 뒤에 끼움 · 명칭 · 규격 · 단위 · 수량 ·
연결 `ref`) ·
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로
아래) · 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) ·
묶음 금액 = 자손 항목 합 · 공종 합 · `sums` = 항목만(묶음 줄 안 셈 · 원가계산서 그대로).
원가계산서로 넘길 합 = `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, qty_of: dict | None = None
) -> dict[str, Any]:
qty = num(item.get("qty"))
over = {}
if qty_of and item["key"] in qty_of and not item.get("hand"): # 166-2 수량 덮어쓰기
over = {"qty_auto": qty}
qty = num(qty_of[item["key"]])
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"),
**over,
**({"hand": True} if item.get("hand") else {}),
**_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]
WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"
WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"
def ref_price(
ref: dict[str, Any] | None, books: dict[str, Any], hopyo: dict[str, Any], row_key: str = ""
) -> dict[str, Any] | None:
"""연결 → 1 단위 단가 `{no, key, labor, material, expense, sum}` — 없으면 None."""
ref = ref or {}
tab, key = ref.get("tab"), ref.get("key")
if tab == "logic":
tab, key = "sg", (books.get("sg_hand") or {}).get(row_key)
if tab in ("dg", "sg") and key:
listing = books.get("daega" if tab == "dg" else "sg_list") or []
row = next((r for r in listing if r["key"] == key), None)
if row is None:
return None
no = f"대가{row['no']}" if tab == "dg" else f"산근 {row['no']}호표"
return {"no": no, "tab": tab, "key": key, **{p: row.get(p) for p in (*PARTS, "sum")}}
return hopyo.get(key) if key else None
def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]:
"""손 줄 → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
out = []
for hand in (user.get("bill") or {}).get("rows") or []:
price = ref_price(hand.get("ref"), books, hopyo, hand["key"])
ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None
why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND
item = {**hand, "hand": True, "note": (price or {}).get("no") or "", "ref": ref}
out.append((item, price, why))
return out
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
"""묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`)."""
return {
"kind": "group",
"key": item["key"],
"section": item["section"],
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
"qty": None,
"note": "",
"ref": None,
"hand": True,
"sum": 0.0,
"labor": 0.0,
"material": 0.0,
"expense": 0.0,
"blank": 0,
}
def _nest(rows: list[dict[str, Any]]) -> None:
"""182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액."""
groups = {row["key"]: row for row in rows if row["kind"] == "group"}
for row in rows:
if not row.get("hand"):
continue
up = groups.get(row.get("parent") or "")
if up is None or up["section"] != row["section"]:
row["parent"] = None
continue
at, n = up, 0
while at is not None and n <= len(groups):
if at["key"] == row["key"]:
row["parent"] = None
break
at, n = groups.get(at.get("parent") or ""), n + 1
for row in rows:
chain = []
at = groups.get(row.get("parent") or "")
while at is not None:
chain.append(at)
at = groups.get(at.get("parent") or "")
depth = 2 if next(p for c, _n, p in SECTIONS if c == row["section"]) else 1
row["level"] = depth if row["kind"] == "title" else depth + 1 + len(chain)
if row["kind"] != "item":
continue
for g in chain:
for p in ("sum", *PARTS):
g[p] += row[p] or 0
if row["sum"] is None:
g["blank"] += 1
for g in groups.values():
g["note"] = f"빈 줄 {g['blank']}" if g["blank"] else ""
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
if at is None and after == f"sec:{hand['section']}":
at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None)
items.insert(len(items) if at is None else at, hand)
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,
books: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) —
주면 구조물 열을 블록마다 나눔 ·
`books` = 호표 목록(`daega` · `sg_list` · `sg_hand` · 166-2)."""
gaps: list[dict[str, str]] = []
hopyo = inputs.get("hopyo") or {}
books = books or {}
qty_of = (user.get("bill") or {}).get("qty") 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, qty_of))
continue
prices = ref_price(item.get("ref"), books, hopyo)
items.append(_item_row(item, prices, gaps, qty_of))
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, qty_of))
for item, prices, why in hand_items(user, books, hopyo):
if item.get("kind") == "group":
row = _group_row(item)
else:
row = _item_row({**item, "why": why}, prices, gaps)
row["parent"] = item.get("parent")
_place(items, row, item.get("after") or "")
owner = [row for row in items if row["section"] == "G" and row["kind"] == "item"]
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:
if row["kind"] != "item":
continue
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)
_nest(rows)
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,
books: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음."""
base = compute_base(inputs, user)
bill = compute_bill(inputs, user, base, price_of, books)
return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}