Files
Aislo/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py
T
eomsangdonandClaude Opus 5 938eef94bd feat(axis): 이중계상 경계를 갈 곳 칸으로 판정 — 어기면 내역서가 멈춤
① 구조물 터파기·되메우기는 토공집계로만 — 토공·사토 공제·참고로 가는 성분을 구조물 줄·묶음 조각·타설·기초잡석이 또 집으면 오류.
② 시멘트·모래·자갈이 자재총괄에 뜨면 오류. ③ 원단위표가 할증을 붙였거나, 자재 합계가 순수량 × (1+율) 과 다르거나,
할증 포함 재료량을 준 공종에 자재가 일위대가 재료비로 붙으면 오류.
판정은 B08 인계(double_count_violations), 멈춤은 B09 build_bill 한 자리. 배정 프로젝트 실측 어긴 자리 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:18:00 +09:00

378 lines
16 KiB
Python

"""구조물도 하단 **일위대가 표** — 양식 줄 조합 × B09 단가표로 단위당 금액 미리보기(PLAN 3장).
⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값.
⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」.
⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2).
하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — B09 `resolve` 도 조립 중엔 안 자름.
⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어
일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장).
깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함.
⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ).
⚠ **저장 자리는 둘**(브레인 판정 2026-09-13) — 줄 더하기·빼기·고르개로 고친
**줄 조합은 양식+프로젝트**(`ROWS_KEY`, 종류별 · [내 라이브러리에 저장] 때 양식에 실림) ·
**수동 단가는 프로젝트만**(`MANUAL_KEY` · 양식에 실으면 내 단가가 남의 프로젝트로 감).
수동 단가 줄은 「미확정」으로 셈.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
#: 하위 구조물 일위대가 코드 머리 — 명세 2장 ② `B-AX-ST-3f9a2b17`.
SUB_STRUCTURE_PREFIX = "B-AX-ST-"
#: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」).
MAX_DEPTH = 5
#: 산출 조건 자리 — 고친 줄 조합 `{type_id: [rows]}` · 수동 단가 `{type_id: {seq: 값}}`.
ROWS_KEY = "structure_unit_price_rows"
MANUAL_KEY = "structure_manual_prices"
#: 고르개 갈래 — 품셈(일위대가·단가산출) · 자원(자재·노임·시간당 중기).
SEARCH_KINDS = {
"work": ("unit_price", "price_basis"),
"resource": ("material", "labor", "machine_hourly"),
}
_MONEY_KEYS = ("material", "labor", "expense")
_UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"}
def _unit(text: Any) -> str:
value = str(text or "").strip()
return _UNIT_ALIASES.get(value, value)
@dataclass
class _Context:
book: Any
find_variant: Callable[[str, str], str | None]
library: dict[str, dict[str, Any]]
#: 맨 윗 표의 수동 단가 `{seq: 값}` — 하위 양식에는 안 씀(그 종류의 줄이 아님).
manual: dict[str, dict[str, Any]]
@dataclass
class _Priced:
"""줄 단가 한 벌 — 안 자른 3분할과 이름·단위."""
money: Any
name: str
spec: str
unit: str
def _ref_of(
row: dict[str, Any], values: dict[str, Any], find_variant: Callable[[str, str], str | None]
) -> tuple[str | None, str]:
"""줄이 가리키는 단가표 코드 — 못 정하면 `None` 과 까닭."""
if row.get("ref_code"):
return str(row["ref_code"]), ""
code = row.get("work_item_code")
if not code:
return None, "공종 코드 미정"
var = row.get("variant_from")
if not var:
return f"B-{code}", ""
value = values.get(var)
found = find_variant(str(code), str(value if value is not None else ""))
if found:
return found, ""
return None, f"{code} 에서 {var}={value} 에 맞는 갈래를 못 찾음"
def _sub_sheet(sub: dict[str, Any], sub_vars: dict[str, Any]) -> dict[str, Any]:
"""하위 양식을 **줄이 준 제원**으로 단위당(L=1) 풂 — 윗 장과 같은 모양의 장 한 벌."""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_sheet
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
values: dict[str, Any] = {}
missing = []
for name, spec in (sub.get("vars") or {}).items():
if name in sub_vars:
values[name] = sub_vars[name]
elif "default" in spec:
values[name] = spec["default"]
elif spec.get("source") == "length_m":
values[name] = 1.0
else:
missing.append(name)
if missing:
raise PriceBookError(
f"하위 양식 {sub.get('code')} 의 제원이 비어 있음: {', '.join(missing)}"
)
body = template_sheet(sub, values)
solved = evaluate_sheets([body])
if solved is None:
raise PriceBookError("식 풀이기를 못 돌려 하위 양식을 못 풂")
units = {row["seq"]: row.get("unit") for row in body["rows"]}
rows = [
{
"no": result["seq"],
"unit": units.get(result["seq"]),
"unit_amount": None if result.get("amount") is None else float(result["amount"]),
"skipped": bool(result.get("skipped")),
"reason": result.get("reason") or "",
"error": result.get("error") or "",
}
for result in solved[0]
]
unit = (sub.get("unit_price") or {}).get("unit") or "m"
return {"rows": rows, "formula_sheet": {"vars": values}, "billing_unit": unit}
def _price(
ref: str, row: dict[str, Any], ctx: _Context, depth: int, seen: tuple[str, ...]
) -> _Priced:
"""줄 하나의 단가 — B-AX-ST 는 하위 양식을 재귀로, 나머지는 B09 단가표."""
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
if not ref.startswith(SUB_STRUCTURE_PREFIX):
title = ctx.book.title(ref)
return _Priced(ctx.book.resolve(ref), title.name, title.spec, _unit(title.unit))
code = ref[2:]
if code in seen:
raise PriceBookError(f"하위 일위대가가 돌고 있음: {' → '.join((*seen, code))}")
if depth + 1 > MAX_DEPTH:
raise PriceBookError(f"하위 일위대가가 {MAX_DEPTH}단을 넘음: {' → '.join((*seen, code))}")
sub = ctx.library.get(code)
if sub is None:
raise PriceBookError(f"하위 양식 {code} 을 프로젝트·프로그램 기본에서 못 찾음")
sheet = _sub_sheet(sub, row.get("sub_vars") or {})
assembled = _assemble(sub, sheet, ctx, depth + 1, (*seen, code))
if assembled is None:
raise PriceBookError(f"하위 양식 {code} 에 일위대가 줄이 없음")
table, money = assembled
if table["blocked"]:
raise PriceBookError(f"하위 일위대가 {code} 미완 — 막힌 줄 {table['blocked']}")
return _Priced(money, str(sub.get("name") or code), "", _unit(sheet["billing_unit"]))
def _assemble(
template: dict[str, Any],
sheet: dict[str, Any],
ctx: _Context,
depth: int,
seen: tuple[str, ...],
) -> tuple[dict[str, Any], Any] | None:
"""표 한 벌과 **안 자른** 단위당 3분할 합(윗 표가 쓸 값)."""
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
spec = template.get("unit_price")
if not spec:
return None
sheet_rows = {row.get("no"): row for row in sheet.get("rows") or []}
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
rows: list[dict[str, Any]] = []
sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)}
exact = Money3()
blocked = 0
unconfirmed = 0
for row in spec.get("rows") or []:
out: dict[str, Any] = {
"seq": row.get("seq"),
"name": row.get("name") or "",
"spec": row.get("spec") or "",
"ref_code": "",
"unit": _unit(row.get("unit")),
"quantity": None,
"skipped": False,
"reason": "",
}
source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None
# 이중계상 경계 ①(명세 6장) — 토공집계·사토 공제·참고로 가는 줄을 일위대가에 또 넣지 않음.
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import NOT_BILLED_BY_WORK_ITEM
owner = NOT_BILLED_BY_WORK_ITEM.get(str((source or {}).get("destination") or ""))
if owner:
out["reason"] = f"이중계상 — 원단위 줄 {row['from_row']}{owner}로 가는 줄"
blocked += 1
rows.append(out)
continue
if source is not None and (source.get("skipped") or source.get("error")):
# 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등).
out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "")
if source.get("error"):
out.update(skipped=False, reason=f"원단위 줄이 안 풀림 — {source['error']}")
blocked += 1
rows.append(out)
continue
if "from_row" in row:
if source is None or source.get("unit_amount") is None:
out["reason"] = f"원단위 줄 {row['from_row']} 이 없음"
blocked += 1
rows.append(out)
continue
quantity = Decimal(str(source["unit_amount"]))
out["unit"] = out["unit"] or _unit(source.get("unit"))
elif row.get("quantity") is not None:
quantity = Decimal(str(row["quantity"]))
else:
out["reason"] = "수량 없음"
blocked += 1
rows.append(out)
continue
out["quantity"] = float(quantity)
ref, why = _ref_of(row, values, ctx.find_variant)
out["ref_code"] = ref or ""
priced = None
manual = ctx.manual.get(str(row.get("seq"))) if depth == 1 else None
if manual:
# 수동 단가가 단가표보다 이김 — 사람이 일부러 넣은 값. 대신 「미확정」으로 셈.
money = Money3(*(Decimal(str(manual.get(key) or 0)) for key in _MONEY_KEYS))
priced = _Priced(money, out["name"], out["spec"], out["unit"])
out.update(
manual=True,
manual_source=manual.get("source") or "",
manual_entered_at=manual.get("entered_at") or "",
)
unconfirmed += 1
elif ref:
try:
priced = _price(ref, row, ctx, depth, seen)
if out["unit"] and priced.unit and priced.unit != out["unit"]:
why = f"단위가 다름 — 수량 {out['unit']} ↔ 단가 {priced.unit}"
priced = None
except PriceBookError as exc:
why = str(exc)
if priced is None:
out["reason"] = why
blocked += 1
rows.append(out)
continue
money = priced.money
exact = exact + money.scaled(quantity)
cells = {
"material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW),
"labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW),
"expense": round_at(money.expense * quantity, OutputPlace.UNIT_PRICE_ROW),
}
for key, value in cells.items():
sums[key] += value
out.update(
name=priced.name or out["name"],
spec=priced.spec or out["spec"],
depth=depth,
unit_material=float(money.material),
unit_labor=float(money.labor),
unit_expense=float(money.expense),
**{key: float(value) for key, value in cells.items()},
total=float(sum(cells.values())),
)
rows.append(out)
table = {
"code": f"B-{template.get('code')}" if template.get("code") else "",
"name": template.get("name") or "",
"unit": sheet.get("billing_unit") or "",
"rows": rows,
**{key: float(value) for key, value in sums.items()},
# 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
"total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)),
"blocked": blocked,
"complete": blocked == 0,
#: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지.
"unconfirmed": unconfirmed,
}
return table, exact
def unit_price_table(
template: dict[str, Any],
sheet: dict[str, Any],
book: Any,
find_variant: Callable[[str, str], str | None],
library: Iterable[dict[str, Any]] = (),
manual: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any] | None:
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`.
코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래).
`library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본).
`manual` — 이 프로젝트의 수동 단가 `{seq: {material, labor, expense, source, entered_at}}`.
"""
ctx = _Context(
book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {}
)
code = str(template.get("code") or "")
assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ())
return assembled[0] if assembled else None
def with_rows(template: dict[str, Any], rows: list[dict[str, Any]] | None) -> dict[str, Any]:
"""고친 줄 조합을 얹은 양식 — 고친 적 없으면 양식 줄. 줄 없는 양식도 빈 표로 세움."""
spec = template.get("unit_price") or {}
picked = (spec.get("rows") or []) if rows is None else rows
return {**template, "unit_price": {**spec, "rows": picked}}
def save_rows(
current: dict[str, Any], type_id: str, template: dict[str, Any], rows: list[dict[str, Any]]
) -> tuple[dict[str, Any], bool]:
"""줄 조합 저장본 — 양식과 같으면 그 종류를 지움(양식대로). (새 저장본, 바뀜)."""
merged = {key: value for key, value in current.items() if key != type_id}
if rows != ((template.get("unit_price") or {}).get("rows") or []):
merged[type_id] = rows
return merged, merged != current
def save_manual(
current: dict[str, Any],
type_id: str,
rows: list[dict[str, Any]],
prices: dict[str, dict[str, Any]],
today: str,
) -> dict[str, Any]:
"""수동 단가 저장본 — **남은 줄의 것만** 둠(뺀 줄의 값이 같은 차례 새 줄에 붙지 않게).
넣은 날짜는 값·출처가 그대로면 옛 날짜를 둠 — 다시 저장했다고 날짜가 새로 서면 안 됨.
"""
seqs = {str(row.get("seq")) for row in rows}
before = current.get(type_id) or {}
kept = {}
for seq, price in prices.items():
if str(seq) not in seqs:
continue
old = before.get(str(seq)) or {}
same = all(old.get(key) == price.get(key) for key in (*_MONEY_KEYS, "source"))
kept[str(seq)] = {**price, "entered_at": old.get("entered_at") if same else today}
merged = {key: value for key, value in current.items() if key != type_id}
if kept:
merged[type_id] = kept
return merged
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
"""고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께."""
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
kinds = SEARCH_KINDS[kind]
words = query.lower().split()
found: list[dict[str, Any]] = []
for title in book.titles.values() if words else ():
if len(found) >= limit:
break
if title.kind.value not in kinds:
continue
text = f"{title.code} {title.name} {title.spec}".lower()
if all(word in text for word in words):
try:
money = book.resolve(title.code)
total: float | None = float(money.material + money.labor + money.expense)
except PriceBookError:
total = None
found.append(
{
"code": title.code,
"name": title.name,
"spec": title.spec,
"unit": _unit(title.unit),
"price": total,
}
)
return found