From 3ab63eb29c75137cbbe8dec4350c11763246fdae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 01:10:55 +0900 Subject: [PATCH] auto: 2026-09-14 01:10 (ESD_LAPTOP) --- .../B09_Estimation_BillOfQuantities.py | 41 +++++++ .../B09_Estimation_BillOfQuantities_Rows.py | 12 ++ B09_Estimation/B09_Estimation_PriceBasis.py | 12 +- B09_Estimation/B09_Estimation_Router.py | 5 + .../B09_Estimation_UnitPriceSheet.py | 115 ++++++++++++++++++ resources/tester/test_b09_bill_sheet_view.py | 98 +++++++++++++++ 6 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UnitPriceSheet.py create mode 100644 resources/tester/test_b09_bill_sheet_view.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index ff463c61..3681c01b 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -165,6 +165,14 @@ class BillRow: in_bill: bool = True #: 금액을 낸 단가 코드(`B-…#갈래`) — 단산 번호를 그 코드로 찾음. 안 선 줄은 빈 글. price_code: str = "" + #: 성분 단가 — 실무 내역서 「노무비·재료비·경비 단가」 칸(화면이 곱하지 않게 서버가 실음). + unit_material_krw: Decimal | None = None + unit_labor_krw: Decimal | None = None + unit_expense_krw: Decimal | None = None + #: 수동 단가로 선 몫 — 화면 빨간 테두리(구조물도 호표 줄). + unconfirmed: int = 0 + #: 「단산 N 참조」 — 비고 첫 조각과 같은 글. 번호는 낼 때마다 매김(저장 안 함). + price_basis_label: str = "" #: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고, #: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮). #: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다** @@ -210,6 +218,12 @@ class BillRow: "material_krw": str(self.material_krw), "labor_krw": str(self.labor_krw), "expense_krw": str(self.expense_krw), + "unit_material_krw": money(self.unit_material_krw), + "unit_labor_krw": money(self.unit_labor_krw), + "unit_expense_krw": money(self.unit_expense_krw), + "price_code": self.price_code, + "unconfirmed": self.unconfirmed, + "price_basis_label": self.price_basis_label, "is_group": self.is_group, "in_bill": self.in_bill, "note": self.note, @@ -235,6 +249,8 @@ class BillResult: notes: list[str] = field(default_factory=list) #: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다. price_basis: Any = None + #: 일위대가 호표 번호 한 벌 — 내역에 처음 쓰인 차례(저장 안 함). + unit_price_sheet: Any = None #: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」). material_sheet: Any = None #: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`. @@ -600,7 +616,14 @@ def build_bill( if label: # 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다. row.notes.insert(0, ("unit_price_krw", label)) + row.price_basis_label = label result.price_basis = sheet + from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet + + result.unit_price_sheet = build_unit_price_sheet( + result.rows, unit_prices.book, structure_prices + ) + _sum_groups(result.rows) if any(m.surcharge_pct is None for m in materials): result.notes.append( @@ -610,6 +633,24 @@ def build_bill( return result +def _sum_groups(rows: list[BillRow]) -> None: + """머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음. + + ⚠ `direct_*`·`body_total_krw` 는 머리글을 빼고 더하므로 두 번 안 셈. + """ + for group in rows: + if not group.is_group: + continue + prefix = f"{group.item_no}-" + children = [ + r for r in rows if not r.is_group and r.item_no.startswith(prefix) and r.amount_krw + ] + group.material_krw = sum((r.material_krw for r in children), _ZERO) + group.labor_krw = sum((r.labor_krw for r in children), _ZERO) + group.expense_krw = sum((r.expense_krw for r in children), _ZERO) + group.amount_krw = sum((r.amount_krw for r in children), _ZERO) + + def bill_summary(result: BillResult) -> dict[str, Any]: """화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다.""" return { diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 50394419..45c237b3 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -40,6 +40,13 @@ def bill_line(unit: Money3, quantity) -> Money3: ) +def _set_unit(row: BillRow, unit: Money3) -> None: + """성분 단가 칸 — 금액을 낸 바로 그 3분할(내역 서식 「노무비·재료비·경비 단가」).""" + row.unit_material_krw = unit.material + row.unit_labor_krw = unit.labor + row.unit_expense_krw = unit.expense + + def _composite_row( item_no: str, item: HandoffWorkItem, @@ -104,6 +111,7 @@ def _composite_row( money = money.floored(Decimal(1)) line = bill_line(money, item.quantity) + _set_unit(row, money) row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW) row.amount_krw = line.total row.material_krw = line.material @@ -160,6 +168,9 @@ def _structure_price_row( # 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장). line = bill_line(entry["money"], item.quantity) + _set_unit(row, entry["money"]) + row.price_code = ref + row.unconfirmed = int(entry["unconfirmed"] or 0) row.unit_price_krw = entry["total"] row.amount_krw = line.total row.material_krw = line.material @@ -462,6 +473,7 @@ def _leaf_row( unit_money = unit_prices.book.resolve(price_code) line = bill_line(unit_money, item.quantity) + _set_unit(row, unit_money) row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) # 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상. row.amount_krw = line.total diff --git a/B09_Estimation/B09_Estimation_PriceBasis.py b/B09_Estimation/B09_Estimation_PriceBasis.py index e2441423..546ce507 100644 --- a/B09_Estimation/B09_Estimation_PriceBasis.py +++ b/B09_Estimation/B09_Estimation_PriceBasis.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any -from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build @@ -43,6 +43,8 @@ class PriceBasisEntry: ref_code: str #: 이 산출서를 부르는 내역 일위대가 전부(갈래·단계 합산 부모 포함). unit_price_codes: list[str] = field(default_factory=list) + #: 성분 금액 — 목록표 「노무비·재료비·경비」 칸(머리 성분 원 미만 절사 값). + money: Money3 = field(default_factory=Money3) @property def label(self) -> str: @@ -59,6 +61,10 @@ class PriceBasisEntry: "unit": self.unit, "unit_price_krw": str(self.unit_price_krw), "ref_code": self.ref_code, + "unit_price_codes": list(self.unit_price_codes), + "material_krw": str(self.money.material), + "labor_krw": str(self.money.labor), + "expense_krw": str(self.money.expense), } @@ -116,14 +122,16 @@ def build_price_basis( entry = numbered.get(basis) if entry is None: title = book.title(basis) + money = book.resolve(basis) entry = PriceBasisEntry( number=len(sheet.entries) + 1, code=basis, name=title.name, spec=title.spec, unit=title.unit, - unit_price_krw=round_at(book.resolve(basis).total, OutputPlace.UNIT_PRICE_ROW), + unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW), ref_code=code, + money=money, ) sheet.entries.append(entry) numbered[basis] = entry diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 9d98763a..77b58cb8 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -807,6 +807,11 @@ async def get_bill(project_id: UUID) -> JSONResponse: "price_basis": ( result.price_basis.as_dict() if result.price_basis else {"entries": []} ), + "unit_price_sheet": ( + result.unit_price_sheet.as_dict() + if result.unit_price_sheet + else {"entries": []} + ), } ) ) diff --git a/B09_Estimation/B09_Estimation_UnitPriceSheet.py b/B09_Estimation/B09_Estimation_UnitPriceSheet.py new file mode 100644 index 00000000..643fccfc --- /dev/null +++ b/B09_Estimation/B09_Estimation_UnitPriceSheet.py @@ -0,0 +1,115 @@ +"""B09 원가계산 — 일위대가표 **호표 번호** (PLAN 12장 · 실무 `일위대가목록표`). + +실무 내역서는 일위대가를 「제 N 호표」로 부르고, 목록표가 번호마다 명칭·규격·성분 금액을 보인다. + +⚠ **번호는 저장하지 않음**(2026-09-14 브레인 판정) — 내역이 바뀌면 번호도 바뀌므로 낼 때마다 매김. + 단산 번호(`B09_Estimation_PriceBasis`)와 같은 규칙: **내역에 처음 쓰인 차례**. +⚠ 내역 줄이 부르지 않고 **다른 일위대가 안에서만** 불리는 호표(찰쌓기 안의 모르타르 배합 따위)는 + 부르는 호표 **바로 뒤**에 번호가 붙음(줄 차례대로 깊이 우선). +⚠ 구조물도 호표(`B-AX-ST-…#키`)는 단가표 제목이 아니라 B08 일위대가 표로 섬 — 금액은 그 표 값, + 안의 하위 호표 번호는 아직 안 매김(구조물도 탭이 그 표를 보임). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceKind + +STRUCTURE_SOURCE = "structure" + + +@dataclass +class UnitPriceSheetEntry: + number: int + code: str + name: str + spec: str + unit: str + money: Money3 + #: 「book」 단가표 제목 · 「structure」 구조물도 일위대가 표. + source: str = "book" + unconfirmed: int = 0 + + @property + def label(self) -> str: + return f"제 {self.number} 호표" + + def as_dict(self) -> dict[str, Any]: + return { + "number": self.number, + "label": self.label, + "code": self.code, + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "material_krw": str(self.money.material), + "labor_krw": str(self.money.labor), + "expense_krw": str(self.money.expense), + "total_krw": str(self.money.total), + "source": self.source, + "unconfirmed": self.unconfirmed, + } + + +@dataclass +class UnitPriceSheet: + entries: list[UnitPriceSheetEntry] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return {"entries": [entry.as_dict() for entry in self.entries]} + + +def _nested(book: PriceBook, code: str, seen: tuple[str, ...]) -> list[str]: + """호표 안에서 부르는 일위대가 — 줄 차례대로 깊이 우선.""" + found: list[str] = [] + for detail in book.details.get(code, []): + ref = detail.ref_code + title = book.titles.get(ref) + if title is None or title.kind is not PriceKind.UNIT_PRICE or ref in seen: + continue + found.append(ref) + found.extend(_nested(book, ref, (*seen, ref))) + return found + + +def build_unit_price_sheet( + rows: list[Any], + book: PriceBook, + structure_prices: dict[str, dict[str, Any]] | None = None, +) -> UnitPriceSheet: + """내역 줄(`BillRow`) 차례 → 호표 번호 한 벌. 같은 호표를 두 줄이 불러도 한 번만.""" + sheet = UnitPriceSheet() + numbered: set[str] = set() + + def add(code: str, **values: Any) -> None: + numbered.add(code) + sheet.entries.append( + UnitPriceSheetEntry(number=len(sheet.entries) + 1, code=code, **values) + ) + + for row in rows: + code = row.price_code + if not code or code in numbered: + continue + structure = (structure_prices or {}).get(code) + if structure is not None: + add( + code, + name=row.name, + spec=row.spec, + unit=row.unit, + money=structure["money"], + source=STRUCTURE_SOURCE, + unconfirmed=int(structure.get("unconfirmed") or 0), + ) + continue + if code not in book.titles: + continue + for ref in (code, *_nested(book, code, (code,))): + if ref in numbered: + continue + title = book.title(ref) + add(ref, name=title.name, spec=title.spec, unit=title.unit, money=book.resolve(ref)) + return sheet diff --git a/resources/tester/test_b09_bill_sheet_view.py b/resources/tester/test_b09_bill_sheet_view.py new file mode 100644 index 00000000..4dbfbbc6 --- /dev/null +++ b/resources/tester/test_b09_bill_sheet_view.py @@ -0,0 +1,98 @@ +"""B09 내역서 화면 자료 — 화면이 곱하거나 더하지 않게 서버가 싣는 칸 (PLAN 12장 1차). + +겨누는 것 + ① 내역 줄 성분 단가 × 수량 = 성분 금액(성분마다 절사) — 화면은 실린 값만 찍음 + ② 머리글 줄 금액 = 아래 줄 금액의 합(성분마다) · 본체 합계엔 안 들어감 + ③ 일위대가 호표 번호 — 내역에 처음 쓰인 차례 · 같은 호표는 한 번 · 안에서 부르는 호표는 뒤에 + ④ 단산 목록 — 부르는 일위대가 전부 · 성분 금액 +""" + +from __future__ import annotations + +from decimal import Decimal +from functools import lru_cache + +from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line, build_bill +from B09_Estimation.B09_Estimation_PriceBook import ( + Money3, + PriceBook, + PriceDetail, + PriceKind, + PriceTitle, +) +from B09_Estimation.B09_Estimation_UnitPrice import _slots, build_unit_prices +from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet + + +@lru_cache(maxsize=1) +def _bill(): + payload = { + "work_items": [ + {"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 123.4}, + {"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "㎥", "quantity": 7}, + {"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 5}, + ], + "materials": [], + } + return build_bill(payload, build=build_unit_prices()) + + +def test_줄_성분_단가와_금액이_실린다() -> None: + priced = [r for r in _bill().rows if not r.is_group and r.amount_krw is not None] + assert priced + for row in priced: + unit = Money3(row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw) + line = bill_line(unit, row.quantity) + assert (line.material, line.labor, line.expense) == ( + row.material_krw, + row.labor_krw, + row.expense_krw, + ) + assert row.amount_krw == line.total + body = row.as_dict() + assert body["price_code"] == row.price_code and body["unit_labor_krw"] is not None + + +def test_머리글_줄은_아래_줄_합이고_본체엔_안_든다() -> None: + bill = _bill() + detail_total = sum(r.amount_krw or 0 for r in bill.rows if not r.is_group) + assert bill.body_total_krw == detail_total + for group in (r for r in bill.rows if r.is_group): + children = [ + r + for r in bill.rows + if not r.is_group and r.item_no.startswith(f"{group.item_no}-") and r.amount_krw + ] + assert group.amount_krw == sum(r.amount_krw for r in children) + assert group.labor_krw == sum(r.labor_krw for r in children) + + +def test_호표_번호는_처음_쓰인_차례이고_안에서_부르는_호표는_뒤에() -> None: + book = PriceBook() + book.add_title(PriceTitle("L", PriceKind.LABOR, "보통인부", slots=_slots(Decimal(100000)))) + for code in ("B-A", "B-B", "B-C"): + book.add_title(PriceTitle(code, PriceKind.UNIT_PRICE, code)) + book.add_detail(PriceDetail("B-C", "L", Decimal(1))) + book.add_detail(PriceDetail("B-A", "B-C", Decimal(2))) # A 안에서 C 를 부름 + book.add_detail(PriceDetail("B-B", "L", Decimal(1))) + + class Row: + def __init__(self, code: str) -> None: + self.price_code, self.name, self.spec, self.unit = code, code, "", "㎥" + + sheet = build_unit_price_sheet([Row("B-B"), Row("B-A"), Row("B-B"), Row("")], book) + assert [(e.number, e.code) for e in sheet.entries] == [(1, "B-B"), (2, "B-A"), (3, "B-C")] + assert sheet.entries[1].as_dict()["label"] == "제 2 호표" + assert sheet.entries[1].money.labor == 200000 + + +def test_내역에_호표_목록과_단산_목록이_함께_실린다() -> None: + bill = _bill() + codes = [e.code for e in bill.unit_price_sheet.entries] + used = list(dict.fromkeys(r.price_code for r in bill.rows if r.price_code)) + assert [c for c in codes if c in used] == used # 안에서 부르는 호표가 끼어도 차례는 그대로 + for entry in bill.price_basis.entries: + body = entry.as_dict() + assert body["unit_price_codes"] and Money3( + *(int(body[k]) for k in ("material_krw", "labor_krw", "expense_krw")) + ).total == int(body["unit_price_krw"].split(".")[0])