From ff6672a13a4ec1649591725622a1fa1b0ab18c5b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 05:10:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EA=B3=84=EC=95=BD=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=202=EB=B2=8C=20=E2=80=94=20=EA=B8=B0=EC=B4=88?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EC=A0=81=EC=9A=A9(=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=ED=91=9C=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=A1=B0=EB=A6=BD)=20=C2=B7=20=EA=B3=84=EC=95=BD=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80/=EC=82=B0=EC=B6=9C=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20W=20=ED=98=B8=ED=91=9C=20=C2=B7=200%=20=3D=200=20?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기초단가 적용: 자재·노임·중기 취득가 채택 단가에 적용률 — 복사본만, 설계 단가표 불변 - 조립값과 다른 줄(할증·수동 단가)·단가표 밖 코드는 성분 곱셈 + 비고에 까닭 - 계약 호표: 설계 본표와 같은 꼴(detail_of)로 W-B·W-D 따로 — 기초단가 적용과 함께만 - 0% 는 0 원(브레인 판정) · 공내역 생성은 그 줄 이름만 가름 - 시험 11건 · 전체 1699 통과(골든셋 포함) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- B09_Estimation/B09_Estimation_Contract.py | 152 +++++++++++++++--- .../B09_Estimation_Router_Contract.py | 6 +- .../B09_Estimation_UI_Tab_Contract.ts | 67 ++++++++ resources/tester/test_b09_contract.py | 103 +++++++++++- 4 files changed, 303 insertions(+), 25 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Contract.py b/B09_Estimation/B09_Estimation_Contract.py index 5eacf158..01aa2248 100644 --- a/B09_Estimation/B09_Estimation_Contract.py +++ b/B09_Estimation/B09_Estimation_Contract.py @@ -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), diff --git a/B09_Estimation/B09_Estimation_Router_Contract.py b/B09_Estimation/B09_Estimation_Router_Contract.py index a5ab9e1b..6a8ece0d 100644 --- a/B09_Estimation/B09_Estimation_Router_Contract.py +++ b/B09_Estimation/B09_Estimation_Router_Contract.py @@ -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", diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts b/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts index 6c1ee501..ef13d8c1 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_Contract.ts @@ -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 }; options_not_used: Record; settings: Record; @@ -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) { diff --git a/resources/tester/test_b09_contract.py b/resources/tester/test_b09_contract.py index 3be35c0a..6199b6a6 100644 --- a/resources/tester/test_b09_contract.py +++ b/resources/tester/test_b09_contract.py @@ -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"] == []