auto: 2026-09-14 01:10 (ESD_LAPTOP)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user