구조물 원단위의 `destination == "material"` 성분만 모아 자재별 합산 후 할증률을 한 번만 적용. 열은 순수량·할증률·합계 + 관급구분·설치주체·비고이며 금액은 없음(B09 경계). - 할증률은 코드가 아니라 데이터 — `resources/data_material_surcharge/` (품셈 1-3-1 재료 할증률 19종 + sha256 매니페스트). 실무 관측값은 `observed_practice` 로 분리(법대로 원칙). - 표에 없는 자재는 0 % 로 넘기지 않고 「할증률 미확보」로 표시. 이름 조회는 정확 일치 — 부분일치면 `막자갈` 이 `자갈` 할증을 뭄. - 이중계상 방어 ㉠ — 앞 단계 `surcharge_applied` 깃발을 실제로 읽어 경고. 자재총괄 응답은 `True`, 원단위표는 `False` 로 어느 쪽 값인지 명시. - 관급/사급 이름은 B09 와 동일(`owner_supplied`/`contractor_supplied`). 관급 줄에만 설치 주체(`install_by`)를 붙이고, 미지정은 기본값으로 때우지 않고 드러냄 — 안전관리비 대상액이 「도급자설치 관급금액」이라서임. - 라우터 `GET /quantity/material-summary` 신설, 화면에 「구조물 원단위」· 「자재총괄」 탭 추가. `design_owner` 가 붙은 타입(측구)은 중복 계상 방지로 제외. 검증 — 전용 테스트 23건 통과, 전체 회귀 473 passed(기존 B05 깨짐 1건 제외). 공용 브라우저 실조작으로 탭 5장·머리글·값 4줄·미확보 안내 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
8.5 KiB
TypeScript
233 lines
8.5 KiB
TypeScript
/* =============================================================================
|
||
* B08_Quantity_UI_MaterialGrid.ts
|
||
* 자재총괄표·구조물 원단위 그리드 (PLAN 8-2·8-6·8-7).
|
||
*
|
||
* 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다.
|
||
*
|
||
* ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은
|
||
* 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고,
|
||
* 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다.
|
||
*
|
||
* ⚠ 반올림은 여기서만 한다(PLAN 8-16 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다.
|
||
* ========================================================================== */
|
||
|
||
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<string, unknown> };
|
||
missing_rate_materials: string[];
|
||
missing_supply_materials: string[];
|
||
missing_install_by_materials: string[];
|
||
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;
|
||
notes: string[];
|
||
components: {
|
||
name: string;
|
||
unit: string;
|
||
amount: number;
|
||
destination: string;
|
||
basis: 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;
|
||
}
|
||
|
||
/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */
|
||
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 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;
|
||
}
|
||
|
||
/** 자재총괄표 — 할증이 붙는 유일한 자리. */
|
||
export function renderMaterialGrid(table: MaterialTable): 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);
|
||
|
||
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)));
|
||
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);
|
||
|
||
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) {
|
||
const spec = `H=${num(structure.height_m, 1)} · L=${num(structure.length_m, 1)}m`;
|
||
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"));
|
||
body.append(tr);
|
||
}
|
||
}
|
||
|
||
element.append(body);
|
||
scroller.append(element);
|
||
wrap.append(scroller);
|
||
return wrap;
|
||
}
|