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:
@@ -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:
|
||||
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user