Files
Aislo/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts
T
eomsangdonandClaude Opus 5 8e78940b7a feat(B08): 거푸집 사용횟수 + 공종코드 잇기
- 거푸집 사용횟수는 **관측값이 아니라 법**임. 품셈 1-7-1 이 구조물 종류별로
  정해 둠(옹벽 3회 · 보호공 기초 6회). 원문 문구를 데이터에 싣고 우리 구조물이
  어느 예시에 걸리는지 적음. 걸리는 예시가 없으면 「사용횟수 미확보」.
- ⚠ 횟수별 재료 환산(품셈 12-4 합판 3회 46.1 %)은 **하지 않음**. 그 비율은
  일위대가 재료비에 걸리는 값이라 B08 이 곱하면 B09 와 겹쳐 두 번 줌.
  B08 이 내는 것은 접촉 면적 그대로 + 몇 회짜리인가까지. 시험으로 못 박음.
- 동바리는 슬래브를 떠받칠 때 쓰는 것이라 지금 서는 구조물(옹벽·집수정)은
  대상이 아님. 0 으로 적지 않고 「대상 없음 + 사유」로 냄.
- 거푸집 이름은 정확 일치로만 봄 — 부분일치면 「거푸집씻기」(공사용수)가 걸림.

공종코드 잇기
- 집수정 → FP-12-15, 물넘이포장 → FP-12-06 로 이음.
- 옹벽은 **품셈 12장에 그 이름의 공종이 없음**. 빈 코드로 두면 「매핑을 못 찾은
  줄」과 구별이 안 되므로 `composite` 로 묶음(타설+거푸집+철근+기초잡석)을 적음.
  일위대가 조립은 B09 몫이고 B08 은 물량과 묶음만 넘김.

검증 — 거푸집 8건 · 인계 45건 통과, 전체 558 passed. tsc 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 00:45:02 +09:00

376 lines
14 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 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다.
* ========================================================================== */
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[];
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;
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 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;
}
/** 거푸집·동바리 안내에 쓰는 값. */
export interface FormworkInfo {
formwork_notes?: string[];
formwork_reuse_missing?: string[];
shoring?: { applicable: boolean; reason: string; pending_types: 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 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,
): 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);
if (info.shoring && !info.shoring.applicable) {
// 「없음」을 0 으로 적지 않는다 — 대상이 없는 것과 값이 0 인 것은 다르다.
const line = document.createElement("p");
line.className = "b08-grid__caption";
line.textContent = `동바리: 대상 없음 — ${info.shoring.reason.replace(/\*\*/g, "")}`;
wrap.append(line);
}
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"));
// ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
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;
}