/* ============================================================================= * 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"; 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[]; } export interface MaterialTable { columns: string[]; rows: MaterialRow[]; surcharge_applied: boolean; surcharge_dataset: { effective_date: string; source: Record }; missing_rate_materials: string[]; missing_supply_materials: string[]; missing_install_by_materials: string[]; amount_spread: Record; double_count_warnings: string[]; skipped_by_destination: Record; 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; /** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */ 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 = { 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; 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, ): 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")); body.append(tr); } element.append(body); 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)); body.append(tr); } } element.append(body); scroller.append(element); wrap.append(scroller); return wrap; }