From 3c47120f2ad21cd8f813ece5d639e993371da338 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 19:15:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=ED=95=98=EB=8B=A8=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=20=ED=8B=80=20=E2=80=94=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=EC=A4=84=20=EC=A1=B0=ED=95=A9=20=C3=97=20B09=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=ED=91=9C=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 양식에 unit_price.rows(수량은 원단위 줄 from_row · 코드는 work_item_code+variant_from 또는 ref_code) - 서버가 B09 단가표(PriceBook.resolve, 읽기만)로 줄마다 재료·노무·경비 · 금액란 0.1원 버림 · 계금 1원 버림 - 못 푼 줄은 막힘 + 까닭(0 아님), 원단위 줄이 안 선 장은 안 섬 · 단위 다름·하위 구조물(B-AX-ST)은 막힘 - 창구 /structure-sheets/unit-price 를 장 조회와 따로 둠(단가표 첫 조립 14초) - 찰쌓기: 돌쌓기 B-FP-13-04-05#갈래 · 기초잡석 B-FP-12-25 · 모르터는 공종 코드 미정 - 시험 4개 추가 · 전체 1539 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructureUnitPrice.py | 159 +++++++++++++++ .../B08_Quantity_Router_StructureSheet.py | 50 +++++ .../B08_Quantity_UI_StructureSheet.ts | 12 +- ...08_Quantity_UI_StructureSheet_UnitPrice.ts | 145 ++++++++++++++ resources/library_structure/masonry_wet.json | 16 +- .../tester/test_b08_structure_unit_price.py | 181 ++++++++++++++++++ 6 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py create mode 100644 B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts create mode 100644 resources/tester/test_b08_structure_unit_price.py diff --git a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py new file mode 100644 index 00000000..40ed9c03 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py @@ -0,0 +1,159 @@ +"""구조물도 하단 **일위대가 표** — 양식 줄 조합 × B09 단가표로 단위당 금액 미리보기(PLAN 3장). + +⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값. +⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」. +⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2). +⚠ 하위 구조물 일위대가(`B-AX-ST-*`)는 `_resolve` 한 자리로 들어옴 — 레시피 150/350 이 2단 이상 + (명세 16장). B-FP·X·L 의 재귀·순환 막이는 단가표가 이미 함. 깊이 5단은 재귀 일감(PLAN 10장). +""" + +from __future__ import annotations + +from collections.abc import Callable +from decimal import Decimal +from typing import Any + +#: 하위 구조물 일위대가 코드 머리 — 명세 2장 ② `B-AX-ST-3f9a2b17`. +SUB_STRUCTURE_PREFIX = "B-AX-ST-" +_UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"} + + +def _unit(text: Any) -> str: + value = str(text or "").strip() + return _UNIT_ALIASES.get(value, value) + + +def _ref_of( + row: dict[str, Any], values: dict[str, Any], find_variant: Callable[[str, str], str | None] +) -> tuple[str | None, str]: + """줄이 가리키는 단가표 코드 — 못 정하면 `None` 과 까닭.""" + if row.get("ref_code"): + return str(row["ref_code"]), "" + code = row.get("work_item_code") + if not code: + return None, "공종 코드 미정" + var = row.get("variant_from") + if not var: + return f"B-{code}", "" + value = values.get(var) + found = find_variant(str(code), str(value if value is not None else "")) + if found: + return found, "" + return None, f"{code} 에서 {var}={value} 에 맞는 갈래를 못 찾음" + + +def _resolve(book: Any, ref: str) -> Any: + """단가 한 줄 — ⚠ 하위 구조물 일위대가는 재귀 일감에서 붙임(지금은 막힘으로).""" + from B09_Estimation.B09_Estimation_PriceBook import PriceBookError + + if ref.startswith(SUB_STRUCTURE_PREFIX): + raise PriceBookError(f"하위 구조물 일위대가({ref})는 아직 못 풂 — 재귀 일감") + return book.resolve(ref) + + +def unit_price_table( + template: dict[str, Any], + sheet: dict[str, Any], + book: Any, + find_variant: Callable[[str, str], str | None], +) -> dict[str, Any] | None: + """장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`. + + 수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`. + 코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래). + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceBookError + from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + + spec = template.get("unit_price") + if not spec: + return None + sheet_rows = {row.get("no"): row for row in sheet.get("rows") or []} + values = (sheet.get("formula_sheet") or {}).get("vars") or {} + rows: list[dict[str, Any]] = [] + sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)} + blocked = 0 + for row in spec.get("rows") or []: + out: dict[str, Any] = { + "seq": row.get("seq"), + "name": row.get("name") or "", + "spec": row.get("spec") or "", + "ref_code": "", + "unit": _unit(row.get("unit")), + "quantity": None, + "skipped": False, + "reason": "", + } + source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None + if source is not None and (source.get("skipped") or source.get("error")): + # 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등). + out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "") + if source.get("error"): + out.update(skipped=False, reason=f"원단위 줄이 안 풀림 — {source['error']}") + blocked += 1 + rows.append(out) + continue + if "from_row" in row: + if source is None or source.get("unit_amount") is None: + out["reason"] = f"원단위 줄 {row['from_row']} 이 없음" + blocked += 1 + rows.append(out) + continue + quantity = Decimal(str(source["unit_amount"])) + out["unit"] = out["unit"] or _unit(source.get("unit")) + elif row.get("quantity") is not None: + quantity = Decimal(str(row["quantity"])) + else: + out["reason"] = "수량 없음" + blocked += 1 + rows.append(out) + continue + out["quantity"] = float(quantity) + + ref, why = _ref_of(row, values, find_variant) + out["ref_code"] = ref or "" + money = None + if ref: + try: + money = _resolve(book, ref) + title = book.title(ref) + if out["unit"] and _unit(title.unit) and _unit(title.unit) != out["unit"]: + why = f"단위가 다름 — 수량 {out['unit']} ↔ 단가 {_unit(title.unit)}" + money = None + else: + out.update(name=title.name or out["name"], spec=title.spec or out["spec"]) + except PriceBookError as exc: + why = str(exc) + if money is None: + out["reason"] = why + blocked += 1 + rows.append(out) + continue + + cells = { + "material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW), + "labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW), + "expense": round_at(money.expense * quantity, OutputPlace.UNIT_PRICE_ROW), + } + for key, value in cells.items(): + sums[key] += value + out.update( + unit_material=float(money.material), + unit_labor=float(money.labor), + unit_expense=float(money.expense), + **{key: float(value) for key, value in cells.items()}, + total=float(sum(cells.values())), + ) + rows.append(out) + + return { + "code": f"B-{template.get('code')}" if template.get("code") else "", + "name": template.get("name") or "", + "unit": sheet.get("billing_unit") or "", + "rows": rows, + **{key: float(value) for key, value in sums.items()}, + # 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음. + "total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)), + "blocked": blocked, + "complete": blocked == 0, + } diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index d70ea1c8..2c41a660 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -440,6 +440,56 @@ async def delete_structure_library_personal( return JSONResponse(content={"status": "success", "deleted": deleted}) +async def _price_build(project_id: UUID) -> Any: + """그 프로젝트가 고른 값으로 조립한 B09 단가표 — B09 창구와 **같은 한 벌**(읽기만).""" + from B09_Estimation.B09_Estimation_Router import _build_for + + return await _build_for(project_id) + + +@router.get("/{project_id}/quantity/structure-sheets/unit-price") +async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResponse: + """장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식, 단가는 B09 단가표. + + ⚠ 장 조회와 창구를 나눔 — 단가표 첫 조립이 십여 초라 장 조회를 늦추지 않게. + ⚠ 값을 여기서 정본으로 적지 않음 — 보이기만(내역 금액은 B09 가 셈). + """ + from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates + from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of + from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table + from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code + + project_root = await _project_root(project_id) + if project_root is None: + return _not_found() + sheets = (await _sheets_of(project_id, project_root)).get("sheets") or [] + picked = next((s for s in sheets if s.get("key") == sheet_key), None) + if picked is None: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."}, + ) + template = template_of(str(picked.get("type_id") or ""), project_templates(project_root)) + if not template or not template.get("unit_price"): + return JSONResponse(content={"status": "success", "unit_price": None}) + try: + build = await _price_build(project_id) + except Exception: + logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "단가표를 조립하지 못했습니다."}, + ) + table = await asyncio.to_thread( + unit_price_table, + template, + picked, + build.book, + lambda code, value: find_variant_code(code, value, build), + ) + return JSONResponse(content={"status": "success", "unit_price": table}) + + @router.get("/{project_id}/quantity/structure-sheets") async def get_structure_sheets(project_id: UUID) -> JSONResponse: """구조물도(표준도) **장 목록 + 원단위 수량표**. diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts index a7222fb3..596933b0 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -32,6 +32,7 @@ import { type StandardSpecResult, } from "./B08_Quantity_UI_StructureSheet_Spec"; import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library"; +import { unitPriceSection } from "./B08_Quantity_UI_StructureSheet_UnitPrice"; export interface StructureSheetRow { no: number; @@ -223,7 +224,11 @@ function editedRows(sheet: StructureSheet): number { } /** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */ -function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement { +function sheetBody( + sheet: StructureSheet, + editor: HTMLElement | null, + below: HTMLElement | null = null, +): HTMLElement { const main = el("div", "b08-sheet__main"); const head = el("p", "b08-sheet__head"); const total = sheet.billing_total @@ -280,6 +285,8 @@ function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLEleme ), ); } + // 하단 일위대가(미리보기) — 수량표 바로 밑(PLAN 3장 한 장의 짜임). + if (below) main.append(below); for (const notice of [ warn("단위당을 못 낸 줄", sheet.unpriced_rows), warn( @@ -423,7 +430,8 @@ export function renderStructureSheets(projectId: string | null): HTMLElement { }, ) : null; - pane.replaceChildren(sheetBody(sheet, editor), aside); + const unitPrice = sheet.library_item ? unitPriceSection(projectId, sheet.key) : null; + pane.replaceChildren(sheetBody(sheet, editor, unitPrice), aside); }; sheets.forEach((sheet, index) => { const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`); diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts new file mode 100644 index 00000000..9cf11d0e --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts @@ -0,0 +1,145 @@ +/* ============================================================================= + * B08_Quantity_UI_StructureSheet_UnitPrice.ts + * 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②. + * + * ⚠ 값을 셈하지 않음 — 서버(`…/structure-sheets/unit-price`)가 B09 단가표로 낸 금액을 적기만. + * ⚠ 장 조회와 따로 받음 — 단가표 첫 조립이 십여 초라 표가 늦게 차도 위 수량표는 먼저 보임. + * ⚠ 막힌 줄은 0 이 아니라 까닭을 적고, 하나라도 있으면 합계 앞에 「미완」. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula"; + +interface UnitPriceRow { + seq: number; + name: string; + spec: string; + ref_code: string; + unit: string; + quantity: number | null; + skipped: boolean; + reason: string; + material?: number; + labor?: number; + expense?: number; + total?: number; +} + +interface UnitPriceTable { + code: string; + name: string; + unit: string; + rows: UnitPriceRow[]; + material: number; + labor: number; + expense: number; + total: number; + blocked: number; + complete: boolean; +} + +/** 금액 칸 — 0.1원 자리까지(금액란 규칙). 못 푼 줄은 빈칸. */ +function won(value: number | undefined): string { + return value === undefined ? "" : num(value, 1); +} + +/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. */ +export function unitPriceSection(projectId: string, sheetKey: string): HTMLElement { + const wrap = el("div", "b08-grid"); + wrap.append( + el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"), + ); + void (async () => { + try { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/unit-price?sheet_key=${encodeURIComponent(sheetKey)}`, + { credentials: "include" }, + ); + const payload = (await response.json().catch(() => ({}))) as { + unit_price?: UnitPriceTable | null; + message?: string; + }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + const table = payload.unit_price; + if (!table) { + wrap.replaceChildren(el("p", "b08-grid__caption", "이 양식에는 일위대가 줄이 아직 없음")); + return; + } + wrap.replaceChildren(...render(table)); + } catch (error) { + wrap.replaceChildren( + el( + "p", + "b08-grid__caption b08-grid__caption--warn", + `일위대가를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`, + ), + ); + } + })(); + return wrap; +} + +function render(table: UnitPriceTable): HTMLElement[] { + const total = table.complete + ? `${num(table.total, 0)}원` + : `미완 — 막힌 줄 ${table.blocked} · 선 줄만 ${num(table.total, 0)}원`; + const head = el( + "p", + "b08-sheet__head", + `일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈)`, + ); + const scroller = el("div", "b08-grid__scroll"); + const grid = el("table", "b08-grid__table b08-grid__table--summary"); + const headRow = document.createElement("tr"); + for (const label of [ + "공종·자원", + "코드", + "수량", + "단위", + "재료비", + "노무비", + "경비", + "합계", + "비고", + ]) { + headRow.append(el("th", "", label)); + } + const thead = document.createElement("thead"); + thead.append(headRow); + const tbody = document.createElement("tbody"); + for (const row of table.rows) { + const tr = document.createElement("tr"); + const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : ""; + // 단가표 이름에 갈래가 이미 들었거나 규격 칸이 코드 자체면 겹쳐 적지 않음. + const spec = + row.spec && !row.name.includes(row.spec) && !row.ref_code.includes(row.spec) ? row.spec : ""; + tr.append( + el("td", "", spec ? `${row.name} (${spec})` : row.name), + el("td", "", row.ref_code), + el("td", "", row.quantity === null ? "" : num(row.quantity, 3)), + el("td", "", row.unit), + el("td", "", won(row.material)), + el("td", "", won(row.labor)), + el("td", "", won(row.expense)), + el("td", "", won(row.total)), + el("td", "", note), + ); + tbody.append(tr); + } + const foot = document.createElement("tr"); + foot.append( + el("td", "", "계"), + el("td", "", ""), + el("td", "", ""), + el("td", "", ""), + el("td", "", won(table.material)), + el("td", "", won(table.labor)), + el("td", "", won(table.expense)), + el("td", "", table.complete ? num(table.total, 0) : `미완 ${num(table.total, 0)}`), + el("td", "", ""), + ); + tbody.append(foot); + grid.append(thead, tbody); + scroller.append(grid); + return [head, scroller]; +} diff --git a/resources/library_structure/masonry_wet.json b/resources/library_structure/masonry_wet.json index c4ac776a..75e53841 100644 --- a/resources/library_structure/masonry_wet.json +++ b/resources/library_structure/masonry_wet.json @@ -261,5 +261,19 @@ "rounding": { "mode": "none", "digits": 0 }, "source": "library" } - ] + ], + "unit_price": { + "note": "하단 일위대가 틀(PLAN 3장 하단 ①) — 갈 곳 unit_price 줄마다 한 줄. 수량은 원단위 줄(from_row), 단가는 B09 단가표. 공종 코드는 인계 대응표(찰쌓기 FP-13-04-05 · 갈래 뒷길이)와 10장 판정(기초잡석 FP-12-25)을 따름. 모르터는 코드 미정이라 막힘으로 둠", + "rows": [ + { + "seq": 1, + "name": "돌쌓기", + "from_row": 1, + "work_item_code": "FP-13-04-05", + "variant_from": "L3" + }, + { "seq": 2, "name": "모르터", "from_row": 8 }, + { "seq": 3, "name": "기초잡석", "from_row": 15, "work_item_code": "FP-12-25" } + ] + } } diff --git a/resources/tester/test_b08_structure_unit_price.py b/resources/tester/test_b08_structure_unit_price.py new file mode 100644 index 00000000..8eef1707 --- /dev/null +++ b/resources/tester/test_b08_structure_unit_price.py @@ -0,0 +1,181 @@ +"""구조물도 하단 일위대가 표 틀 (2026-09-13, PLAN 3장 하단 ①). + +겨누는 것 + ① 줄 금액 = 원단위 줄 수량 × B09 단가(3분할) · 금액란 0.1원 버림 · 계금 1원 버림 + ② 못 푼 줄은 0 이 아니라 막힘 + 까닭 · 막힌 줄이 있으면 미완 + ③ 원단위 줄이 안 선 장(버림 「안 넣음」)은 일위대가 줄도 안 섬 — 막힘이 아님 + ④ 단위가 다르거나 하위 구조물 일위대가(B-AX-ST)면 막힘 + ⑤ 창구가 실제 단가표로 찰쌓기 갈래를 찾아 값을 냄 +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 +import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 +from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table # noqa: E402 +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError # noqa: E402 + +PROJECT_ID = "44444444-4444-4444-4444-444444444444" +SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets" + + +class FakeBook: + def __init__(self, titles: dict[str, tuple[str, str, Money3]]): + self.titles = titles + + def title(self, code: str) -> SimpleNamespace: + if code not in self.titles: + raise PriceBookError(f"단가표에 없는 코드입니다: {code}") + name, unit, _money = self.titles[code] + return SimpleNamespace(name=name, spec="", unit=unit) + + def resolve(self, code: str) -> Money3: + return self.titles[code][2] if code in self.titles else self.title(code) + + +BOOK = FakeBook( + { + "B-FP-13-04-05#55cm이하": ( + "찰쌓기(장비)", + "㎡", + Money3(Decimal("100.05"), Decimal("200.07"), Decimal("30")), + ), + "B-FP-12-25": ("기초잡석", "㎥", Money3(Decimal("1000"), Decimal("500"), Decimal("0"))), + "B-FP-99-01": ("다른 단위", "m", Money3(Decimal("1"))), + } +) +TEMPLATE = { + "code": "AX-ST-56e81a2c", + "name": "돌쌓기(찰)", + "unit_price": { + "rows": [ + { + "seq": 1, + "name": "돌쌓기", + "from_row": 1, + "work_item_code": "FP-13-04-05", + "variant_from": "L3", + }, + {"seq": 2, "name": "모르터", "from_row": 8}, + {"seq": 3, "name": "기초잡석", "from_row": 15, "work_item_code": "FP-12-25"}, + ] + }, +} + + +def _variant(code: str, value: str) -> str | None: + return "B-FP-13-04-05#55cm이하" if (code, value) == ("FP-13-04-05", "45") else None + + +def _sheet(**rows: dict) -> dict: + base = { + 1: {"no": 1, "unit": "㎡", "unit_amount": 2.61}, + 8: {"no": 8, "unit": "㎥", "unit_amount": 0.02349}, + 15: {"no": 15, "unit": "㎥", "unit_amount": 0.4}, + } + for key, value in rows.items(): + base[int(key[1:])] = {**base[int(key[1:])], **value} + return {"rows": list(base.values()), "formula_sheet": {"vars": {"L3": 45}}, "billing_unit": "m"} + + +def test_줄_금액은_수량_곱하기_단가이고_못_푼_줄은_막힘() -> None: + table = unit_price_table(TEMPLATE, _sheet(), BOOK, _variant) + rows = {row["seq"]: row for row in table["rows"]} + stone = rows[1] + assert stone["ref_code"] == "B-FP-13-04-05#55cm이하" and stone["name"] == "찰쌓기(장비)" + # 2.61 × 100.05 = 261.1305 → 0.1원 미만 버림 261.1 + assert stone["material"] == pytest.approx(261.1) + assert stone["labor"] == pytest.approx(522.1) # 522.1827 + assert stone["expense"] == pytest.approx(78.3) # 78.3 + assert rows[2]["reason"] == "공종 코드 미정" and "total" not in rows[2] + assert rows[3]["total"] == pytest.approx(600.0) # 0.4 × 1500 + assert table["blocked"] == 1 and table["complete"] is False + # 계금 — 줄 금액 합(261.1+522.1+78.3+600) 1원 미만 버림 + assert table["total"] == pytest.approx(1461.0) + assert table["code"] == "B-AX-ST-56e81a2c" + + +def test_원단위_줄이_안_선_장은_일위대가_줄도_안_섬() -> None: + sheet = _sheet(r15={"skipped": True, "unit_amount": None, "reason": "조건이 거짓: BLIND"}) + rows = {row["seq"]: row for row in unit_price_table(TEMPLATE, sheet, BOOK, _variant)["rows"]} + assert rows[3]["skipped"] is True and rows[3]["reason"].startswith("조건이 거짓") + + +def test_갈래를_못_찾거나_단위가_다르거나_하위_구조물이면_막힘() -> None: + sheet = _sheet() + sheet["formula_sheet"]["vars"]["L3"] = 60 + template = { + "code": "AX-ST-00000000", + "unit_price": { + "rows": [ + {"seq": 1, "from_row": 1, "work_item_code": "FP-13-04-05", "variant_from": "L3"}, + {"seq": 2, "from_row": 1, "ref_code": "B-FP-99-01"}, + {"seq": 3, "quantity": 1, "ref_code": "B-AX-ST-12345678"}, + ] + }, + } + rows = {row["seq"]: row for row in unit_price_table(template, sheet, BOOK, _variant)["rows"]} + assert "갈래를 못 찾음" in rows[1]["reason"] + assert rows[2]["reason"].startswith("단위가 다름") + assert "재귀" in rows[3]["reason"] + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + root = tmp_path / "project" + root.mkdir() + wall = StructureInstance.model_validate( + { + "type_id": "masonry_wet", + "placement": "interval", + "start_m": 0.0, + "end_m": 10.0, + "options": {"height_m": 2.5, "back_len_cm": 45}, + } + ) + save_structures(str(root), [wall], base_revision=0) + + async def fake_root(project_id): + return str(root) + + async def no_route(project_id): + return {} + + async def real_build(project_id): + from B09_Estimation.B09_Estimation_UnitPrice import cached_build + + return cached_build() + + monkeypatch.setattr(router_module, "_project_root", fake_root) + monkeypatch.setattr(router_module, "_price_build", real_build) + monkeypatch.setattr(material_module, "_section_modes", no_route) + monkeypatch.setattr(material_module, "_ground_types", no_route) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def test_창구가_실제_단가표로_찰쌓기_갈래를_찾는다(client: TestClient) -> None: + sheet = client.get(SHEETS).json()["sheets"][0] + response = client.get(f"{SHEETS}/unit-price", params={"sheet_key": sheet["key"]}) + assert response.status_code == 200, response.text + table = response.json()["unit_price"] + rows = {row["seq"]: row for row in table["rows"]} + assert rows[1]["ref_code"] == "B-FP-13-04-05#55cm이하" + assert rows[1]["labor"] > 0 and rows[1]["quantity"] == pytest.approx(2.5 * 1.09**0.5) + assert rows[3]["ref_code"] == "B-FP-12-25" + assert rows[2]["reason"] == "공종 코드 미정" and table["complete"] is False