feat(B09): 인계 계약 시험 + ㉦ 큰돌쌓기 포함자재 가드 + 수량 2자리 표시
인계 계약 시험 (같은 병 재발 방지) - **B08 이 보내는 칸을 내가 실제로 읽는지** 시험으로 마주 걺. 새 칸이 오면 깨짐. 안 읽는 칸은 까닭을 함께 적게 함(측점·spec_detail 등) - 값이 자료형까지 실제로 닿는지도 짝으로 봄 — 이름만 맞고 값이 안 실리면 같은 병 ㉦ 가드 신설 (품셈 13-6 [주]① 「고임돌 및 채움 콘크리트 등은 품에 포함」) - 큰돌쌓기 줄이 서 있는데 **그 구조물이 낳은** 고임돌·채움콘크리트가 자재로도 서면 멈춤 - ⚠ 13-6 한정 — 돌쌓기 13-4 의 고임돌은 정상이라 안 걸림. 짝 시험 2건 - 내 일위대가는 그 둘을 안 세우고 있음을 확인(품셈 표에 없음) 수량 표시 - 내역서 수량을 **소수 2자리**로 보임. 계산은 전정밀 그대로 - 「표시는 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다」를 표 아래 상시 표시 (실무 서식 자릿수는 기준 문서에 없어 2자리는 잠정) 검증: pytest 195 통과(신규 5), tsc 0건, 화면 실측 — 15,726.93 · 92.45 로 뜸 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Guards import (
|
||||
check_excluded_rows_not_priced,
|
||||
check_included_materials_not_listed,
|
||||
check_free_haul_not_priced,
|
||||
check_haul_volume_within_cut,
|
||||
)
|
||||
@@ -373,6 +374,15 @@ def build_bill(
|
||||
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
|
||||
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
|
||||
|
||||
# ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가.
|
||||
check_included_materials_not_listed(
|
||||
work_item_codes=[row.code or "" for row in result.rows],
|
||||
materials=[
|
||||
{"material_name": m.material_name, "source_structure": list(m.source_structure)}
|
||||
for m in materials
|
||||
],
|
||||
)
|
||||
|
||||
# ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡).
|
||||
# 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다.
|
||||
# 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다.
|
||||
|
||||
@@ -245,3 +245,40 @@ def check_drain_pipe_not_double_counted(
|
||||
"실었습니다 — 윗단 값에 파이프의 노무비·재료비가 이미 들어 있습니다 "
|
||||
"(품셈 13-6-2 [주]③)."
|
||||
)
|
||||
|
||||
|
||||
#: 큰돌쌓기(13-6) 품에 **이미 들어 있는** 자재 — 따로 세우면 두 번이다.
|
||||
#: 근거: 품셈 13-6 [주]① 「고임돌 및 채움 콘크리트 등은 품에 포함」.
|
||||
#: ⚠ **13-6 한정**이다 — 돌쌓기(13-4)·돌붙임(13-7)에는 이 [주]가 없으므로
|
||||
#: 그쪽에서 고임돌이 자재로 오는 것은 정상이다. 넓게 잡으면 정상 자재를 지운다.
|
||||
BOULDER_INCLUDED_MATERIALS = ("고임돌", "채움콘크리트", "채움 콘크리트")
|
||||
BOULDER_WORK_ITEM_PREFIX = "FP-13-06"
|
||||
|
||||
|
||||
def check_included_materials_not_listed(
|
||||
*,
|
||||
work_item_codes: list[str],
|
||||
materials: list[dict],
|
||||
name_field: str = "material_name",
|
||||
source_field: str = "source_structure",
|
||||
label: str = "큰돌쌓기",
|
||||
) -> None:
|
||||
"""㉦ 품에 포함된 자재를 따로 세지 않았는가 (품셈 13-6 [주]①).
|
||||
|
||||
큰돌쌓기 줄이 서 있는데 **그 구조물이 낳은** 고임돌·채움콘크리트가 자재로도 서면
|
||||
같은 것을 두 번 센다. 자재의 `source_structure` 로 **그 구조물에서 온 것만** 본다 —
|
||||
다른 구조물(돌쌓기 13-4)의 고임돌은 정상이다.
|
||||
"""
|
||||
if not any(str(code).startswith(BOULDER_WORK_ITEM_PREFIX) for code in work_item_codes):
|
||||
return
|
||||
for material in materials:
|
||||
name = "".join(str(material.get(name_field) or "").split())
|
||||
if name not in {"".join(x.split()) for x in BOULDER_INCLUDED_MATERIALS}:
|
||||
continue
|
||||
sources = material.get(source_field) or []
|
||||
if any(label in str(source) for source in sources):
|
||||
raise DoubleCountError(
|
||||
f"{label}: 「{material.get(name_field)}」이 자재로도 실렸습니다 — "
|
||||
"큰돌쌓기 품에 이미 들어 있습니다 (품셈 13-6 [주]① 「고임돌 및 "
|
||||
"채움 콘크리트 등은 품에 포함」)."
|
||||
)
|
||||
|
||||
@@ -673,6 +673,23 @@ interface BillDto {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다.
|
||||
*
|
||||
* ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이
|
||||
* 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다.
|
||||
* 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다.
|
||||
*/
|
||||
function formatQuantity(value: string | null): string {
|
||||
if (value === null || value === "") return "";
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return value;
|
||||
return parsed.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBill(projectId: string): Promise<BillDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`,
|
||||
@@ -806,7 +823,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
indent + row.name,
|
||||
row.spec,
|
||||
row.unit,
|
||||
row.quantity ?? "",
|
||||
formatQuantity(row.quantity),
|
||||
row.unit_price_krw ?? "",
|
||||
row.amount_krw ?? "",
|
||||
row.note,
|
||||
@@ -827,6 +844,12 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`;
|
||||
body.append(total);
|
||||
|
||||
// 표시 자릿수와 계산 자릿수가 다르다는 것을 숨기지 않는다.
|
||||
const precision = document.createElement("div");
|
||||
precision.className = "b09-hint";
|
||||
precision.textContent = L("B09_Estimation_Boq_Precision");
|
||||
body.append(precision);
|
||||
|
||||
// ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다.
|
||||
const shortfall = document.createElement("div");
|
||||
shortfall.className = "b09-hint";
|
||||
@@ -838,7 +861,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
note.className = "b09-hint";
|
||||
note.textContent =
|
||||
`${L("B09_Estimation_Boq_Excluded")}: ` +
|
||||
bill.excluded.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
|
||||
bill.excluded
|
||||
.map((row) => `${row.name} ${formatQuantity(row.quantity)}${row.unit}`)
|
||||
.join(", ");
|
||||
body.append(note);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user