Files
Aislo/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts
T
eomsangdonandClaude Opus 5 f952ac7ffd fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림
무슨 일이 있었나
랩탑 줄의 병합 `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
2026-09-12 18:18:57 +09:00

524 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}