From 89effeca071b070b8ac46be833d38b222a033971 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 10 Sep 2026 00:06:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=82=B0=EC=B6=9C=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=20=E2=80=94=20=EC=A4=84=EC=97=90=20=EB=8B=AC=EB=A6=B0?= =?UTF-8?q?=20=EA=B7=BC=EA=B1=B0=EB=A5=BC=20=ED=95=9C=20=EC=9E=A5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=91=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별표2 (5)(가) 열셋째. 근거는 줄마다 이미 있었고 묶는 자리만 없었음. 원가계산에 「산출기초」 탭으로 세움. 넷으로 접음 — ① 어느 판으로 계산했나(데이터 기준일·지문) ② 무엇을 골랐나(고른 값만) ③ 공종마다 무엇을 근거로 했나(줄 문구 그대로) ④ 못 채운 자리(0 으로 안 때운 자리). - 판 목록은 장부(_manifest.json)를 그대로 읽음 — 목록을 따로 적으면 한쪽만 고쳐짐. - ⚠ 여기서 값을 다시 계산하지 않음 — 금액 칸이 아예 없음(두 벌 방지, 시험으로 못 박음). ⇒ 「설계서 구성」의 산출기초가 반쪽 → 있음. 법이 정한 13 중 9 가 서고, 우리가 더 낼 것은 공사설명서 하나(서식·설계하중 표기는 사용자에게 받아야 함). 곁들여: 자재총괄의 「미분류」를 「미정(발주기관 결정)」으로 고침 — 발주기관이 정할 자리인데 우리가 못 만든 것처럼 읽히던 문구(V-14). 값·판정은 그대로. Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_MaterialSummary.py | 9 +- B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 7 +- B09_Estimation/B09_Estimation_BasisSheet.py | 164 ++++++++++++++++++ .../B09_Estimation_DesignDocIndex.py | 9 +- B09_Estimation/B09_Estimation_Router.py | 22 +++ B09_Estimation/B09_Estimation_UI_BaseData.ts | 85 +++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 27 +++ resources/tester/test_b08_material_summary.py | 6 +- resources/tester/test_b09_basis_sheet.py | 87 ++++++++++ resources/tester/test_b09_design_doc_index.py | 17 +- ui_template/ui_template_locale_b2.ts | 1 + 11 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_BasisSheet.py create mode 100644 resources/tester/test_b09_basis_sheet.py diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index 7f9a5810..f7bf60ee 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -64,7 +64,14 @@ ACCEPTED_DESTINATION = "material" SUPPLY_OWNER = "owner_supplied" # 관급 — 발주처 지급 SUPPLY_CONTRACTOR = "contractor_supplied" # 사급 — 도급자 구입 SUPPLY_UNKNOWN = "unknown" # 아직 안 정함 — 화면에 드러낸다 -SUPPLY_LABELS = {SUPPLY_OWNER: "관급", SUPPLY_CONTRACTOR: "사급", SUPPLY_UNKNOWN: "미분류"} +#: ⚠ 「미분류」로만 적으면 **우리가 아직 못 만든 것처럼** 읽힌다 — 실제로는 **발주기관이 +#: 정할 자리**라 우리가 지어내면 안 되는 값이다(2026-09-09 화면 확인에서 나옴). +#: 그래서 표기에 **누가 정하는지**를 함께 적는다. 값(`unknown`)과 판정은 그대로다. +SUPPLY_LABELS = { + SUPPLY_OWNER: "관급", + SUPPLY_CONTRACTOR: "사급", + SUPPLY_UNKNOWN: "미정(발주기관 결정)", +} #: ⚠ 관급 안의 **설치 주체** — 안전관리비 대상액은 관급 전액이 아니라 「도급자설치 관급금액」이다 #: (PLAN 8-10 대상액 정의). 관급/사급 두 갈래로만 두면 B09 가 ⑤를 못 세운다. diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 25b51e82..65ceec08 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -4,7 +4,7 @@ * * 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다. * - * ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은 + * ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급/사급 미정·설치주체 미지정은 * 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고, * 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다. * @@ -174,7 +174,8 @@ function warning(title: string, items: string[]): HTMLElement | null { /** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */ const SUPPLY_OPTIONS = [ - { value: "unknown", label: "미분류" }, + // ⚠ 「미분류」로만 적으면 미완성으로 읽힌다 — **발주기관이 정할 자리**임을 표기에 담는다. + { value: "unknown", label: "미정(발주기관 결정)" }, { value: "contractor_supplied", label: "사급" }, { value: "owner_supplied", label: "관급" }, ]; @@ -259,7 +260,7 @@ export function renderMaterialGrid( for (const notice of [ warning("⚠ 중복 할증 위험", table.double_count_warnings), warning("할증률 미확보", table.missing_rate_materials), - warning("관급구분 미분류", table.missing_supply_materials), + warning("관급/사급 미정 — 발주기관이 정하는 자리입니다", table.missing_supply_materials), warning("설치 주체 미지정(관급)", table.missing_install_by_materials), ]) { if (notice) wrap.append(notice); diff --git a/B09_Estimation/B09_Estimation_BasisSheet.py b/B09_Estimation/B09_Estimation_BasisSheet.py new file mode 100644 index 00000000..d12d1a96 --- /dev/null +++ b/B09_Estimation/B09_Estimation_BasisSheet.py @@ -0,0 +1,164 @@ +"""B09 — **산출기초** 한 장 (별표2 (5)(가) 열셋째). + +**무엇인가** — 새로 세는 장이 아니다. **이미 줄마다 달려 있는 근거를 한 장으로 접는** 자리다. +법이 설계서 맨 뒤에 「산출기초」를 두라 했는데, 우리는 근거를 **줄에만** 달고 있어 +설계서로 묶을 때 그 문구들을 사람이 손으로 모아야 했다. + +**넷으로 접는다** + + ① 어느 판으로 계산했나 — 품셈·요율·노임·유가·기계·자재 데이터의 판과 기준일 + ② 무엇을 골랐나 — 프로젝트가 고른 값(범위 계수·장비·유가 지역·수송·품 할증 …) + ③ 공종마다 무엇을 근거로 했나 — 일위대가 줄에 달린 근거 문구 그대로 + ④ 못 채운 자리 — 값이 안 선 사유(0 으로 때우지 않은 자리) + +⚠ **여기서 값을 다시 계산하지 않는다.** 계산은 일위대가·내역서가 하고 이 장은 **모으기만** + 한다 — 두 벌로 세면 설계서와 내역서가 조용히 갈린다. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import PriceKind + +_MANIFEST = ("resources", "data_cost_input_value", "_manifest.json") +_MASTER_DIR = ("resources", "data_work_item_master") + +SHEET_NOTE = ( + "산출기초는 새로 세는 장이 아니라 **줄에 달린 근거를 한 장으로 모은 것**입니다 —" + " 값은 일위대가·내역서가 낸 그대로입니다." +).replace("**", "") + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _read_json(*parts: str) -> Any: + with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle: + return json.load(handle) + + +def dataset_versions() -> list[dict[str, str]]: + """① 어느 판으로 계산했나 — 값층 데이터의 판과 기준일. + + ⚠ **장부(`_manifest.json`)를 그대로 읽는다** — 여기서 목록을 따로 적으면 판이 바뀔 때 + 한쪽만 고쳐진다. + """ + rows: list[dict[str, str]] = [] + manifest = _read_json(*_MANIFEST) + for entry in manifest.get("files") or []: + rows.append( + { + "dataset_id": str(entry.get("dataset_id") or ""), + "file": str(entry.get("file") or ""), + "effective_date": str(entry.get("effective_date") or ""), + "sha256": str(entry.get("sha256") or "")[:12], + } + ) + # 공종 마스터는 다른 폴더의 장부라 함께 싣는다 — 품셈 원문을 정규화한 판이다. + master_dir = os.path.join(_project_root(), *_MASTER_DIR) + if os.path.isdir(master_dir): + for name in sorted(os.listdir(master_dir)): + if name.startswith("work_item_master_") and name.endswith(".json"): + rows.append( + { + "dataset_id": "work_item_master", + "file": name, + "effective_date": name[len("work_item_master_") : -len(".json")], + "sha256": "", + } + ) + return rows + + +def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]: + """② 무엇을 골랐나 — **고른 것만** 싣는다(안 고른 칸은 기본값이라 줄이 안 선다).""" + picked = settings or {} + rows: list[dict[str, str]] = [] + + for key, label in ( + ("misc_material_percent", "공구손료·잡재료 (주재료비의 %)"), + ("fuel_region", "유가 지역(시도코드)"), + ("transport_distance_km", "기계 수송 거리(편도 ㎞)"), + ("transport_road", "수송 도로 구분"), + ): + value = str(picked.get(key) or "").strip() + if value: + rows.append({"item": label, "value": value}) + + for code, choice in sorted((picked.get("range_factor_choices") or {}).items()): + rows.append({"item": f"범위 계수 {code}", "value": str(choice)}) + for code, choice in sorted((picked.get("machine_choices") or {}).items()): + rows.append({"item": f"장비 규격 {code}", "value": str(choice)}) + + from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices, total_percent + + surcharge = parse_choices(picked.get("labor_surcharge")) + if surcharge: + percent, reasons = total_percent(surcharge) + rows.append({"item": f"품 할인·할증 합계 {percent:g}%", "value": " · ".join(reasons)}) + return rows + + +def work_item_basis(build: Any) -> list[dict[str, Any]]: + """③ 공종마다 무엇을 근거로 했나 — **줄에 달린 문구를 그대로** 모은다.""" + rows: list[dict[str, Any]] = [] + for code, title in sorted(build.book.titles.items()): + if title.kind is not PriceKind.UNIT_PRICE: + continue + notes = [ + detail.note.strip() + for detail in build.book.details.get(code, []) + if detail.note and detail.note.strip() + ] + work_item_code = code[2:].split("#")[0] + source = build.factor_sources.get(work_item_code) + if source and source not in notes: + notes.append(str(source)) + if not notes: + continue + rows.append( + { + "code": code, + "name": title.name, + "unit": title.unit, + "notes": notes, + } + ) + return rows + + +def open_gaps(build: Any) -> list[dict[str, str]]: + """④ 못 채운 자리 — **0 으로 때우지 않은 자리**를 사유째 모은다.""" + rows: list[dict[str, str]] = [] + for code, reason in sorted(build.component_gaps.items()): + rows.append({"kind": "성분 못 채움", "code": code, "reason": str(reason)}) + for code, section in sorted(build.basis_missing.items()): + rows.append({"kind": "밑수 미확보", "code": code, "reason": str(section)}) + for note in build.transport_notes: + rows.append({"kind": "수송비", "code": "FP-10-04", "reason": str(note)}) + for machine in build.incomplete_machines: + rows.append({"kind": "기계 층 미완성", "code": machine, "reason": "손료·운전경비 미확보"}) + return rows + + +def basis_sheet(build: Any, settings: dict[str, Any] | None = None) -> dict[str, Any]: + """산출기초 한 장 — 넷을 접어 낸다.""" + versions = dataset_versions() + conditions = chosen_conditions(settings) + items = work_item_basis(build) + gaps = open_gaps(build) + return { + "note": SHEET_NOTE, + "dataset_versions": versions, + "chosen_conditions": conditions, + "work_items": items, + "gaps": gaps, + "summary": ( + f"데이터 {len(versions)} 판 · 고른 값 {len(conditions)} · " + f"근거가 달린 공종 {len(items)} · 못 채운 자리 {len(gaps)}" + ), + } diff --git a/B09_Estimation/B09_Estimation_DesignDocIndex.py b/B09_Estimation/B09_Estimation_DesignDocIndex.py index d42a8e33..875bc28d 100644 --- a/B09_Estimation/B09_Estimation_DesignDocIndex.py +++ b/B09_Estimation/B09_Estimation_DesignDocIndex.py @@ -149,12 +149,13 @@ DESIGN_DOC_ITEMS: tuple[dict[str, Any], ...] = ( { "order": 13, "name": "산출기초", - "status": STATUS_PARTIAL, + "status": STATUS_READY, "owner": OWNER_PROGRAM, - "where": "각 줄의 산출근거 문구", + "where": "원가계산 → 산출기초", "note": ( - "줄마다 어디서 온 값인지는 적혀 있으나 별도의 장으로 묶여 있지 않다 — " - "설계서로 묶으려면 그 문구들을 한 장으로 모으는 자리가 필요하다." + "줄에 달린 근거를 한 장으로 모은 장이다 — 어느 판으로 계산했나(데이터 기준일) · " + "무엇을 골랐나(산출 조건) · 공종마다 무엇을 근거로 했나 · 못 채운 자리. " + "여기서 값을 다시 계산하지 않는다." ), }, ) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index b2d4169f..92752a3f 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -319,6 +319,28 @@ async def get_price_sources(project_id: UUID) -> JSONResponse: ) +@router.get("/{project_id}/estimation/basis-sheet") +async def get_basis_sheet(project_id: UUID) -> JSONResponse: + """**산출기초** — 줄에 달린 근거를 한 장으로 모은다(별표2 (5)(가) 열셋째). + + ⚠ 값을 다시 계산하지 않는다 — 일위대가·내역서가 낸 그대로를 모으기만 한다. + """ + from B09_Estimation.B09_Estimation_BasisSheet import basis_sheet + from common_util.common_util_project_settings import estimation_settings + + try: + build = await _build_for(project_id) + root = await _project_root_of(project_id) + settings = estimation_settings(root) if root else {} + return JSONResponse(content={"status": "success", **basis_sheet(build, settings)}) + except Exception: + logger.exception("B09 산출기초 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "산출기초를 못 만들었습니다."}, + ) + + @router.get("/{project_id}/estimation/machine-expense") async def get_machine_expense(project_id: UUID) -> JSONResponse: """**각종 중기경비계산서** — 기종마다 한 장(별표2 (5)(가) 아홉째). diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index 47dcad4d..5f88db88 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -357,6 +357,91 @@ export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto): for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet)); } +/* ============================================================================= + * 산출기초 — 줄에 달린 근거를 한 장으로 모은 장(별표2 (5)(가) 열셋째). + * ⚠ 여기서 값을 다시 계산하지 않는다 — 모으기만 한다. + * ========================================================================== */ + +export interface BasisSheetDto { + status: string; + note: string; + summary: string; + dataset_versions: Array<{ + dataset_id: string; + file: string; + effective_date: string; + sha256: string; + }>; + chosen_conditions: Array<{ item: string; value: string }>; + work_items: Array<{ code: string; name: string; unit: string; notes: string[] }>; + gaps: Array<{ kind: string; code: string; reason: string }>; +} + +export async function fetchBasisSheet(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/basis-sheet`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`basis-sheet ${response.status}`); + return (await response.json()) as BasisSheetDto; +} + +export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void { + body.append(head("산출기초")); + body.append(note(data.summary)); + body.append(note(data.note)); + + body.append(head(`① 어느 판으로 계산했나 (${data.dataset_versions.length})`)); + body.append( + table( + ["자료", "파일", "기준일", "지문(앞 12)"], + data.dataset_versions.map((row) => [ + row.dataset_id, + row.file, + row.effective_date, + row.sha256 || "—", + ]), + [0, 1, 2, 3], + ), + ); + + body.append(head(`② 무엇을 골랐나 (${data.chosen_conditions.length})`)); + if (data.chosen_conditions.length === 0) { + body.append(note("고른 값이 없습니다 — 전부 확정 기본값으로 돌고 있습니다.")); + } else { + body.append( + table( + ["항 목", "고른 값"], + data.chosen_conditions.map((row) => [row.item, row.value]), + [0, 1], + ), + ); + } + + body.append(head(`③ 공종마다 무엇을 근거로 했나 (${data.work_items.length})`)); + body.append( + table( + ["코드", "공 종", "단위", "근 거"], + data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]), + [0, 1, 2, 3], + ), + ); + + body.append(head(`④ 못 채운 자리 (${data.gaps.length})`)); + if (data.gaps.length === 0) { + body.append(note("못 채운 자리가 없습니다.")); + } else { + body.append( + table( + ["갈 래", "코드", "사 유"], + data.gaps.map((row) => [row.kind, row.code, row.reason]), + [0, 1, 2], + ), + ); + body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다.")); + } +} + /** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */ export function drawBaseDataError(body: HTMLElement): void { body.append(note(L("B09_Estimation_Tab_Pending"))); diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 01149edc..766587b8 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -19,17 +19,20 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { drawBaseDataTab, drawFactorChoices, + drawBasisSheet, drawDesignDocTab, drawMachineExpense, drawMachineTab, drawPriceSourcesPending, drawPriceSourcesSections, fetchBaseData, + fetchBasisSheet, fetchDesignDocIndex, fetchMachineExpense, fetchFactorChoices, fetchPriceSources, type BaseDataDto, + type BasisSheetDto, type DesignDocDto, type MachineExpenseDto, type FactorChoicesDto, @@ -602,6 +605,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["supply", "B09_Estimation_Tab_Supply", true], ["base_data", "B09_Estimation_Tab_BaseData", true], ["design_doc", "B09_Estimation_Tab_DesignDoc", true], + ["basis_sheet", "B09_Estimation_Tab_BasisSheet", true], ]; function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { @@ -815,6 +819,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { let priceSources: PriceSourcesDto | null = null; let factorChoices: FactorChoicesDto | null = null; let designDoc: DesignDocDto | null = null; + let basisSheet: BasisSheetDto | null = null; let machineExpense: MachineExpenseDto | null = null; let sheet: CostSheetDto | null = null; let unitPriceList: UnitPriceListDto | null = null; @@ -1177,6 +1182,28 @@ export async function renderB09Estimation(root: HTMLElement): Promise { drawMaterialTab(); return; } + if (activeTab === "basis_sheet") { + // 산출기초 — 줄에 달린 근거를 모아 오는 장이라 조립이 끝나야 뜬다. + if (basisSheet) { + drawBasisSheet(body, basisSheet); + return; + } + const waiting = document.createElement("div"); + waiting.className = "b09-empty"; + waiting.textContent = L("B09_Estimation_Tab_Pending"); + body.append(waiting); + if (projectId) { + void fetchBasisSheet(projectId) + .then((data) => { + basisSheet = data; + drawBody(); + }) + .catch(() => { + /* 못 받아도 화면을 비우지 않는다. */ + }); + } + return; + } if (activeTab === "design_doc") { // 설계서 구성표 — 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표다. if (designDoc) { diff --git a/resources/tester/test_b08_material_summary.py b/resources/tester/test_b08_material_summary.py index 5966bddf..07c62699 100644 --- a/resources/tester/test_b08_material_summary.py +++ b/resources/tester/test_b08_material_summary.py @@ -215,10 +215,12 @@ def test_합계는_반올림하지_않음() -> None: # ── 관급/사급 ─────────────────────────────────────────────────────── -def test_안_정한_자재는_미분류로_드러남() -> None: +def test_안_정한_자재는_미정으로_드러남() -> None: + """⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼 + 읽힌다(2026-09-09 화면 확인). 값(`unknown`)과 판정은 그대로다.""" table = build_table(원단위표(성분("야면석", "㎥", 5.0))) assert 줄(table, "야면석")["supply"] == SUPPLY_UNKNOWN - assert 줄(table, "야면석")["supply_label"] == "미분류" + assert 줄(table, "야면석")["supply_label"] == "미정(발주기관 결정)" assert table["missing_supply_materials"] == ["야면석"] diff --git a/resources/tester/test_b09_basis_sheet.py b/resources/tester/test_b09_basis_sheet.py new file mode 100644 index 00000000..c6d0ebd2 --- /dev/null +++ b/resources/tester/test_b09_basis_sheet.py @@ -0,0 +1,87 @@ +"""산출기초 — 줄에 달린 근거를 한 장으로 (2026-09-09). + +별표2 (5)(가) 열셋째. **새로 세는 장이 아니다** — 이미 줄마다 달려 있는 근거를 모은다. + +⚠ 겨누는 것 다섯 + ① 어느 판으로 계산했나 — **장부(`_manifest.json`)를 그대로** 읽는다(목록을 따로 안 적음) + ② 고른 값만 싣는다 — 안 고른 칸은 기본값이라 줄이 안 선다 + ③ 근거는 **줄에 달린 문구 그대로** — 여기서 새로 짓지 않는다 + ④ 못 채운 자리는 **사유째** 모은다(0 으로 때운 자리가 아님) + ⑤ 여기서 **값을 다시 계산하지 않는다** — 일위대가가 낸 값과 두 벌이 되면 안 된다 +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_BasisSheet import ( # noqa: E402 + basis_sheet, + chosen_conditions, + dataset_versions, + work_item_basis, +) +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + + +def test_판_목록은_장부를_그대로_읽는다() -> None: + manifest = json.loads( + (ROOT / "resources" / "data_cost_input_value" / "_manifest.json").read_text( + encoding="utf-8" + ) + ) + ledger = {str(row["file"]) for row in manifest["files"]} + rows = dataset_versions() + assert ledger <= {row["file"] for row in rows} + # 공종 마스터도 함께 실린다 — 품셈 원문을 정규화한 판이다. + assert any(row["dataset_id"] == "work_item_master" for row in rows) + for row in rows: + assert row["effective_date"], row + + +def test_고른_값만_싣는다() -> None: + assert chosen_conditions({}) == [] + assert chosen_conditions({"misc_material_percent": "", "fuel_region": ""}) == [] + picked = chosen_conditions({"fuel_region": "01", "transport_distance_km": "12"}) + assert [row["value"] for row in picked] == ["01", "12"] + + +def test_품_할증은_고른_것을_풀어_적는다() -> None: + picked = chosen_conditions({"labor_surcharge": {"1-4-1": "1-4-1:0"}}) + assert len(picked) == 1 + assert "10%" in picked[0]["item"] + assert "작업시기" in picked[0]["value"] + + +def test_근거는_줄에_달린_문구_그대로다() -> None: + build = cached_build() + rows = {row["code"]: row for row in work_item_basis(build)} + assert rows, "근거가 달린 공종이 하나도 없습니다" + for code, row in rows.items(): + notes = { + detail.note.strip() + for detail in build.book.details.get(code, []) + if detail.note and detail.note.strip() + } + # 줄에 없는 문구를 지어내지 않는다(공종 단위 출처 한 줄만 더 붙을 수 있다). + assert notes <= set(row["notes"]) + + +def test_못_채운_자리는_사유째_모은다() -> None: + sheet = basis_sheet(cached_build(), {}) + assert sheet["gaps"], "못 채운 자리가 하나도 없다고 나옵니다 — 셈이 이상합니다" + for row in sheet["gaps"]: + assert row["kind"] and row["code"] and row["reason"] + + +def test_값을_다시_계산하지_않는다() -> None: + """⑤ 이 장에는 금액 칸이 없다 — 금액은 일위대가·내역서 몫이다.""" + sheet = basis_sheet(cached_build(), {}) + text = json.dumps(sheet, ensure_ascii=False) + assert "총액" not in text and "합계 금액" not in text + for row in sheet["work_items"]: + assert set(row) == {"code", "name", "unit", "notes"} diff --git a/resources/tester/test_b09_design_doc_index.py b/resources/tester/test_b09_design_doc_index.py index fc3f8ccf..d4449156 100644 --- a/resources/tester/test_b09_design_doc_index.py +++ b/resources/tester/test_b09_design_doc_index.py @@ -60,13 +60,20 @@ def test_설계자_몫과_우리_몫을_가른다() -> None: assert item["status"] == STATUS_MISSING -def test_반쪽은_반쪽이라_적는다() -> None: - """⚠ 2026-09-09 밤 — 중기경비계산서를 세우면서 **반쪽이 하나 줄었다**(기종마다 한 장). +def test_반쪽이_남지_않았다() -> None: + """⚠ 2026-09-09 밤 — 중기경비계산서·산출기초를 차례로 세우며 **반쪽이 다 없어졌다.** - 남은 반쪽은 산출기초 하나 — 근거 문구는 줄마다 있으나 한 장으로 묶는 자리가 없다. + 이제 우리 몫 가운데 「없음」은 공사설명서 하나이고, 그것은 **서식·설계하중 표기를 + 사용자에게 받아야** 서는 자리다. """ partial = {item["name"] for item in DESIGN_DOC_ITEMS if item["status"] == STATUS_PARTIAL} - assert partial == {"산출기초"} + assert partial == set() + ours_missing = { + item["name"] + for item in DESIGN_DOC_ITEMS + if item["owner"] == OWNER_PROGRAM and item["status"] != STATUS_READY + } + assert ours_missing == {"공사설명서"} def test_서는_것은_어디서_나오는지_적혀_있다() -> None: @@ -84,7 +91,7 @@ def test_못_내는_것에는_무엇이_필요한지_적혀_있다() -> None: def test_요약이_셈과_맞는다() -> None: data = design_doc_index() - assert data["counts"][STATUS_READY] == 8 + assert data["counts"][STATUS_READY] == 9 assert sum(data["counts"].values()) == len(DESIGN_DOC_ITEMS) ours_missing = [ item["name"] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 2d713882..ce05c112 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -828,6 +828,7 @@ export const ui_locales_b2 = { B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"], B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"], B09_Estimation_Tab_DesignDoc: ["설계서 구성", "Design Document Set"], + B09_Estimation_Tab_BasisSheet: ["산출기초", "Calculation Basis"], B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"], B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"], B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"],