Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-09-13 19:26:44 +09:00
4 changed files with 222 additions and 32 deletions
@@ -77,6 +77,14 @@ def project_templates(project_root: str | Path | None) -> dict[str, dict[str, An
}
def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]:
"""하위 일위대가(`B-AX-ST-*`)를 찾을 양식들 — 프로그램 기본 뒤에 **프로젝트에 박힌 것**(이김).
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
"""
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()]
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
"""고른 항목을 프로젝트 작업본에 박음 — 같은 종류의 옛 것은 지움(종류당 하나).
@@ -3,18 +3,24 @@
⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값.
⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」.
⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2).
하위 구조물 일위대가(`B-AX-ST-*`)는 `_resolve` 한 자리로 들어옴 — 레시피 150/350 이 2단 이상
(명세 16장). B-FP·X·L 의 재귀·순환 막이는 단가표가 이미 함. 깊이 5단은 재귀 일감(PLAN 10장).
하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — B09 `resolve` 도 조립 중엔 안 자름.
⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어
일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장).
깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함.
⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ).
"""
from __future__ import annotations
from collections.abc import Callable
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
_UNIT_ALIASES = {"m2": "", "m3": "", "M2": "", "M3": "", "M": "m"}
@@ -23,6 +29,23 @@ def _unit(text: Any) -> str:
return _UNIT_ALIASES.get(value, value)
@dataclass
class _Context:
book: Any
find_variant: Callable[[str, str], str | None]
library: 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]:
@@ -42,27 +65,83 @@ def _ref_of(
return None, f"{code} 에서 {var}={value} 에 맞는 갈래를 못 찾음"
def _resolve(book: Any, ref: str) -> Any:
"""단가 한 줄 — ⚠ 하위 구조물 일위대가는 재귀 일감에서 붙임(지금은 막힘으로)."""
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
if ref.startswith(SUB_STRUCTURE_PREFIX):
raise PriceBookError(f"하위 구조물 일위대가({ref})는 아직 못 풂 — 재귀 일감")
return book.resolve(ref)
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 unit_price_table(
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],
book: Any,
find_variant: Callable[[str, str], str | None],
) -> dict[str, Any] | None:
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`.
코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래).
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
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")
@@ -72,6 +151,7 @@ def unit_price_table(
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
for row in spec.get("rows") or []:
out: dict[str, Any] = {
@@ -110,26 +190,25 @@ def unit_price_table(
continue
out["quantity"] = float(quantity)
ref, why = _ref_of(row, values, find_variant)
ref, why = _ref_of(row, values, ctx.find_variant)
out["ref_code"] = ref or ""
money = None
priced = None
if ref:
try:
money = _resolve(book, ref)
title = book.title(ref)
if out["unit"] and _unit(title.unit) and _unit(title.unit) != out["unit"]:
why = f"단위가 다름 — 수량 {out['unit']} ↔ 단가 {_unit(title.unit)}"
money = None
else:
out.update(name=title.name or out["name"], spec=title.spec or out["spec"])
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 money is None:
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),
@@ -138,6 +217,9 @@ def unit_price_table(
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),
@@ -146,7 +228,7 @@ def unit_price_table(
)
rows.append(out)
return {
table = {
"code": f"B-{template.get('code')}" if template.get("code") else "",
"name": template.get("name") or "",
"unit": sheet.get("billing_unit") or "",
@@ -157,3 +239,23 @@ def unit_price_table(
"blocked": blocked,
"complete": blocked == 0,
}
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]] = (),
) -> dict[str, Any] | None:
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`.
코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래).
`library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본).
"""
ctx = _Context(book, find_variant, {str(t["code"]): t for t in library if t.get("code")})
code = str(template.get("code") or "")
assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ())
return assembled[0] if assembled else None
@@ -454,7 +454,10 @@ async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResp
⚠ 장 조회와 창구를 나눔 — 단가표 첫 조립이 십여 초라 장 조회를 늦추지 않게.
⚠ 값을 여기서 정본으로 적지 않음 — 보이기만(내역 금액은 B09 가 셈).
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
available_templates,
project_templates,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
@@ -486,6 +489,7 @@ async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResp
picked,
build.book,
lambda code, value: find_variant_code(code, value, build),
available_templates(project_root),
)
return JSONResponse(content={"status": "success", "unit_price": table})
@@ -4,8 +4,9 @@
① 줄 금액 = 원단위 줄 수량 × B09 단가(3분할) · 금액란 0.1원 버림 · 계금 1원 버림
② 못 푼 줄은 0 이 아니라 막힘 + 까닭 · 막힌 줄이 있으면 미완
③ 원단위 줄이 안 선 장(버림 「안 넣음」)은 일위대가 줄도 안 섬 — 막힘이 아님
④ 단위가 다르거나 하위 구조물 일위대가(B-AX-ST)면 막힘
④ 단위가 다르거나 하위 양식을 못 찾으면 막힘
⑤ 창구가 실제 단가표로 찰쌓기 갈래를 찾아 값을 냄
⑥ 재귀 — 하위 구조물 일위대가(B-AX-ST)를 제원으로 풀어 넣음 · 돌면 막힘 · 5단까지
"""
from __future__ import annotations
@@ -131,7 +132,82 @@ def test_갈래를_못_찾거나_단위가_다르거나_하위_구조물이면_
rows = {row["seq"]: row for row in unit_price_table(template, sheet, BOOK, _variant)["rows"]}
assert "갈래를 못 찾음" in rows[1]["reason"]
assert rows[2]["reason"].startswith("단위가 다름")
assert "재귀" in rows[3]["reason"]
assert "못 찾음" in rows[3]["reason"] # 하위 양식이 프로젝트·기본에 없음
def _leaf(code: str, next_code: str | None = None) -> dict:
"""하위 양식 — 면적 = H×L×2(㎥) 한 줄. 다음 코드가 있으면 그것을 1m 부름."""
unit_rows = [{"seq": 1, "name": "잡석", "from_row": 1, "work_item_code": "FP-12-25"}]
if next_code:
unit_rows = [
{
"seq": 1,
"quantity": 1,
"unit": "m",
"ref_code": f"B-{next_code}",
"sub_vars": {"H": 1},
}
]
return {
"code": code,
"name": f"하위 {code[-2:]}",
"vars": {"H": {"source": "height_m"}, "L": {"source": "length_m"}},
"tables": {},
"rows": [
{
"seq": 1,
"name": "면적",
"formula": "H*L*2",
"unit": "",
"destination": "unit_price",
"rounding": {"mode": "none", "digits": 0},
}
],
"unit_price": {"unit": "m", "rows": unit_rows},
}
def _parent(ref: str, sub_vars: dict | None = None) -> dict:
row = {"seq": 1, "name": "하위 부름", "quantity": 3, "unit": "m", "ref_code": ref}
if sub_vars is not None:
row["sub_vars"] = sub_vars
return {"code": "AX-ST-a0000000", "unit_price": {"rows": [row]}}
def test_하위_구조물_일위대가를_제원으로_풀어_윗_표에_넣는다() -> None:
"""PLAN 3장 재귀 — 하위 양식 1m = 면적 3㎥ × 잡석(1000·500) · 윗 표 3m."""
library = [_leaf("AX-ST-c0000001")]
table = unit_price_table(
_parent("B-AX-ST-c0000001", {"H": 1.5}), _sheet(), BOOK, _variant, library
)
row = table["rows"][0]
assert row["name"] == "하위 01" and row["reason"] == ""
assert row["material"] == pytest.approx(9000.0) and row["labor"] == pytest.approx(4500.0)
assert table["complete"] is True and table["total"] == pytest.approx(13500.0)
# 제원을 안 주면 막힘 — 0 으로 풀지 않음.
missing = unit_price_table(_parent("B-AX-ST-c0000001"), _sheet(), BOOK, _variant, library)
assert "제원이 비어 있음: H" in missing["rows"][0]["reason"]
def test_하위_일위대가가_돌면_막힘() -> None:
library = [_leaf("AX-ST-c0000002", next_code="AX-ST-c0000002")]
table = unit_price_table(
_parent("B-AX-ST-c0000002", {"H": 1}), _sheet(), BOOK, _variant, library
)
assert "미완" in table["rows"][0]["reason"] and table["complete"] is False
def test_재귀는_5단까지() -> None:
"""맨 윗 표 1단 + 하위 넷 = 5단은 섬 · 하위 다섯이면 6단이라 막힘."""
codes = [f"AX-ST-d000000{i}" for i in range(1, 6)]
library = [
_leaf(code, codes[i + 1] if i + 1 < len(codes) else None) for i, code in enumerate(codes)
]
five = unit_price_table(_parent(f"B-{codes[1]}", {"H": 1}), _sheet(), BOOK, _variant, library)
assert five["complete"] is True # 윗 표 → d2 → d3 → d4 → d5(잎) = 5단
six = unit_price_table(_parent(f"B-{codes[0]}", {"H": 1}), _sheet(), BOOK, _variant, library)
assert six["complete"] is False # 윗 표 → d1 → … → d5 = 6단
@pytest.fixture()