feat(B08): 남은 표 다섯 장에 근거 사전과 등급색 배선 — 8-36 ⑥ B08 몫 닫음

토적표만 서 있던 근거 호버를 토공집계·운반·준비공·자재총괄·구조물 원단위까지 넓힘.
사전 여섯 장 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
This commit is contained in:
2026-09-12 16:18:20 +09:00
co-authored by Claude Opus 5
parent 7068f59dff
commit e192742384
6 changed files with 498 additions and 28 deletions
+311 -1
View File
@@ -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(),
}
)
+14 -9
View File
@@ -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]:
@@ -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;
+22 -9
View File
@@ -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")),
},
];
+47 -3
View File
@@ -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<number, string | undefined>,
): 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;
+57 -6
View File
@@ -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 집계표, "내역서로 나가는 값은 집계표 「계」에서 서야 함"