① 구조물 터파기·되메우기는 토공집계로만 — 토공·사토 공제·참고로 가는 성분을 구조물 줄·묶음 조각·타설·기초잡석이 또 집으면 오류. ② 시멘트·모래·자갈이 자재총괄에 뜨면 오류. ③ 원단위표가 할증을 붙였거나, 자재 합계가 순수량 × (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
378 lines
16 KiB
Python
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
|