feat(b08): 구조물도 하단 일위대가 표 틀 — 양식 줄 조합 × B09 단가표 미리보기

- 양식에 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 19:15:18 +09:00
co-authored by Claude Opus 5
parent a484783d6f
commit 3c47120f2a
6 changed files with 560 additions and 3 deletions
@@ -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,
}
@@ -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:
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
+10 -2
View File
@@ -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}`);
@@ -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];
}
+15 -1
View File
@@ -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" }
]
}
}
@@ -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