feat(b09): 계약 단계 2벌 — 기초단가 적용(단가표 복사본 다시 조립) · 계약 일위대가/산출근거 W 호표 · 0% = 0 원

- 기초단가 적용: 자재·노임·중기 취득가 채택 단가에 적용률 — 복사본만, 설계 단가표 불변
- 조립값과 다른 줄(할증·수동 단가)·단가표 밖 코드는 성분 곱셈 + 비고에 까닭
- 계약 호표: 설계 본표와 같은 꼴(detail_of)로 W-B·W-D 따로 — 기초단가 적용과 함께만
- 0% 는 0 원(브레인 판정) · 공내역 생성은 그 줄 이름만 가름
- 시험 11건 · 전체 1699 통과(골든셋 포함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 05:10:41 +09:00
co-authored by Claude Opus 5
parent 52771faefe
commit ff6672a13a
4 changed files with 303 additions and 25 deletions
+132 -20
View File
@@ -15,12 +15,14 @@
from __future__ import annotations
import copy
from dataclasses import replace
from decimal import Decimal, InvalidOperation
from typing import Any
# ⚠ `_Rows` 를 먼저 부르면 순환 import — 설계 내역 본체가 다시 내보내는 자리에서 받음.
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
from B09_Estimation.B09_Estimation_PriceBook import Money3
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceBookError, PriceKind
_ZERO = Decimal(0)
_HUNDRED = Decimal(100)
@@ -46,15 +48,12 @@ OPTIONS: tuple[tuple[str, str], ...] = (
)
#: 계산에 아직 안 쓰는 옵션과 그 까닭 — 조용히 무시하지 않고 화면에 적음.
OPTION_NOT_USED = {
"apply_to_base_prices": (
"기초단가(자재·노임·중기) 수준 적용은 다음 차례 — 지금은 일위대가 성분에 적용"
),
"generate_unit_prices": "계약 일위대가·산출근거 표 생성은 다음 차례",
"labor_ratio": (
"「노무비 비율 별도」의 계산 뜻이 분석 자료에서 확인 안 됨(27번 §2.2) — 칸만 받음"
),
"tax_free_material": "비과세자재대 줄이 아직 원가계산서에 없음 — 칸만 받음",
}
_SHEETS_NEED_BASE = "「기초단가에 적용」을 함께 켜야 계약 일위대가·산출근거가 섬"
def _pct(value: Any) -> Decimal | None:
@@ -89,11 +88,73 @@ def _money(value: Any) -> Decimal:
return Decimal(str(value)) if value not in (None, "") else _ZERO
def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) -> dict[str, Any]:
"""설계 내역 줄(`BillRow.as_dict`) → 계약내역 줄 · 합계 · 까닭.
#: 기초단가 층 — 적용률이 걸리는 카탈로그 종류와 성분(자재 = 재 · 노임 = 노 · 중기 취득가 = 경).
_BASE_KIND_RATE = {
PriceKind.MATERIAL: "material_pct",
PriceKind.LABOR: "labor_pct",
PriceKind.MACHINE_BASE: "expense_pct",
}
⚠ 0% 처리(판정 대기): 「공내역 생성」을 켜면 그 성분이 0 원(세 성분 모두 0 이면 공내역 줄).
끄면 0% 를 「적용 안 함」으로 봄 — STmate 가 따로 옵션을 둔 까닭으로 읽은 것(표본 없음).
def contract_book(book: PriceBook, factors: dict[str, Decimal]) -> PriceBook:
"""「기초단가(재,노,경,일식)에 단가적용율 적용」 — **복사본**의 채택 단가에 적용률을 곱함.
그 위 층(중기사용료 X · 단가산출 D · 일위대가 B)은 **다시 조립**해 선다 — 설계와 같은 절사
(`PriceBook.resolve`). ⚠ 원본 단가표(캐시 공유)는 안 건드림. 일식(W)은 단가 0 이라 그대로.
"""
copied = copy.deepcopy(book)
for title in copied.titles.values():
key = _BASE_KIND_RATE.get(title.kind)
if key is None:
continue
slot = title.adopted_slot - 1
if 0 <= slot < len(title.slots) and title.slots[slot] is not None:
title.slots[slot] = title.slots[slot] * factors[key]
return copied
#: 계약 호표로 내는 층 — 일위대가(B)와 그 아래 단가산출근거(D).
_SHEET_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS)
def _add_sheets(
contract_build: Any, base_code: str, code: str, sheets: dict[str, dict[str, Any]]
) -> None:
"""「적용율 적용된 일위대가/산출근거」 — 설계 본표와 **같은 꼴**(`detail_of`)로 W 코드 한 장씩.
그 일위대가가 부르는 산출근거(D)도 따라 냄(W-D-…, 줄끼리 한 장 공유).
"""
from B09_Estimation.B09_Estimation_UnitPrice_View import detail_of
book = contract_build.book
stack = [(base_code, code)]
while stack:
design_code, sheet_code = stack.pop()
if sheet_code in sheets:
continue
sheets[sheet_code] = {
**detail_of(contract_build, design_code),
"code": sheet_code,
"design_code": design_code,
}
for detail in book.details.get(design_code, []):
ref = book.titles.get(detail.ref_code)
if ref is not None and ref.kind in _SHEET_KINDS and detail.ref_code != design_code:
stack.append((detail.ref_code, f"W-{detail.ref_code}"))
def contract_bill(
bill_rows: list[dict[str, Any]],
settings: dict[str, Any],
build: Any = None,
) -> dict[str, Any]:
"""설계 내역 줄(`BillRow.as_dict`) → 계약내역 줄 · 합계 · 까닭 · 계약 호표.
`build` = 설계 일위대가 조립본(`UnitPriceBuild` — 캐시 공유본이라 **안 고침**). 「기초단가에
적용」이 켜진 때만 씀 — 단가표 복사본(`contract_book`)을 다시 조립.
⚠ 0% = 0 원(2026-09-14 브레인 판정) — 「적용 안 함」은 적용제외 체크가 맡음. 「공내역 생성」은
그 0 원 줄에 W 코드 「공내역」 이름을 붙이는 것뿐(끄면 그냥 0 원 줄). 표본 없음 — 확인 대기.
"""
rates = {key: _pct(settings.get(key, "100")) or _ZERO for key, _ in RATE_FIELDS}
zero_empty = bool(settings.get("zero_makes_empty"))
@@ -101,10 +162,17 @@ def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) ->
excluded = {str(item) for item in settings.get("excluded") or []}
def factor(key: str) -> Decimal:
pct = rates[key]
if pct == 0 and not zero_empty:
return Decimal(1) # 0% = 적용 안 함(공내역 옵션 꺼짐)
return pct / _HUNDRED
return rates[key] / _HUNDRED
book = build.book if build is not None else None
scaled_book = (
contract_book(book, {key: factor(key) for key, _ in RATE_FIELDS})
if book is not None and settings.get("apply_to_base_prices")
else None
)
# 조립본의 곁가지(못 붙은 줄 등)는 그대로 읽고 단가표만 계약 복사본으로 — 얕은 복사.
contract_build = replace(build, book=scaled_book) if scaled_book is not None else None
sheets: dict[str, dict[str, Any]] = {}
rows: list[dict[str, Any]] = []
design = Money3()
@@ -131,14 +199,18 @@ def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) ->
if str(row.get("item_no")) in excluded:
unit, code, note = unit_design, base_code, "적용 제외 — 설계 단가 그대로"
else:
unit = Money3(
material=unit_design.material * factor("material_pct"),
labor=unit_design.labor * factor("labor_pct"),
expense=unit_design.expense * factor("expense_pct"),
).floored(Decimal(1))
unit, note = _contract_unit(row, unit_design, factor, book, scaled_book)
suffix = f"@{row.get('item_no')}" if separate else ""
code = f"W-{base_code}{suffix}"
note = "공내역 — 적용률 0%" if unit.total == 0 and zero_empty else ""
if unit.total == 0 and zero_empty:
note = "공내역 — 적용률 0%"
if (
contract_build is not None
and settings.get("generate_unit_prices")
and note.startswith("기초단가 적용")
and scaled_book.titles[base_code].kind in _SHEET_KINDS
):
_add_sheets(contract_build, base_code, code, sheets)
line = bill_line(unit, qty)
contract += line
row.update(
@@ -172,10 +244,50 @@ def contract_bill(bill_rows: list[dict[str, Any]], settings: dict[str, Any]) ->
for part in ("material", "labor", "expense")
},
},
"options_not_used": {k: v for k, v in OPTION_NOT_USED.items() if settings.get(k)},
"options_not_used": {
**{k: v for k, v in OPTION_NOT_USED.items() if settings.get(k)},
**(
{"generate_unit_prices": _SHEETS_NEED_BASE}
if settings.get("generate_unit_prices") and not settings.get("apply_to_base_prices")
else {}
),
},
# 「적용율 적용된 일위대가/산출근거 생성」 — 기초단가 적용이 켜진 때만
# (성분 곱셈에는 다시 조립할 호표가 없음).
"unit_price_sheets": list(sheets.values()),
}
def _contract_unit(
row: dict[str, Any],
unit_design: Money3,
factor,
book: PriceBook | None,
scaled_book: PriceBook | None,
) -> tuple[Money3, str]:
"""계약 성분 단가와 비고. 기초단가 적용이면 단가표를 다시 조립, 아니면 성분에 곱함.
⚠ 설계 단가가 단가표 조립값과 다르면(할증·수동 단가·구조물도 호표) 기초단가로 못 풂 —
성분 곱셈으로 떨어지고 그 사실을 비고에 적음(조용히 틀린 값을 세우지 않음).
"""
scaled = Money3(
material=unit_design.material * factor("material_pct"),
labor=unit_design.labor * factor("labor_pct"),
expense=unit_design.expense * factor("expense_pct"),
).floored(Decimal(1))
if scaled_book is None or book is None:
return scaled, ""
code = str(row.get("price_code") or "")
if code not in book.titles:
return scaled, "기초단가로 못 풂(단가표 밖 코드) — 성분에 적용"
try:
if book.resolve(code).floored(Decimal(1)) != unit_design:
return scaled, "설계 단가가 단가표 조립값과 다름(할증·수동 단가) — 성분에 적용"
return scaled_book.resolve(code).floored(Decimal(1)), "기초단가 적용 — 단가표 다시 조립"
except PriceBookError as error:
return scaled, f"기초단가로 못 풂({error}) — 성분에 적용"
def _totals(money: Money3) -> dict[str, str]:
return {
"material_krw": str(money.material),
@@ -35,7 +35,7 @@ async def _root(project_id: UUID) -> str | None:
@router.get("/{project_id}/estimation/contract")
async def get_contract(project_id: UUID) -> JSONResponse:
"""계약내역 한 장 — 설계 줄 옆에 계약단가·계약금액 · 설계↔계약 합계 · 옵션."""
from B09_Estimation.B09_Estimation_Router import get_bill
from B09_Estimation.B09_Estimation_Router import _build_for, get_bill
from common_util.common_util_project_settings import estimation_settings
root = await _root(project_id)
@@ -49,7 +49,9 @@ async def get_contract(project_id: UUID) -> JSONResponse:
if response.status_code != 200 or "rows" not in bill:
return JSONResponse(status_code=response.status_code or 502, content=bill)
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
result = contract_bill(bill["rows"], stored)
# 조립본은 캐시 공유본 — `contract_bill` 이 단가표 복사본에만 적용률을 얹음(설계 불변).
build = await _build_for(project_id) if stored.get("apply_to_base_prices") else None
result = contract_bill(bill["rows"], stored, build=build)
return JSONResponse(
content={
"status": "success",
@@ -40,10 +40,34 @@ interface Money {
total_krw: string;
}
/** 계약 호표 한 장 — 설계 본표(`detail_of`)와 같은 꼴 + 계약 코드(W-). */
interface ContractSheet {
code: string;
design_code: string;
name: string;
spec: string;
unit: string;
material: string;
labor: string;
expense: string;
total: string;
rows: {
name: string;
spec: string;
unit?: string;
quantity: string;
material: string;
labor: string;
expense: string;
total: string;
}[];
}
interface ContractDto {
status: string;
message?: string;
rows: ContractRow[];
unit_price_sheets: ContractSheet[];
totals: { design: Money; contract: Money; ratio_pct: Record<string, string | null> };
options_not_used: Record<string, string>;
settings: Record<string, string | boolean | string[]>;
@@ -75,6 +99,8 @@ function injectStyles(): void {
.b09ct__rates input { width: 4.5em; text-align: right; }
.b09ct__option { display: flex; gap: 4px; align-items: flex-start; }
.b09ct__totals { font-size: 12px; font-variant-numeric: tabular-nums; }
.b09ct__sheets { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; font-size: 12px; }
.b09ct__sheets summary { cursor: pointer; }
`;
document.head.append(style);
}
@@ -261,10 +287,51 @@ function drawBody(ctx: B09TabContext, data: ContractDto): void {
table.append(tr);
}
scroll.append(table);
if (data.unit_price_sheets.length) scroll.append(drawSheets(data.unit_price_sheets));
wrap.append(scroll);
ctx.body.append(wrap);
}
/** 「적용율 적용된 일위대가/산출근거」 — 설계 호표는 그대로, 계약 호표(W-)만 따로 펼침. */
function drawSheets(sheets: ContractSheet[]): HTMLElement {
const box = el("div", "b09ct__sheets");
box.append(el("strong", "", `계약 일위대가·산출근거 ${sheets.length}장 — 설계 호표는 그대로`));
for (const sheet of sheets) {
const details = el("details");
details.append(
el(
"summary",
"",
`${sheet.code} ${sheet.name} ${sheet.spec} (설계 ${sheet.design_code}) — ` +
`${won(sheet.material)} · 노 ${won(sheet.labor)} · 경 ${won(sheet.expense)} · 계 ${won(sheet.total)}`,
),
);
const table = el("table", "b09ct__table");
const head = el("tr");
for (const label of ["명칭", "규격", "단위", "수량", "재료비", "노무비", "경비", "합계"]) {
head.append(el("th", "", label));
}
table.append(head);
for (const row of sheet.rows) {
const tr = el("tr");
tr.append(
el("td", "", row.name),
el("td", "", row.spec ?? ""),
el("td", "", row.unit ?? ""),
el("td", "num", row.quantity),
el("td", "num", won(row.material)),
el("td", "num", won(row.labor)),
el("td", "num", won(row.expense)),
el("td", "num", won(row.total)),
);
table.append(tr);
}
details.append(table);
box.append(details);
}
return box;
}
function render(ctx: B09TabContext): void {
injectStyles();
if (!ctx.projectId) {
+100 -3
View File
@@ -4,9 +4,11 @@
① 적용률 100% 면 설계와 같음 · 설계 줄(입력)은 안 바뀜
② 노·재·경 따로 곱해짐 · 절사는 설계 내역 규칙(성분 단가 원 미만 · 줄 성분마다)
③ 적용 제외 줄은 설계 단가 · W 코드 안 붙음
④ 0% + 「공내역 생성」 = 0 원 공내역 줄 · 옵션 끄면 0% = 적용 안 함
④ 0% = 0 원(브레인 판정) · 「공내역 생성」 켜면 그 줄에 공내역 이름
⑤ 동일코드 개별생성 켜면 줄마다 다른 W 코드 · 끄면 같은 코드 한 벌
⑥ 묶음 줄 합 · 틀린 적용률 거름
⑦ 2벌 — 기초단가 적용은 단가표 **복사본**만(설계 단가표 불변) · 못 푸는 줄은 까닭 ·
계약 호표(W-B·W-D)는 기초단가 적용과 함께만 섬
"""
from __future__ import annotations
@@ -20,6 +22,13 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Contract import clean_settings, contract_bill # noqa: E402
from B09_Estimation.B09_Estimation_PriceBook import ( # noqa: E402
PriceBook,
PriceDetail,
PriceKind,
PriceTitle,
)
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild # noqa: E402
ROWS = [
{"item_no": "1", "is_group": True, "in_bill": True},
@@ -88,8 +97,8 @@ def test_0퍼센트_공내역_옵션() -> None:
zero = {"labor_pct": "0", "material_pct": "0", "expense_pct": "0"}
empty = _rows({**zero, "zero_makes_empty": True})["1.1"]
assert empty["contract_amount_krw"] == "0" and "공내역" in empty["contract_note"]
kept = _rows(zero)["1.1"]
assert kept["contract_unit_price_krw"] == "6009" # 옵션 끄면 0% = 적용 안 함
plain = _rows(zero)["1.1"]
assert plain["contract_amount_krw"] == "0" and plain["contract_note"] == "" # 그냥 0 원 줄
def test_동일코드_개별생성() -> None:
@@ -112,3 +121,91 @@ def test_묶음_줄_합과_합계_비율() -> None:
def test_틀린_적용률은_거른다() -> None:
cleaned, errors = clean_settings({"labor_pct": "-3", "material_pct": "abc", "expense_pct": ""})
assert len(errors) == 2 and cleaned["expense_pct"] == "100"
# ── 2벌 — 「기초단가에 적용」 · 「적용율 적용된 일위대가/산출근거 생성」 ──────────────
def _build():
"""자재 1,001 · 노임 2,003 · 산출근거 D-T(노임 0.5) · 일위대가 B-T(자재 2 + D-T 1)."""
book = PriceBook()
for code, kind, price in (
("M-A", PriceKind.MATERIAL, "1001"),
("L-B", PriceKind.LABOR, "2003"),
):
slots = [None] * 6
slots[5] = Decimal(price)
book.add_title(PriceTitle(code=code, kind=kind, name=code, slots=slots))
book.add_title(PriceTitle(code="D-T", kind=PriceKind.PRICE_BASIS, name="산출근거"))
book.add_title(PriceTitle(code="B-T", kind=PriceKind.UNIT_PRICE, name="일위대가"))
book.add_detail(PriceDetail("D-T", "L-B", Decimal("0.5")))
book.add_detail(PriceDetail("B-T", "M-A", Decimal(2)))
book.add_detail(PriceDetail("B-T", "D-T", Decimal(1)))
return UnitPriceBuild(book=book)
def _line(item_no: str, code: str, material: str, labor: str) -> dict:
return {
"item_no": item_no,
"is_group": False,
"in_bill": True,
"quantity": "3",
"price_code": code,
"unit_material_krw": material,
"unit_labor_krw": labor,
"unit_expense_krw": "0",
}
BASE_ROWS = [
_line("2.1", "B-T", "2002", "1001"), # 설계 단가 = 단가표 조립값
_line("2.2", "B-T", "2500", "1001"), # 수동·할증으로 조립값과 다름
_line("2.3", "Z-없음", "100", "0"), # 단가표 밖
]
BASE_ON = {
"material_pct": "90",
"labor_pct": "80",
"apply_to_base_prices": True,
"generate_unit_prices": True,
}
def test_기초단가_적용은_복사본만_고치고_다시_조립() -> None:
build = _build()
before = (build.book.resolve("B-T"), copy.deepcopy(build.book.titles["M-A"].slots))
result = contract_bill(BASE_ROWS, BASE_ON, build=build)
assert (build.book.resolve("B-T"), build.book.titles["M-A"].slots) == before # 설계 단가표 불변
rows = {row["item_no"]: row for row in result["rows"]}
# 자재 900.9 × 2 = 1801.8 → 1801 · 노임 1602.4 × 0.5 = 801.2 → D 801 → B 801
# (성분에 곱했으면 노무 1001 × 0.8 = 800.8 → 800 — 층을 다시 조립해야 801)
assert (rows["2.1"]["contract_unit_material_krw"], rows["2.1"]["contract_unit_labor_krw"]) == (
"1801",
"801",
)
assert rows["2.1"]["contract_note"].startswith("기초단가 적용")
def test_기초단가로_못_푸는_줄은_성분에_곱하고_까닭을_적는다() -> None:
rows = {r["item_no"]: r for r in contract_bill(BASE_ROWS, BASE_ON, build=_build())["rows"]}
assert (
rows["2.2"]["contract_unit_material_krw"] == "2250"
and "조립값과 다름" in (rows["2.2"]["contract_note"])
)
assert "단가표 밖" in rows["2.3"]["contract_note"]
def test_계약_호표는_W코드로_따로_서고_산출근거도_따라_선다() -> None:
sheets = contract_bill(BASE_ROWS, BASE_ON, build=_build())["unit_price_sheets"]
by_code = {sheet["code"]: sheet for sheet in sheets}
assert set(by_code) == {"W-B-T", "W-D-T"}
assert (by_code["W-B-T"]["design_code"], by_code["W-B-T"]["total"]) == ("B-T", "2602")
assert by_code["W-D-T"]["labor"] == "801"
def test_호표_생성은_기초단가_적용이_함께_켜져야_선다() -> None:
only_sheets = {**BASE_ON, "apply_to_base_prices": False}
result = contract_bill(BASE_ROWS, only_sheets, build=_build())
assert result["unit_price_sheets"] == []
assert "generate_unit_prices" in result["options_not_used"]
off = contract_bill(BASE_ROWS, {**BASE_ON, "generate_unit_prices": False}, build=_build())
assert off["unit_price_sheets"] == []