From e1927423848bbe5b76b607eda59dc7de0b8ed334 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 16:18:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EB=82=A8=EC=9D=80=20=ED=91=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=84=AF=20=EC=9E=A5=EC=97=90=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=EA=B3=BC=20=EB=93=B1=EA=B8=89=EC=83=89=20?= =?UTF-8?q?=EB=B0=B0=EC=84=A0=20=E2=80=94=208-36=20=E2=91=A5=20B08=20?= =?UTF-8?q?=EB=AA=AB=20=EB=8B=AB=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토적표만 서 있던 근거 호버를 토공집계·운반·준비공·자재총괄·구조물 원단위까지 넓힘. 사전 여섯 장 48열이 됨(토적표 20 · 집계 5 · 운반 5 · 준비공 5 · 자재 7 · 원단위 6). 같은 열이라도 줄마다 성격이 갈리는 자리를 **칸 등급**으로 갈랐음 - 토공집계 「계」 — 무대(소운반 20m) 줄은 `final` 이 아니라 `excluded`. 집계에는 오르되 내역 줄이 아님(품셈 1-2-7). 채우면 이중계상이라 「못 세움」과 뜻이 정반대임 - 운반표 — 같은 줄의 토량·거리 둘 다 `excluded`. 값은 검산에만 씀 - 준비공 「수량」 — 값을 못 세운 줄은 `blocked`. 근거가 오면 채워질 자리라 제외와 갈림 **B08 에서 `final`(최종)이 처음 서는 자리는 토공집계표 「계」** 임. 토적표는 중간 장부라 최종 열이 하나도 없음 — 그 갈림을 시험으로 박았음(등급 여섯은 한 장이 아니라 여러 장을 합쳐야 다 쓰임). 자체검증 — 시험 4개 추가(모두 12개). 집계표·운반표 사전 열 이름이 실제 엔진 줄에 있는지 대조하고(운반표 `average_distance_m` 은 필드가 아니라 property 라 따로 봄), 등급·이름·식 검사를 여섯 장 전부로 넓힘. `pytest -q` **1317 passed** · `tsc` 통과. 브라우저(ORCA 5173, 실제 사전을 물려 다섯 표를 그림) — 집계 정상줄 `final` / 무대줄 `excluded` · 운반 정상 `calc` / 무대 `excluded` · 준비공 정상 `calc` / 값 없는 줄 `blocked` · 자재 총수량 `final` · 관급사급 `input` · 원단위 수량 `calc`. 카드에 식·원천·채택·자리가 다 뜸. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk --- B08_Quantity/B08_Quantity_Provenance.py | 312 ++++++++++++++++++- B08_Quantity/B08_Quantity_Router_Material.py | 23 +- B08_Quantity/B08_Quantity_UI_MaterialGrid.ts | 47 +++ B08_Quantity/B08_Quantity_UI_Page.ts | 31 +- B08_Quantity/B08_Quantity_UI_SummaryGrid.ts | 50 ++- resources/tester/test_b08_provenance.py | 63 +++- 6 files changed, 498 insertions(+), 28 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Provenance.py b/B08_Quantity/B08_Quantity_Provenance.py index a33e3379..401d3d84 100644 --- a/B08_Quantity/B08_Quantity_Provenance.py +++ b/B08_Quantity/B08_Quantity_Provenance.py @@ -23,6 +23,9 @@ from typing import Any from common_util.common_util_provenance import ( TIER_CALC, + TIER_FINAL, + TIER_INPUT, + TIER_STANDARD, TIER_SURVEY, ColumnProvenance, provenance_payload, @@ -155,9 +158,316 @@ def earthwork_sheet() -> dict[str, Any]: ) +def summary_sheet() -> dict[str, Any]: + """토공집계표 — 토적표·사면표를 공종별 총량으로 모은 장. + + ⚠ **B08 에서 `final`(최종)이 처음 서는 자리다.** 토적표는 중간 장부였고, 내역서로 + 나가는 값은 여기 「계」다. 다만 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이 + 되지 않는** 줄이 있어, 그 줄의 「계」는 칸 등급 `excluded` 로 덮어쓴다(화면 배선). + """ + return sheet_provenance( + [ + ColumnProvenance( + key="group", + label="구분", + tier=TIER_STANDARD, + formula="품셈 공종 갈래 이름을 그대로 씀 (흙깎기·성토·측구터파기…)", + source="거창 실무 토공집계표 시트의 열 문구를 그대로 옮김", + code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow", + ), + ColumnProvenance( + key="item", + label="공종", + tier=TIER_STANDARD, + formula="지반 갈래 이름 (토사·연암·발파암…)", + source="갈래 수는 프로젝트 설정의 암 갈래 세트가 정함 — 코드에 안 박음", + rule="암 총량을 설계자가 넣은 갈래 비율(%)로 나눠 줄을 만듦", + code="B08_Quantity_Engine_EarthworkSummary.py:_rock_split", + ), + ColumnProvenance( + key="spec", + label="규격", + tier=TIER_STANDARD, + formula="시공 방법 표기 (기계(굴삭기)·백호우…)", + source="품셈 공종이 요구하는 규격. 암은 시공법(긁어내기/터뜨리기)이 갈림", + code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="품셈 공종이 정한 단위 (㎥·㎡·주…)", + source="단위가 다르면 내역 단가와 안 맞음 — 여기서 정하지 않고 품셈을 따름", + code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow", + ), + ColumnProvenance( + key="amount", + label="계", + tier=TIER_FINAL, + formula="토적표·사면표 총량 × 반영률(%)", + source=( + "토공은 토적표 합계, 사면은 사면표 합계. ⚠ 반영률은 법정값이 아니라 " + "설계자가 넣는 값이고 기본 100 %" + ), + rule="무대(소운반 20m)는 집계에는 오르되 내역 줄이 아님 — 그 줄은 「제외」로 섬", + code="B08_Quantity_Engine_EarthworkSummary.py:build_table", + ), + ] + ) + + +def haul_sheet() -> dict[str, Any]: + """운반거리 — (운반수단 × 지반유형)별 가중평균 줄. + + ⚠⚠ **상태가 둘이다.** 거리는 다짐상태로 재고, 내역에 오르는 수량만 자연상태(÷C)로 낸다 + (설계실무 요령 5-4-3). 이 표의 「토량」은 **다짐상태**이므로 내역서 수량과 숫자가 다르다 — + 그 어긋남이 정상이라는 것을 카드가 말해 주어야 헛걸음을 안 한다. + """ + return sheet_provenance( + [ + ColumnProvenance( + key="equipment", + label="운반수단", + tier=TIER_CALC, + formula="유토곡선이 띠마다 고른 수단 (무대·도자운반·덤프운반)", + source="B06 운반계획(HaulPlan)의 띠. 여기서 다시 고르지 않음", + code="B08_Quantity_Engine_HaulSummary.py:_legs_of", + ), + ColumnProvenance( + key="ground", + label="지반유형", + tier=TIER_CALC, + formula="띠의 토량을 절토 구간 구성비로 안분한 세 갈래 (토사·리핑암·발파암)", + source="B06 운반계획이 이미 안분해 둔 값", + code="B08_Quantity_Engine_HaulSummary.py:_legs_of", + ), + ColumnProvenance( + key="volume_m3", + label="토량", + tier=TIER_CALC, + formula="그 갈래에 속한 근거 구간들의 토량 합", + source=( + "⚠ **다짐상태**임. 내역서에 오르는 수량은 자연상태(÷토량환산계수)라 " + "숫자가 다름 — 어긋난 것이 아님" + ), + code="B08_Quantity_Engine_HaulSummary.py:summarize", + ), + ColumnProvenance( + key="average_distance_m", + label="평균운반거리", + tier=TIER_CALC, + formula="Σ(토량 × 거리) ÷ Σ(토량) — 단순평균이 아님", + source="실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식", + code="B08_Quantity_Engine_HaulSummary.py:119 average_distance_m", + ), + ColumnProvenance( + key="legs", + label="근거 구간", + tier=TIER_CALC, + formula="이 평균을 만든 구간의 개수", + source="구간 줄은 버리지 않고 표 아래 근거로 함께 냄 — 되짚을 수 있어야 함", + code="B08_Quantity_Engine_HaulSummary.py:190", + ), + ] + ) + + +def preparation_sheet() -> dict[str, Any]: + """준비공·사방공 — **못 서는 줄도 서는 장.** + + 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도 + 상태와 사유를 달아 그대로 세운다. 값이 비어 있는 줄의 「수량」은 칸 등급 `blocked` 로 + 덮어쓴다 — **근거가 오면 채워질 자리**이지 일부러 비운 자리가 아니다. + """ + return sheet_provenance( + [ + ColumnProvenance( + key="group", + label="구분", + tier=TIER_STANDARD, + formula="준비공·사방공의 갈래 이름", + source="품셈 9장(준비공)과 배치된 구조물 종류가 줄을 만듦", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="item", + label="공종", + tier=TIER_STANDARD, + formula="품셈 공종 이름 (표토제거·제근·임목파쇄…)", + source="공종이 없으면 줄도 없음 — 화면에서 이름을 짓지 않음", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="품셈 공종이 정한 단위", + source="단위가 다르면 내역 단가와 안 맞음", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="amount", + label="수량", + tier=TIER_CALC, + formula="공종마다 다름 — 표토제거는 면적 × 표토 두께(T), 제근은 임목축적 등급", + source=( + "밑수는 사면표·구조물 목록이 내고, 두께·등급·개소는 산출 조건 패널에서 " + "설계자가 넣음" + ), + rule="넣어야 할 값이 비면 줄은 서되 수량이 「-」로 남고 사유가 붙음", + code="B08_Quantity_Engine_Preparation.py:build_table", + ), + ColumnProvenance( + key="status", + label="상태", + tier=TIER_CALC, + formula="값을 세웠나 못 세웠나", + source="못 세운 줄은 옆 칸에 사유가 붙음 — 사유가 곧 무엇을 넣어야 하는지임", + code="B08_Quantity_Engine_Preparation_Status.py", + ), + ] + ) + + +def material_sheet() -> dict[str, Any]: + """자재총괄 — 구조물 원단위에서 자재별로 모은 장. 관급/사급을 줄마다 고른다.""" + return sheet_provenance( + [ + ColumnProvenance( + key="name", + label="자재", + tier=TIER_STANDARD, + formula="품셈·카탈로그의 자재 이름", + source="구조물 원단위의 성분 이름을 그대로 모음 — 여기서 이름을 짓지 않음", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="자재가 팔리는 단위 (㎥·본·kg…)", + source="단가가 붙는 단위와 같아야 함", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="net_amount", + label="순수량", + tier=TIER_CALC, + formula="구조물마다 낸 성분 수량의 합 (할증 전)", + source="구조물 원단위 표의 「수량」을 자재 이름으로 모은 값", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="surcharge_pct", + label="할증률", + tier=TIER_STANDARD, + formula="자재마다 정해진 할증률(%)", + source="할증 판(dataset)이 정함. 판에 없는 자재는 「-」로 두고 지어내지 않음", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="total_amount", + label="총수량", + tier=TIER_FINAL, + formula="순수량 × (1 + 할증률)", + source="내역서·자재대로 나가는 값. 할증률이 없으면 순수량 그대로", + code="B08_Quantity_Engine_MaterialSummary.py", + ), + ColumnProvenance( + key="supply", + label="관급/사급", + tier=TIER_INPUT, + formula="설계자가 줄마다 고름", + source="자재마다 갈리는 발주 결정이라 표 안에서 고름 (2026-09-07 확정)", + code="B08_Quantity_UI_MaterialGrid.ts", + ), + ColumnProvenance( + key="install_by", + label="설치 주체", + tier=TIER_INPUT, + formula="설계자가 줄마다 고름", + source="⚠ **관급 줄에만 뜻이 있음** — 사급으로 되돌리면 값이 비워짐", + code="B08_Quantity_UI_MaterialGrid.ts", + ), + ] + ) + + +def unit_quantity_sheet() -> dict[str, Any]: + """구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. + + ⚠ 이 장은 **근거·출처 열을 이미 화면에 들고 있다**(2026-09-09 부터). 사전은 그 열이 + 무엇을 뜻하는지 설명하는 자리이지, 있는 값을 다시 만드는 자리가 아니다. + """ + return sheet_provenance( + [ + ColumnProvenance( + key="structure", + label="구조물", + tier=TIER_SURVEY, + formula="B05 노선에 놓인 구조물의 이름과 놓인 측점", + source="측점 표기(NO.4 ~ NO.4+10)는 화면이 만듦 — 서버는 이정만 냄", + code="B08_Quantity_Engine_Handoff_Rows_Prep.py:182", + ), + ColumnProvenance( + key="spec", + label="규격", + tier=TIER_SURVEY, + formula="구조물 제원 (길이 × 높이)", + source="B05·B06 이 배치할 때 정한 치수. 여기서 다시 정하지 않음", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="component", + label="성분", + tier=TIER_STANDARD, + formula="그 구조물이 쓰는 재료·공종 이름", + source="품셈 표 또는 실무 관측 원단위표가 정함", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="unit", + label="단위", + tier=TIER_STANDARD, + formula="성분이 세어지는 단위", + source="단가가 붙는 단위와 같아야 함", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="amount", + label="수량", + tier=TIER_CALC, + formula="치수 전개(길이·높이로 편 식) 또는 실무 관측 원단위 × 개소", + source="어느 쪽인지는 같은 줄의 「출처」 칸이 말해 줌 (치수 전개 / 실무 관측)", + rule="치수 전개는 식이 있고, 실무 관측은 관측값이라 식이 없음 — 둘을 섞지 않음", + code="B08_Quantity_Engine_UnitQuantity.py", + ), + ColumnProvenance( + key="destination", + label="갈 곳", + tier=TIER_STANDARD, + formula="이 성분이 어느 표로 가는가 (자재총괄·공종 내역·양쪽)", + source=( + "⚠ 갈 곳이 겹치면 이중계상임 — 그것을 막으려고 성분마다 갈 곳을 적음 (PLAN 8-7)" + ), + code="B08_Quantity_Engine_Handoff_Mapping.py", + ), + ] + ) + + def quantity_provenance() -> dict[str, Any] | None: """B08 응답에 실을 사전 — **개발환경이 아니면 `None`.** 시트를 늘릴 때는 여기 한 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다. """ - return provenance_payload({"earthwork": earthwork_sheet()}) + return provenance_payload( + { + "earthwork": earthwork_sheet(), + "summary": summary_sheet(), + "haul": haul_sheet(), + "preparation": preparation_sheet(), + "material": material_sheet(), + "unit_quantity": unit_quantity_sheet(), + } + ) diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index f5c55b21..344d1500 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -31,6 +31,7 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table +from B08_Quantity.B08_Quantity_Provenance import quantity_provenance from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( ground_types_from_designs, @@ -173,15 +174,19 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: # 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다. if row.get("composite_parts") or row.get("composite_not_ready") ] - return JSONResponse( - content={ - "unit_quantity": unit_table, - "material": material_table, - "composite": composite, - "skipped_structures": skipped, - "structure_count": len(structures), - } - ) + body: dict[str, Any] = { + "unit_quantity": unit_table, + "material": material_table, + "composite": composite, + "skipped_structures": skipped, + "structure_count": len(structures), + } + # 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라 + # 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다. + provenance = quantity_provenance() + if provenance is not None: + body["provenance"] = provenance + return JSONResponse(content=body) async def project_haul_inputs(project_id: UUID) -> dict[str, Any]: diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 65ceec08..6ce32804 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -12,6 +12,12 @@ * ========================================================================== */ import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid"; +import { + attachProvenance, + markProvenanceCell, + type ProvenancePayload, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; export interface MaterialRow { name: string; @@ -27,6 +33,38 @@ export interface MaterialRow { sources: string[]; } +/** 줄 하나의 칸에 열 키를 차례대로 심는다 (집계표 쪽과 같은 틀). */ +function markRow(tr: HTMLTableRowElement, keys: readonly (string | null)[]): void { + [...tr.children].forEach((cell, index) => { + const key = keys[index]; + if (key) markProvenanceCell(cell as HTMLElement, key); + }); +} + +/** 자재총괄 열 차례 — 비고는 사전을 안 붙인다. */ +const MATERIAL_KEYS = [ + "name", + "unit", + "net_amount", + "surcharge_pct", + "total_amount", + "supply", + "install_by", + null, +] as const; + +/** 구조물 원단위 열 차례 — 근거·출처는 **이미 설명 글**이라 카드를 거듭 안 띄운다. */ +const UNIT_QUANTITY_KEYS = [ + "structure", + "spec", + "component", + "unit", + "amount", + "destination", + null, + null, +] as const; + export interface MaterialTable { columns: string[]; rows: MaterialRow[]; @@ -95,6 +133,8 @@ export interface MaterialResponse { skipped_structures: string[]; structure_count: number; /** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */ + /** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */ + provenance?: ProvenancePayload; composite?: { name: string; parts: CompositePart[]; @@ -244,6 +284,7 @@ function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTML export function renderMaterialGrid( table: MaterialTable, options?: MaterialGridOptions, + sheet?: ProvenanceSheet, ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -330,10 +371,12 @@ export function renderMaterialGrid( tr.append(textCell(row.install_by_label)); } tr.append(textCell(row.note, "b08-grid__note")); + markRow(tr, MATERIAL_KEYS); body.append(tr); } element.append(body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; @@ -465,11 +508,15 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement // 나중에 「이 값이 왜 이런가」를 되짚을 수 있다. const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개"; tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}회` : kind)); + markRow(tr, UNIT_QUANTITY_KEYS); body.append(tr); } } element.append(body); + // 성분이 없는 줄은 칸을 붙여 쌀으므로(`colSpan`) 짚지 않았다 — 차례가 어긋나면 + // 엉뚱한 열의 설명이 뜼다. + attachProvenance(element, response.provenance?.sheets?.unit_quantity); scroller.append(element); wrap.append(scroller); return wrap; diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index d4c548c2..6813b290 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -781,7 +781,10 @@ function buildQuantityBody( }; // 등급색 토글 — ⚠ **사전이 왔을 때만** 만든다(개발환경). 배포 빌드에서는 단추 자체가 없다. - if ((table as unknown as { provenance?: unknown } | null)?.provenance) { + if ( + (table as unknown as { provenance?: unknown } | null)?.provenance || + (material as unknown as { provenance?: unknown } | null)?.provenance + ) { tabs.append(createProvenanceToggle(body)); } @@ -799,13 +802,19 @@ function buildQuantityBody( { label: L("B08_Quantity_Tab_Summary"), build: () => - table.summary ? renderSummaryGrid(table.summary) : message(L("B08_Quantity_Grid_Empty")), + table.summary + ? renderSummaryGrid(table.summary, table.provenance?.sheets?.summary) + : message(L("B08_Quantity_Grid_Empty")), }, { label: L("B08_Quantity_Tab_Haul"), build: () => table.haul - ? renderHaulGrid(table.haul, Boolean(table.haul_available)) + ? renderHaulGrid( + table.haul, + Boolean(table.haul_available), + table.provenance?.sheets?.haul, + ) : message(L("B08_Quantity_Haul_Missing")), }, { @@ -813,7 +822,7 @@ function buildQuantityBody( build: () => { const preparation = (table as unknown as { preparation?: PreparationTable }).preparation; return preparation - ? renderPreparationGrid(preparation) + ? renderPreparationGrid(preparation, table.provenance?.sheets?.preparation) : message(L("B08_Quantity_Grid_Empty")); }, }, @@ -826,12 +835,16 @@ function buildQuantityBody( label: L("B08_Quantity_Tab_Material"), build: () => material - ? renderMaterialGrid(material.material, { - choices: draft.material_supply, - onChange: () => { - draft.dirty = true; + ? renderMaterialGrid( + material.material, + { + choices: draft.material_supply, + onChange: () => { + draft.dirty = true; + }, }, - }) + material.provenance?.sheets?.material, + ) : message(L("B08_Quantity_Material_Failed")), }, ]; diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts index b4c03e32..7845acce 100644 --- a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -11,11 +11,38 @@ * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { + attachProvenance, + markProvenanceCell, + type ProvenanceSheet, +} from "@ui/ui_template_provenance"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/** 줄 하나의 칸에 열 키를 차례대로 심는다. + + * 이 세 표는 칸을 `textCell()` 로 줄지어 붙이므로 **다 지은 뒤에 차례로 짚는 것**이 + * 가장 적게 고치는 길이다. `override` 는 **그 칸만 열 등급을 이기는** 자리다 — + * 같은 열이라도 줄마다 성격이 갈리는 것(내역 제외 줄·값을 못 세운 줄)을 위한 것이다. + */ +function markRow( + tr: HTMLTableRowElement, + keys: readonly (string | null)[], + override?: Record, +): void { + [...tr.children].forEach((cell, index) => { + const key = keys[index]; + if (key) markProvenanceCell(cell as HTMLElement, key, override?.[index]); + }); +} + +/** 열 차례 — 비고는 사유 글이라 사전을 안 붙인다(`null`). */ +const SUMMARY_KEYS = ["group", "item", "spec", "unit", "amount", null] as const; +const HAUL_KEYS = ["equipment", "ground", "volume_m3", "average_distance_m", "legs", null] as const; +const PREPARATION_KEYS = ["group", "item", "unit", "amount", "status", null] as const; + export interface SummaryRow { group: string; item: string; @@ -77,7 +104,7 @@ function textCell(text: string, className?: string): HTMLTableCellElement { } /** 토공집계표 — 실무 시트와 같은 여섯 열. */ -export function renderSummaryGrid(table: SummaryTable): HTMLElement { +export function renderSummaryGrid(table: SummaryTable, sheet?: ProvenanceSheet): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -114,17 +141,25 @@ export function renderSummaryGrid(table: SummaryTable): HTMLElement { note.prepend(tag); } tr.append(note); + // ⚠ 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이 아닌** 줄은 「계」가 + // 최종이 아니라 **제외**임(품셀 1-2-7). 못 세운 것과 뜻이 정반대라 칸 등급을 갈라 준다. + markRow(tr, SUMMARY_KEYS, row.in_bill ? undefined : { 4: "excluded" }); body.append(tr); } element.append(head, body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; } /** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */ -export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElement { +export function renderHaulGrid( + table: HaulTable, + available: boolean, + sheet?: ProvenanceSheet, +): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -180,10 +215,13 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함")); } tr.append(note); + // 내역 줄이 안 되는 줄은 토량·거리 둘 다 「제외」임 — 값은 검산에만 쓴다. + markRow(tr, HAUL_KEYS, row.in_bill ? undefined : { 2: "excluded", 3: "excluded" }); body.append(tr); } element.append(head, body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; @@ -212,7 +250,10 @@ export interface PreparationTable { * 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도 * 상태와 사유를 달아 그대로 세운다. */ -export function renderPreparationGrid(table: PreparationTable): HTMLElement { +export function renderPreparationGrid( + table: PreparationTable, + sheet?: ProvenanceSheet, +): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b08-grid"; @@ -251,10 +292,13 @@ export function renderPreparationGrid(table: PreparationTable): HTMLElement { note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`)); } tr.append(note); + // 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다. + markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined); body.append(tr); } element.append(head, body); + attachProvenance(element, sheet); scroller.append(element); wrap.append(scroller); return wrap; diff --git a/resources/tester/test_b08_provenance.py b/resources/tester/test_b08_provenance.py index 3f6bcd95..597f3fc3 100644 --- a/resources/tester/test_b08_provenance.py +++ b/resources/tester/test_b08_provenance.py @@ -11,8 +11,15 @@ import dataclasses import pytest +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryRow from B08_Quantity.B08_Quantity_Engine_EarthworkTable import EarthworkRow -from B08_Quantity.B08_Quantity_Provenance import earthwork_sheet, quantity_provenance +from B08_Quantity.B08_Quantity_Engine_HaulSummary import HaulSummaryRow +from B08_Quantity.B08_Quantity_Provenance import ( + earthwork_sheet, + haul_sheet, + quantity_provenance, + summary_sheet, +) from common_util import common_util_provenance as provenance_module from common_util.common_util_provenance import ( TIERS, @@ -30,16 +37,38 @@ def test_토적표_사전이_엔진_열과_같은_이름을_쓴다(): assert not 낯선_키, f"사전에 있는데 토적표 줄에 없는 열: {낯선_키}" +def _모든_열(): + """개발환경에서 실리는 여섯 장을 한 줄로 펜다 — (장 이름, 열 키, 몸통).""" + payload = quantity_provenance() + assert payload is not None + for sheet_name, sheet in payload["sheets"].items(): + for key, body in sheet["columns"].items(): + yield sheet_name, key, body + + +def test_여섯_장이_다_실린다(): + payload = quantity_provenance() + assert payload is not None + assert set(payload["sheets"]) == { + "earthwork", + "summary", + "haul", + "preparation", + "material", + "unit_quantity", + } + + def test_사전_등급이_전부_아는_값이다(): - for key, body in earthwork_sheet()["columns"].items(): - assert body["tier"] in TIERS, f"{key} 의 등급이 모르는 값: {body['tier']}" + for sheet_name, key, body in _모든_열(): + assert body["tier"] in TIERS, f"{sheet_name}.{key} 등급이 모르는 값: {body['tier']}" def test_사전_열마다_이름과_식이_비어_있지_않다(): """빈 카드는 「설명이 있다」는 거짓만 남긴다 — 적을 것이 없으면 열을 아예 안 넣는다.""" - for key, body in earthwork_sheet()["columns"].items(): - assert body.get("label"), f"{key} 에 이름이 없음" - assert body.get("formula"), f"{key} 에 식이 없음" + for sheet_name, key, body in _모든_열(): + assert body.get("label"), f"{sheet_name}.{key} 에 이름이 없음" + assert body.get("formula"), f"{sheet_name}.{key} 에 식이 없음" def test_같은_열을_두_번_적으면_막는다(): @@ -79,3 +108,25 @@ def test_고르는_자리의_채택_규칙은_적었을_때만_실린다(): key="b", label="나", tier="calc", formula="x", rule="A·B 중 작은 쪽" ).as_dict() assert 있는_것["rule"] == "A·B 중 작은 쪽" + + +def test_집계표_사전이_엔진_열과_같은_이름을_쓴다(): + row_fields = {field.name for field in dataclasses.fields(SummaryRow)} + 낯선_키 = sorted(set(summary_sheet()["columns"]) - row_fields) + assert not 낯선_키, f"사전에 있는데 집계표 줄에 없는 열: {낯선_키}" + + +def test_운반표_사전이_엔진_열과_같은_이름을_쓴다(): + """⚠ `average_distance_m` 은 필드가 아니라 property 라 필드 목록만 보면 놓친다.""" + names = {field.name for field in dataclasses.fields(HaulSummaryRow)} + names |= {n for n in dir(HaulSummaryRow) if not n.startswith("_")} + 낯선_키 = sorted(set(haul_sheet()["columns"]) - names) + assert not 낯선_키, f"사전에 있는데 운반표 줄에 없는 열: {낯선_키}" + + +def test_집계표에_최종이_서고_토적표에는_없다(): + """등급 여섯은 한 장이 아니라 **두 장을 합쳐야** 다 쓰인다 — 그 갈림을 시험으로 박는다.""" + 토적표 = {body["tier"] for body in earthwork_sheet()["columns"].values()} + 집계표 = {body["tier"] for body in summary_sheet()["columns"].values()} + assert "final" not in 토적표, "토적표는 중간 장부라 최종 열이 없어야 함" + assert "final" in 집계표, "내역서로 나가는 값은 집계표 「계」에서 서야 함"