- UI_Tabs_Bill — 항목 수량 덮어쓰기(빈칸 = 자동 · 풍선 자동 값) · 손 줄 명칭 · 규격 · 단위 · 수량 - UI_Shell — 내역서 표 우클릭 메뉴 붙임 · 고친 뒤 보던 자리 지킴 - Calc_Bill 짝 — 손 줄 자리(after) 놓침 고침 - 손 줄 띠 표시(CSS) · 새 시험 test_166_2(셈 · 거울 · 다듬기 · 새 산근 · 집계표 · 메뉴) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
311 lines
14 KiB
Python
311 lines
14 KiB
Python
"""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 + 「연결 없음」 목록.
|
||
손질(166-2) = 사용자 값 `user.bill` — `qty{줄 키: 수}` 자동 줄 수량 덮어쓰기(키 지우면 자동) ·
|
||
`rows[]` 손 줄(`hand:` · 공종 · `after` 앞 줄 키 뒤에 끼움 · 명칭 · 규격 · 단위 · 수량 ·
|
||
연결 `ref`) ·
|
||
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
|
||
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
|
||
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
|
||
원가계산서로 넘길 합 = `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 _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):
|
||
_place(items, _item_row({**item, "why": why}, prices, gaps), item.get("after") or "")
|
||
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],
|
||
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"]}
|