auto: 2026-09-14 01:10 (ESD_LAPTOP)

This commit is contained in:
2026-09-14 01:10:55 +09:00
parent 314c00406e
commit 3ab63eb29c
6 changed files with 281 additions and 2 deletions
@@ -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 {
@@ -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
+10 -2
View File
@@ -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
+5
View File
@@ -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": []}
),
}
)
)
@@ -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
@@ -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])