무슨 일이 있었나 랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운 커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`· `CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴. 잃었던 것 - 공용 — `common_util_provenance.py` · `ui_template_provenance.ts` - B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸 - B09 — 근거 사전 셋 - B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈 - 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등) 어떻게 되살렸나 `611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림. 이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난 옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛 상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 — `9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑). 위키 여덟은 코덱스 몫이라 손대지 않음. 자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 · `B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의 `attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped** (되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
524 lines
21 KiB
TypeScript
524 lines
21 KiB
TypeScript
/* =============================================================================
|
||
* B08_Quantity_UI_MaterialGrid.ts
|
||
* 자재총괄표·구조물 원단위 그리드 (PLAN 8-2·8-6·8-7).
|
||
*
|
||
* 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다.
|
||
*
|
||
* ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급/사급 미정·설치주체 미지정은
|
||
* 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고,
|
||
* 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다.
|
||
*
|
||
* ⚠ 반올림은 여기서만 한다(PLAN 8-16 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다.
|
||
* ========================================================================== */
|
||
|
||
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
|
||
import {
|
||
attachProvenance,
|
||
markProvenanceCell,
|
||
type ProvenancePayload,
|
||
type ProvenanceSheet,
|
||
} from "@ui/ui_template_provenance";
|
||
|
||
export interface MaterialRow {
|
||
name: string;
|
||
unit: string;
|
||
net_amount: number;
|
||
surcharge_pct: number | null;
|
||
total_amount: number;
|
||
supply: string;
|
||
supply_label: string;
|
||
install_by: string | null;
|
||
install_by_label: string;
|
||
note: string;
|
||
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[];
|
||
surcharge_applied: boolean;
|
||
surcharge_dataset: { effective_date: string; source: Record<string, unknown> };
|
||
missing_rate_materials: string[];
|
||
missing_supply_materials: string[];
|
||
missing_install_by_materials: string[];
|
||
amount_spread: Record<string, { min: number; median: number; max: number; count: number }>;
|
||
double_count_warnings: string[];
|
||
skipped_by_destination: Record<string, number>;
|
||
row_count: number;
|
||
}
|
||
|
||
export interface UnitQuantityStructure {
|
||
structure_id: string | null;
|
||
type_id: string;
|
||
name: string;
|
||
length_m: number;
|
||
height_m: number;
|
||
/** 구조물이 덮는 구간(m) — 표기(`NO.4 ~ NO.4+10`)는 화면 몫이다
|
||
* (`B08_Quantity_Engine_Handoff_Rows_Prep.py:182` 「표기는 만들지 않는다」). */
|
||
start_m?: number | null;
|
||
end_m?: number | null;
|
||
notes: string[];
|
||
components: {
|
||
name: string;
|
||
unit: string;
|
||
amount: number;
|
||
destination: string;
|
||
basis: string;
|
||
/** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */
|
||
basis_kind?: string;
|
||
source?: string;
|
||
/** 거푸집 줄만 — 몇 회짜리인가(품셈 1-7-1). 횟수별 재료 환산은 B09 몫이다. */
|
||
reuse_count?: number | null;
|
||
reuse_note?: string;
|
||
}[];
|
||
}
|
||
|
||
/** 묶음으로 서는 구조물의 조각. 품셈에 그 이름의 공종이 없어 여러 공종으로 나뉜다. */
|
||
export interface CompositePart {
|
||
code: string | null;
|
||
name?: string;
|
||
unit?: string;
|
||
quantity: number | null;
|
||
basis_kind?: string | string[] | null;
|
||
/** 철근 갈래(간단/보통/복잡/매우복잡) — 품셈 원문이 정한다. */
|
||
kind?: string | null;
|
||
kind_basis?: string;
|
||
not_ready?: boolean;
|
||
why?: string;
|
||
/** 물량은 섰으나 일부 몫이 빠진 조각 — 「다 섰다」로 오해하지 않게 함께 보인다. */
|
||
incomplete_note?: string;
|
||
}
|
||
|
||
export interface MaterialResponse {
|
||
unit_quantity: {
|
||
structures: UnitQuantityStructure[];
|
||
totals: { name: string; unit: string; amount: number; destination: string }[];
|
||
surcharge_applied: boolean;
|
||
mix_components_found: string[];
|
||
structure_count: number;
|
||
};
|
||
material: MaterialTable;
|
||
skipped_structures: string[];
|
||
structure_count: number;
|
||
/** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */
|
||
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
|
||
provenance?: ProvenancePayload;
|
||
composite?: {
|
||
name: string;
|
||
parts: CompositePart[];
|
||
/** 못 채운 조각 — 「단가 없음」과 「물량 없음」을 가르려고 사유를 구조로 받는다. */
|
||
not_ready?: { code: string | null; reason: string }[] | null;
|
||
}[];
|
||
}
|
||
|
||
/** 구조물이 덮는 구간을 실무 표기로 — 값이 없으면 빈 문자열(지어내지 않는다). */
|
||
function stationRange(startM?: number | null, endM?: number | null): string {
|
||
if (typeof startM !== "number" || typeof endM !== "number") return "";
|
||
return startM === endM ? stationLabel(startM) : `${stationLabel(startM)} ~ ${stationLabel(endM)}`;
|
||
}
|
||
|
||
/** 거푸집·동바리 안내에 쓰는 값. */
|
||
export interface FormworkInfo {
|
||
formwork_notes?: string[];
|
||
formwork_reuse_missing?: string[];
|
||
shoring?: { applicable: boolean; reason: string; pending_types: string[] };
|
||
/** 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다. */
|
||
pending_choices?: {
|
||
label: string;
|
||
default?: unknown;
|
||
where?: string;
|
||
effect?: string;
|
||
scope?: string;
|
||
}[];
|
||
}
|
||
|
||
/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */
|
||
const DESTINATION_LABELS: Record<string, string> = {
|
||
earthwork: "토공 합산",
|
||
material: "자재총괄",
|
||
unit_price: "일위대가",
|
||
};
|
||
|
||
function num(value: number | null | undefined, digits: number): string {
|
||
if (value === undefined || value === null || Number.isNaN(value)) return "";
|
||
return value.toLocaleString("ko-KR", {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
});
|
||
}
|
||
|
||
function textCell(text: string, className?: string): HTMLTableCellElement {
|
||
const td = document.createElement("td");
|
||
td.textContent = text;
|
||
if (className) td.className = className;
|
||
return td;
|
||
}
|
||
|
||
function headRow(labels: string[]): HTMLTableSectionElement {
|
||
const head = document.createElement("thead");
|
||
const tr = document.createElement("tr");
|
||
for (const label of labels) {
|
||
const th = document.createElement("th");
|
||
th.textContent = label;
|
||
tr.append(th);
|
||
}
|
||
head.append(tr);
|
||
return head;
|
||
}
|
||
|
||
/** 못 채운 조각의 사유를 사람이 읽는 줄로. 코드가 있으면 앞에 붙인다. */
|
||
function reasons(items?: { code: string | null; reason: string }[] | null): string[] {
|
||
return (items ?? []).map((item) => (item.code ? `${item.code} ${item.reason}` : item.reason));
|
||
}
|
||
|
||
/** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */
|
||
function warning(title: string, items: string[]): HTMLElement | null {
|
||
if (!items.length) return null;
|
||
const element = document.createElement("p");
|
||
element.className = "b08-grid__caption b08-grid__caption--warn";
|
||
element.textContent = `${title}: ${items.join(" · ")}`;
|
||
return element;
|
||
}
|
||
|
||
/** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */
|
||
const SUPPLY_OPTIONS = [
|
||
// ⚠ 「미분류」로만 적으면 미완성으로 읽힌다 — **발주기관이 정할 자리**임을 표기에 담는다.
|
||
{ value: "unknown", label: "미정(발주기관 결정)" },
|
||
{ value: "contractor_supplied", label: "사급" },
|
||
{ value: "owner_supplied", label: "관급" },
|
||
];
|
||
const INSTALL_BY_OPTIONS = [
|
||
{ value: "", label: "미지정" },
|
||
{ value: "contractor", label: "도급자설치" },
|
||
{ value: "owner", label: "관 직접설치" },
|
||
];
|
||
|
||
export interface SupplyChoice {
|
||
supply: string;
|
||
install_by: string | null;
|
||
}
|
||
|
||
export interface MaterialGridOptions {
|
||
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
|
||
choices: Record<string, SupplyChoice>;
|
||
onChange: () => void;
|
||
}
|
||
|
||
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
|
||
function choiceCell(
|
||
value: string,
|
||
options: { value: string; label: string }[],
|
||
disabled: boolean,
|
||
onChange: (value: string) => void,
|
||
): HTMLTableCellElement {
|
||
const td = document.createElement("td");
|
||
const select = document.createElement("select");
|
||
select.className = "b08-grid__select";
|
||
for (const option of options) {
|
||
const element = document.createElement("option");
|
||
element.value = option.value;
|
||
element.textContent = option.label;
|
||
select.append(element);
|
||
}
|
||
select.value = value;
|
||
select.disabled = disabled;
|
||
select.addEventListener("change", () => {
|
||
onChange(select.value);
|
||
td.classList.add("is-changed");
|
||
});
|
||
td.append(select);
|
||
return td;
|
||
}
|
||
|
||
/** 값의 크기 요약 — 자릿수가 어긋난 것은 사람이 훑어야 보인다. */
|
||
function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTMLElement | null {
|
||
const units = Object.keys(spread || {});
|
||
if (!units.length) return null;
|
||
const element = document.createElement("p");
|
||
element.className = "b08-grid__caption";
|
||
element.textContent =
|
||
title +
|
||
" " +
|
||
units
|
||
.map((unit) => {
|
||
const s = spread[unit];
|
||
return `${unit} 최소 ${num(s.min, 2)} · 중앙 ${num(s.median, 2)} · 최대 ${num(s.max, 2)}`;
|
||
})
|
||
.join(" / ");
|
||
return element;
|
||
}
|
||
|
||
/** 자재총괄표 — 할증이 붙는 유일한 자리. */
|
||
export function renderMaterialGrid(
|
||
table: MaterialTable,
|
||
options?: MaterialGridOptions,
|
||
sheet?: ProvenanceSheet,
|
||
): HTMLElement {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "b08-grid";
|
||
|
||
const caption = document.createElement("p");
|
||
caption.className = "b08-grid__caption";
|
||
const edition = table.surcharge_dataset?.effective_date || "판 미상";
|
||
caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`;
|
||
wrap.append(caption);
|
||
|
||
const spread = spreadLine(table.amount_spread, "물량 크기:");
|
||
if (spread) wrap.append(spread);
|
||
|
||
for (const notice of [
|
||
warning("⚠ 중복 할증 위험", table.double_count_warnings),
|
||
warning("할증률 미확보", table.missing_rate_materials),
|
||
warning("관급/사급 미정 — 발주기관이 정하는 자리입니다", table.missing_supply_materials),
|
||
warning("설치 주체 미지정(관급)", table.missing_install_by_materials),
|
||
]) {
|
||
if (notice) wrap.append(notice);
|
||
}
|
||
|
||
if (!table.rows.length) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "b08-quantity__message";
|
||
empty.textContent = "구조물에서 나온 자재가 없음 — 구조물을 먼저 배치할 것";
|
||
wrap.append(empty);
|
||
return wrap;
|
||
}
|
||
|
||
const scroller = document.createElement("div");
|
||
scroller.className = "b08-grid__scroll";
|
||
const element = document.createElement("table");
|
||
element.className = "b08-grid__table b08-grid__table--summary";
|
||
element.append(headRow(table.columns));
|
||
|
||
const body = document.createElement("tbody");
|
||
for (const row of table.rows) {
|
||
const tr = document.createElement("tr");
|
||
tr.append(textCell(row.name, "b08-grid__station"));
|
||
tr.append(textCell(row.unit, "b08-grid__unit"));
|
||
tr.append(textCell(num(row.net_amount, 2)));
|
||
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
|
||
tr.append(textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0)));
|
||
tr.append(textCell(num(row.total_amount, 2)));
|
||
if (options) {
|
||
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
|
||
const chosen = options.choices[row.name] ?? {
|
||
supply: row.supply,
|
||
install_by: row.install_by,
|
||
};
|
||
const installCell = choiceCell(
|
||
chosen.install_by ?? "",
|
||
INSTALL_BY_OPTIONS,
|
||
chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다
|
||
(value) => {
|
||
const current = options.choices[row.name] ?? chosen;
|
||
options.choices[row.name] = { supply: current.supply, install_by: value || null };
|
||
options.onChange();
|
||
},
|
||
);
|
||
tr.append(
|
||
choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => {
|
||
const current = options.choices[row.name] ?? chosen;
|
||
const next = {
|
||
// 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다.
|
||
supply: value,
|
||
install_by: value === "owner_supplied" ? (current.install_by ?? null) : null,
|
||
};
|
||
options.choices[row.name] = next;
|
||
// ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도
|
||
// 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리).
|
||
const select = installCell.querySelector("select") as HTMLSelectElement | null;
|
||
if (select) {
|
||
select.disabled = value !== "owner_supplied";
|
||
select.value = next.install_by ?? "";
|
||
}
|
||
options.onChange();
|
||
}),
|
||
);
|
||
tr.append(installCell);
|
||
} else {
|
||
tr.append(textCell(row.supply_label));
|
||
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;
|
||
}
|
||
|
||
/** 구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. */
|
||
export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "b08-grid";
|
||
const unit = response.unit_quantity;
|
||
|
||
const caption = document.createElement("p");
|
||
caption.className = "b08-grid__caption";
|
||
caption.textContent = `구조물 ${unit.structure_count}개 · 치수는 구조물 정본(B05)에서 · 할증 전 값`;
|
||
wrap.append(caption);
|
||
|
||
// ㉢ 배합이 섞였으면 화면에도 뜬다 — 코드 검사만으로는 사람이 모른다.
|
||
const mixed = warning("⚠ 배합 성분이 섞였음(B09 일위대가와 이중계상)", unit.mix_components_found);
|
||
if (mixed) wrap.append(mixed);
|
||
const skipped = warning("건너뛴 구조물", response.skipped_structures);
|
||
if (skipped) wrap.append(skipped);
|
||
|
||
// 거푸집 사용횟수 — 값이 아니라 **몇 회짜리인지**를 알려 주는 자리(품셈 1-7-1).
|
||
const info = unit as unknown as FormworkInfo;
|
||
const reuse = warning("거푸집 사용횟수", info.formwork_notes ?? []);
|
||
if (reuse) wrap.append(reuse);
|
||
const reuseMissing = warning("사용횟수 미확보", info.formwork_reuse_missing ?? []);
|
||
if (reuseMissing) wrap.append(reuseMissing);
|
||
// ⚠ **구조물이 하나도 없으면 안 띄운다** — 「동바리 대상 없음」은 옹벽·집수정을 세어 보고
|
||
// 하는 말인데, 셀 구조물 자체가 없으면 그 말이 사실을 넘어선다(2026-09-09).
|
||
if (info.shoring && !info.shoring.applicable && unit.structure_count > 0) {
|
||
// 「없음」을 0 으로 적지 않는다 — 대상이 없는 것과 값이 0 인 것은 다르다.
|
||
const line = document.createElement("p");
|
||
line.className = "b08-grid__caption";
|
||
line.textContent = `동바리: 대상 없음 — ${info.shoring.reason.replace(/\*\*/g, "")}`;
|
||
wrap.append(line);
|
||
}
|
||
|
||
// ⚠ 값을 바꾸는 설계 조건인데 칸이 없는 것 — 「무엇을 정해야 하는지」만으로는 부족하고
|
||
// **「정하면 얼마나 달라지는지」**까지 보여야 사용자가 판단한다.
|
||
for (const choice of info.pending_choices ?? []) {
|
||
const line = document.createElement("p");
|
||
line.className = "b08-quantity__notice";
|
||
const parts = [`⚠ 미확정: ${choice.label}`];
|
||
if (choice.effect) parts.push(choice.effect.replace(/\*\*/g, ""));
|
||
if (choice.where) parts.push(`근거 ${choice.where}`);
|
||
if (choice.scope) parts.push(choice.scope.replace(/\*\*/g, ""));
|
||
line.textContent = parts.join(" · ");
|
||
wrap.append(line);
|
||
}
|
||
|
||
// ⚠ 품셈에 그 이름의 공종이 없어 **여러 공종으로 나뉘어 서는** 구조물 — 무엇으로
|
||
// 나뉘는지와 각 조각의 물량·갈래를 보인다. 코드만으로는 사람이 검증할 수 없다.
|
||
for (const group of response.composite ?? []) {
|
||
// 조각이 하나도 없으면 「묶음 공종 — 」 빈 줄이 남는다. 사유만 보이는 것이 낫다.
|
||
if (!group.parts?.length) {
|
||
const blocked = warning("⚠ 묶음을 못 세움", reasons(group.not_ready));
|
||
if (blocked) wrap.append(blocked);
|
||
continue;
|
||
}
|
||
const box = document.createElement("p");
|
||
box.className = "b08-quantity__notice";
|
||
const parts = group.parts.map((part) => {
|
||
const kind = part.kind ? `#${part.kind}` : "";
|
||
const amount =
|
||
part.quantity === null || part.quantity === undefined
|
||
? "-"
|
||
: `${num(part.quantity, 3)}${part.unit ?? ""}`;
|
||
const flag = part.not_ready ? " ⚠" : part.incomplete_note ? " ⚠부분" : "";
|
||
return `${part.name ?? part.code}${kind} ${amount}${flag}`;
|
||
});
|
||
box.textContent = `${group.name}: 묶음 공종 — ${parts.join(" · ")}`;
|
||
wrap.append(box);
|
||
const blocked = warning("⚠ 물량을 못 채운 조각", reasons(group.not_ready));
|
||
if (blocked) wrap.append(blocked);
|
||
// ⚠ 값이 있는데 일부만 선 조각 — **부분 성공이 완전 실패보다 위험하다**.
|
||
const partial = warning(
|
||
"⚠ 일부 몫이 빠진 조각",
|
||
group.parts.filter((p) => p.incomplete_note).map((p) => `${p.name}: ${p.incomplete_note}`),
|
||
);
|
||
if (partial) wrap.append(partial);
|
||
}
|
||
|
||
if (!unit.structures.length) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "b08-quantity__message";
|
||
empty.textContent = "배치된 구조물이 없음";
|
||
wrap.append(empty);
|
||
return wrap;
|
||
}
|
||
|
||
const scroller = document.createElement("div");
|
||
scroller.className = "b08-grid__scroll";
|
||
const element = document.createElement("table");
|
||
element.className = "b08-grid__table b08-grid__table--summary";
|
||
element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거", "출처"]));
|
||
|
||
const body = document.createElement("tbody");
|
||
for (const structure of unit.structures) {
|
||
// 규격 + **구간 표기** — 구간은 길이와 같은 값에서 나오므로 둘이 갈릴 수 없다
|
||
// (`length_m` 은 겹침을 지운 구간 목록의 합, 2026-09-09 확인).
|
||
const span = stationRange(structure.start_m, structure.end_m);
|
||
const spec =
|
||
`H=${num(structure.height_m, 1)} · L=${num(structure.length_m, 1)}m` +
|
||
(span ? ` · ${span}` : "");
|
||
if (!structure.components.length) {
|
||
const tr = document.createElement("tr");
|
||
tr.append(textCell(structure.name, "b08-grid__station"));
|
||
tr.append(textCell(spec));
|
||
const note = textCell(structure.notes.join(" · "), "b08-grid__note");
|
||
note.colSpan = 5;
|
||
tr.append(note);
|
||
body.append(tr);
|
||
continue;
|
||
}
|
||
let first = true;
|
||
for (const component of structure.components) {
|
||
const tr = document.createElement("tr");
|
||
// 같은 구조물이 이어지면 이름을 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다.
|
||
tr.append(textCell(first ? structure.name : "", "b08-grid__station"));
|
||
tr.append(textCell(first ? spec : ""));
|
||
first = false;
|
||
tr.append(textCell(component.name));
|
||
tr.append(textCell(component.unit, "b08-grid__unit"));
|
||
tr.append(textCell(num(component.amount, 3)));
|
||
tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination));
|
||
tr.append(textCell(component.basis, "b08-grid__note"));
|
||
// ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야
|
||
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
|
||
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;
|
||
}
|