Files
Aislo/B09_Estimation/B09_Estimation_UI_BaseData.ts
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

1162 lines
40 KiB
TypeScript
Raw Permalink 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.
/* =============================================================================
* B09_Estimation_UI_BaseData.ts
* 기초자료 탭 · 중기 탭 — 목록표 넷을 그린다 (사용자 확정 12번 「내야 할 표 16개 전체」).
*
* 기초자료 탭 : 노무비목록표 · 재료비목록표 · 경비목록표
* 중기 탭 : 중기목록표 (합계 + 노무·재료·경비 3분할)
*
* 서식은 지어내지 않았다 — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를
* 그대로 옮겼다. 칸 이름·차례가 그 시트와 같다.
*
* 화면 조립부(`B09_Estimation_UI_Page.ts`)가 이미 700줄을 크게 넘어 여기로 뺐다.
* 표를 그리는 일만 하고 **상태를 들지 않는다** — 부르는 쪽이 자료를 넘긴다.
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
attachProvenance,
markProvenanceCell,
type ProvenancePayload,
type ProvenanceSheet,
} from "@ui/ui_template_provenance";
import { API_BASE_URL } from "@config/config_frontend";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 목록표 한 줄 — 실무 시트 칸 그대로. */
export interface BaseDataRow {
code: string;
name: string;
spec: string;
unit: string;
unit_price_krw: string | null;
note: string;
}
/** 중기목록표 한 줄 — 합계와 3분할을 함께 보인다. */
export interface MachineRow {
code: string;
name: string;
spec: string;
unit: string;
total_krw: string | null;
labor_krw: string | null;
material_krw: string | null;
expense_krw: string | null;
note: string;
}
export interface BaseDataDto {
status: string;
labor: BaseDataRow[];
material: BaseDataRow[];
expense: BaseDataRow[];
machine: MachineRow[];
/** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */
provenance?: ProvenancePayload;
}
export async function fetchBaseData(projectId: string): Promise<BaseDataDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/base-data`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`base-data ${response.status}`);
return (await response.json()) as BaseDataDto;
}
function money(value: string | null): string {
if (value === null || value === "") return "";
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value;
}
function head(text: string): HTMLElement {
const el = document.createElement("div");
el.className = "b09-hint";
el.style.fontWeight = "600";
el.textContent = text;
return el;
}
function note(text: string): HTMLElement {
const el = document.createElement("div");
el.className = "b09-hint";
el.textContent = text;
return el;
}
/**
* 표 한 장.
*
* `keys`·`sheet` 를 함께 주면 칸마다 근거 호버가 붙는다 — **사전이 없으면 아무 일도
* 안 한다**(빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다). 안 주는 표는 종전 그대로다.
*/
function table(
headers: string[],
rows: string[][],
leftCols: number[],
keys?: string[],
sheet?: ProvenanceSheet,
): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
headers.forEach((text, index) => {
const th = document.createElement("th");
th.textContent = text;
if (leftCols.includes(index)) th.className = "b09-left";
headRow.append(th);
});
thead.append(headRow);
const tbody = document.createElement("tbody");
for (const cells of rows) {
const tr = document.createElement("tr");
cells.forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
if (leftCols.includes(index)) td.className = "b09-left";
const key = keys?.[index];
const column = key ? sheet?.columns[key] : undefined;
if (key && column) markProvenanceCell(td, key, column.tier);
tr.append(td);
});
tbody.append(tr);
}
el.append(thead, tbody);
attachProvenance(el, sheet);
return el;
}
/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */
function catalogTable(rows: BaseDataRow[], sheet?: ProvenanceSheet): HTMLElement {
return table(
["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"],
rows.map((row) => [
row.code,
row.name,
row.spec,
row.unit,
money(row.unit_price_krw),
row.note,
]),
[0, 1, 2, 5],
["code", "name", "spec", "unit", "unit_price_krw", "note"],
sheet,
);
}
/**
* 기초자료 탭 — 목록표 셋.
*
* ⚠ 표가 비거나 한 줄뿐일 때 **그냥 두지 않는다** — 「다 채운 것」으로 읽히기 때문이다.
* 재료비목록표가 지금 그 자리다(사급 자재 카탈로그가 아직 안 섰다).
*/
export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void {
const groups: Array<[string, BaseDataRow[], string]> = [
["노무비목록표", data.labor, ""],
[
"재료비목록표",
data.material,
data.material.length <= 1
? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다."
: "",
],
["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."],
];
for (const [title, rows, hint] of groups) {
body.append(head(`${title} (${rows.length})`));
if (hint) body.append(note(hint));
if (rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
continue;
}
body.append(catalogTable(rows, data.provenance?.sheets?.catalog));
}
}
/** 중기 탭 — 중기목록표. 합계와 3분할을 함께 보인다(실무 시트와 같은 칸). */
export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
body.append(head(`중기목록표 (${data.machine.length})`));
if (data.machine.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
return;
}
body.append(
table(
["코드번호", "명 칭", "규 격", "단위", "합 계", "노 무 비", "재 료 비", "경 비", "비 고"],
data.machine.map((row) => [
row.code,
row.name,
row.spec,
row.unit,
money(row.total_krw),
money(row.labor_krw),
money(row.material_krw),
money(row.expense_krw),
row.note,
]),
[0, 1, 2, 8],
[
"code",
"name",
"spec",
"unit",
"total_krw",
"labor_krw",
"material_krw",
"expense_krw",
"note",
],
data.provenance?.sheets?.machine,
),
);
// ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다.
body.append(
note(
"조종원 노임은 「노임 ÷ 8시간 × 16/12 × 25/20」(약 1.667배)으로 셉니다. " +
"공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 계상해야 " +
"합니다(건협 임금적용요령 4-나 · 기재부 정부 입찰·계약 집행기준 제76조의3). " +
"⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따랐습니다 — 실무 두 공사지· " +
"임도교본 예제·상용 적산 프로그램이 모두 같은 계수를 씁니다.",
),
);
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다."));
}
/* =============================================================================
* 설계서 구성표 — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)).
* ⚠ 「없음」과 「우리 몫 아님」을 갈라 보인다 — 갈라야 다음에 할 일이 달라진다.
* ========================================================================== */
export interface DesignDocDto {
status: string;
law: string;
summary: string;
counts: Record<string, number>;
items: Array<{
order: number;
name: string;
status: string;
owner: string;
where: string;
note: string;
}>;
notes: string[];
}
export async function fetchDesignDocIndex(projectId: string): Promise<DesignDocDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/design-doc-index`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`design-doc-index ${response.status}`);
return (await response.json()) as DesignDocDto;
}
export function drawDesignDocTab(body: HTMLElement, data: DesignDocDto): void {
body.append(head(`설계서 구성 (법이 정한 ${data.items.length})`));
body.append(note(data.summary));
body.append(note(data.law));
body.append(
table(
["차례", "이 름", "상 태", "누가 만드나", "어디서 나오나", "비 고"],
data.items.map((row) => [
String(row.order),
row.name,
row.status,
row.owner,
row.where || "—",
row.note,
]),
[0, 1, 2, 3, 4, 5],
),
);
for (const line of data.notes) body.append(note(line));
}
/* =============================================================================
* 각종 중기경비계산서 — 기종마다 한 장(별표2 (5)(가) 아홉째).
* ⚠ 목록표가 「얼마」라면 이 장은 **왜 그 값인가**다. 계산 과정을 감추지 않는다.
* ========================================================================== */
export interface MachineExpenseDto {
status: string;
summary: string;
notes: string[];
sheets: Array<{
machine_code: string;
name: string;
spec: string;
price_thousand_krw: string | null;
economic_life_hours: number | null;
annual_standard_hours: number | null;
depreciation_coefficient: number | null;
maintenance_coefficient: number | null;
management_coefficient: number | null;
loss_coefficient: number | null;
loss_krw_per_hour: string | null;
fuel_liters_per_hour: string | null;
fuel_price_per_liter: string | null;
fuel_scope: string;
misc_material_percent: string | null;
operator_code: string;
operator_daily_wage: string | null;
operator_krw_per_hour: string | null;
material_krw: string | null;
labor_krw: string | null;
expense_krw: string | null;
total_krw: string | null;
variant: string;
attachment: boolean;
attachment_note: string;
gaps: string[];
}>;
}
export async function fetchMachineExpense(projectId: string): Promise<MachineExpenseDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/machine-expense`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`machine-expense ${response.status}`);
return (await response.json()) as MachineExpenseDto;
}
/** 기종 한 장 — 손료·운전경비·시간당 사용료를 차례로. */
function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLElement {
const box = document.createElement("div");
box.className = "b09-panel__group";
const title = document.createElement("p");
title.className = "b09-panel__legend";
title.textContent =
`${sheet.machine_code} ${sheet.name} ${sheet.spec}`.trim() +
(sheet.variant ? ` — ${sheet.variant}` : "");
box.append(title);
const coefficient = (value: number | null) => (value === null ? "—" : String(value));
box.append(
table(
["구 분", "내 용", "값"],
[
["① 손료", "취득가격(천원)", money(sheet.price_thousand_krw)],
[
"",
"내용시간 / 연간표준가동시간",
`${coefficient(sheet.economic_life_hours)} / ${coefficient(sheet.annual_standard_hours)}`,
],
[
"",
"상각비·정비비·관리비 계수 (10⁻⁷)",
`${coefficient(sheet.depreciation_coefficient)} + ${coefficient(sheet.maintenance_coefficient)} + ${coefficient(sheet.management_coefficient)} = ${coefficient(sheet.loss_coefficient)}`,
],
["", "시간당 손료(원)", money(sheet.loss_krw_per_hour)],
[
"② 운전경비",
`주연료(L/hr) × 유가(${sheet.fuel_scope})`,
`${sheet.fuel_liters_per_hour ?? "—"} × ${money(sheet.fuel_price_per_liter)}`,
],
["", "잡재료(주연료의 %)", sheet.misc_material_percent ?? "—"],
[
"",
`조종원(${sheet.operator_code || "—"}) 일당 → 시간당`,
`${money(sheet.operator_daily_wage)}${money(sheet.operator_krw_per_hour)}`,
],
[
"③ 시간당 사용료",
"재료비 / 노무비 / 경비",
`${money(sheet.material_krw)} / ${money(sheet.labor_krw)} / ${money(sheet.expense_krw)}`,
],
["", "합 계", money(sheet.total_krw)],
],
[0, 1],
),
);
if (sheet.variant) {
box.append(
note(
"같은 기종이라도 조합 사용이면 잡재료가 16% 로 줄어 재료비가 달라집니다 —" +
" 그래서 층이 따로 섭니다(건설품셈 제8장 [주]⑤).",
),
);
}
if (sheet.attachment_note) box.append(note(sheet.attachment_note));
for (const gap of sheet.gaps) box.append(note(`⚠ ${gap}`));
return box;
}
export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto): void {
body.append(head(`각종 중기경비계산서 (${data.sheets.length})`));
body.append(note(data.summary));
for (const line of data.notes) body.append(note(line));
for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet));
}
/* =============================================================================
* 산출기초 — 줄에 달린 근거를 한 장으로 모은 장(별표2 (5)(가) 열셋째).
* ⚠ 여기서 값을 다시 계산하지 않는다 — 모으기만 한다.
* ========================================================================== */
export interface BasisSheetDto {
status: string;
provenance?: ProvenancePayload;
note: string;
summary: string;
dataset_versions: Array<{
dataset_id: string;
file: string;
effective_date: string;
sha256: string;
}>;
chosen_conditions: Array<{ item: string; value: string }>;
work_items: Array<{ code: string; name: string; unit: string; notes: string[] }>;
gaps: Array<{ kind: string; code: string; reason: string }>;
}
export async function fetchBasisSheet(projectId: string): Promise<BasisSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/basis-sheet`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`basis-sheet ${response.status}`);
return (await response.json()) as BasisSheetDto;
}
export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
body.append(head("산출기초"));
body.append(note(data.summary));
body.append(note(data.note));
body.append(head(`① 어느 판으로 계산했나 (${data.dataset_versions.length})`));
body.append(
table(
["자료", "파일", "기준일", "지문(앞 12)"],
data.dataset_versions.map((row) => [
row.dataset_id,
row.file,
row.effective_date,
row.sha256 || "—",
]),
[0, 1, 2, 3],
["dataset_id", "file", "effective_date", "sha256"],
data.provenance?.sheets?.basis_versions,
),
);
body.append(head(`② 무엇을 골랐나 (${data.chosen_conditions.length})`));
if (data.chosen_conditions.length === 0) {
body.append(note("고른 값이 없습니다 — 전부 확정 기본값으로 돌고 있습니다."));
} else {
body.append(
table(
["항 목", "고른 값"],
data.chosen_conditions.map((row) => [row.item, row.value]),
[0, 1],
["item", "value"],
data.provenance?.sheets?.basis_chosen,
),
);
}
body.append(head(`③ 공종마다 무엇을 근거로 했나 (${data.work_items.length})`));
body.append(
table(
["코드", "공 종", "단위", "근 거"],
data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]),
[0, 1, 2, 3],
["code", "name", "unit", "notes"],
data.provenance?.sheets?.basis_items,
),
);
body.append(head(`④ 못 채운 자리 (${data.gaps.length})`));
if (data.gaps.length === 0) {
body.append(note("못 채운 자리가 없습니다."));
} else {
body.append(
table(
["갈 래", "코드", "사 유"],
data.gaps.map((row) => [row.kind, row.code, row.reason]),
[0, 1, 2],
["kind", "code", "reason"],
data.provenance?.sheets?.basis_gaps,
),
);
body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다."));
}
}
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
export function drawBaseDataError(body: HTMLElement): void {
body.append(note(L("B09_Estimation_Tab_Pending")));
}
/* =============================================================================
* 자재단가대비표(A9) · 환율및기초자료(A10) — 사용자 확정 ③·⑮ 와 한 벌.
*
* 표를 냈는데 화면에 없으면 **낸 것이 아니다.** 그래서 기초자료 탭 아래에 붙인다.
* 서식은 실무 「자재단가대비표」·「환율및기초자료」 시트를 그대로 옮겼다 —
* 원천마다 **단가·페이지** 두 칸이 서고, 채택한 원천에 표시가 붙는다.
* ========================================================================== */
/** 원천 한 칸 — 값이 없으면 **빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다. */
export interface PriceSlot {
name: string;
price_krw: string | null;
/** 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜(확정 ③). */
source_note: string;
adopted: boolean;
}
export interface MaterialComparisonRow {
code: string;
name: string;
spec: string;
unit: string;
slots: PriceSlot[];
adopted_slot: number;
adopted_price_krw: string | null;
note: string;
}
export interface FuelScope {
key: string;
label: string;
available: boolean;
why?: string;
}
export interface PriceSourcesDto {
status: string;
provenance?: ProvenancePayload;
material_comparison: {
slot_names: string[];
rows: MaterialComparisonRow[];
notes: string[];
};
base_reference: {
exchange: { rows: unknown[]; note: string };
labor: {
rows: Array<{
code: string;
name: string;
day_wage_krw: string | null;
hourly_krw: string | null;
formula: string;
}>;
note: string;
};
fuel: {
diesel_krw_per_l: string | null;
scope: string;
effective_date: string;
dataset_id: string;
scopes: FuelScope[];
/** 고를 수 있는 시도 — 판에 있는 것만. 값을 함께 실어 고르기 전에 견줄 수 있다. */
regions: Array<{ code: string; name: string; diesel_krw_per_l: string | null }>;
region: string;
region_name: string;
region_missing?: string;
note: string;
};
};
}
export async function fetchPriceSources(projectId: string): Promise<PriceSourcesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/price-sources`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`price-sources ${response.status}`);
return (await response.json()) as PriceSourcesDto;
}
/**
* 자재단가대비표 — 원천마다 **단가·페이지** 두 칸이라 머리글이 두 줄이다.
* 채택한 원천 칸에 표시를 넣어 「어느 것을 썼나」가 한눈에 보이게 한다.
*/
function comparisonTable(
slotNames: string[],
rows: MaterialComparisonRow[],
sheet?: ProvenanceSheet,
): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
const thead = document.createElement("thead");
const top = document.createElement("tr");
const bottom = document.createElement("tr");
// ⚠ 슬롯 6 이 곧 「적용 단가」다(`JUKNM=6`). 그 자리에 「적 용」 칸을 또 세우면
// 같은 값이 두 번 선다 — 실무 시트도 원천 다섯 + 적용 하나로 끝난다.
const appliedIsLastSlot =
rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length);
const trailing = appliedIsLastSlot ? [] : ["적 용"];
const fixed = ["코드번호", "명 칭", "규 격", "단위"];
fixed.forEach((text, index) => {
const th = document.createElement("th");
th.textContent = text;
th.rowSpan = 2;
if (index <= 2) th.className = "b09-left";
top.append(th);
});
for (const name of [...slotNames, ...trailing]) {
const th = document.createElement("th");
th.textContent = name;
th.colSpan = 2;
top.append(th);
for (const sub of ["단 가", "페이지"]) {
const cell = document.createElement("th");
cell.textContent = sub;
bottom.append(cell);
}
}
const noteHead = document.createElement("th");
noteHead.textContent = "비 고";
noteHead.rowSpan = 2;
noteHead.className = "b09-left";
top.append(noteHead);
thead.append(top, bottom);
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
/** 칸 하나 — `key` 를 주면 근거 호버가 붙는다(사전에 없는 열은 아무 일도 안 한다). */
const put2 = (td: HTMLElement, key: string, tier?: string): void => {
const column = sheet?.columns[key];
if (column) markProvenanceCell(td, key, tier ?? column.tier);
};
const put = (text: string, left = false, key?: string): void => {
const td = document.createElement("td");
td.textContent = text;
if (left) td.className = "b09-left";
if (key) put2(td, key);
tr.append(td);
};
put(row.code, true, "code");
put(row.name, true, "name");
put(row.spec, true, "spec");
put(row.unit, false, "unit");
for (const slot of row.slots) {
const td = document.createElement("td");
td.textContent = money(slot.price_krw);
// 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다.
if (slot.adopted) td.style.fontWeight = "700";
// ⚠ **빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」** — 막힌 자리로 표시한다.
put2(td, "slot_price", slot.price_krw === null ? "blocked" : undefined);
tr.append(td);
const page = document.createElement("td");
page.textContent = slot.source_note;
page.className = "b09-left";
put2(page, "slot_page");
tr.append(page);
}
if (!appliedIsLastSlot) {
put(money(row.adopted_price_krw), false, "adopted_price_krw");
put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true);
}
put(row.note, true, "note");
tbody.append(tr);
}
el.append(thead, tbody);
attachProvenance(el, sheet);
return el;
}
/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */
function baseReferenceSections(
body: HTMLElement,
data: PriceSourcesDto["base_reference"],
projectId: string,
reload: () => void,
laborSheet?: ProvenanceSheet,
fuelSheet?: ProvenanceSheet,
): void {
body.append(head("환율및기초자료 — ① 환율"));
body.append(note(data.exchange.note));
body.append(head(`환율및기초자료 — ② 인건비 (${data.labor.rows.length})`));
if (data.labor.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(
table(
["코드번호", "직 종", "일 당", "시간당", "산 식"],
data.labor.rows.map((row) => [
row.code,
row.name,
money(row.day_wage_krw),
money(row.hourly_krw),
row.formula,
]),
[0, 1, 4],
["code", "name", "day_wage_krw", "hourly_krw", "formula"],
laborSheet,
),
);
}
// 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다.
body.append(
note(
"시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " +
"조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.",
),
);
body.append(note(data.labor.note));
body.append(head("환율및기초자료 — ③ 단가 및 재료비"));
const fuel = data.fuel;
body.append(
table(
["항 목", "단 가", "적용 범위", "기준일", "자료"],
[
[
"경유",
money(fuel.diesel_krw_per_l),
fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope,
fuel.effective_date,
fuel.dataset_id,
],
],
[0, 2, 3, 4],
["item", "price_krw", "scope", "effective_date", "dataset_id"],
fuelSheet,
),
);
// ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고
// 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다.
// ⚠ 시도가 들어온 뒤로는 **한 칸에서 전국과 시도를 함께** 고른다 — 범위 칸과 시도 칸을
// 따로 두면 「지역인데 시도를 안 고른 상태」가 생겨 무슨 값으로 섰는지 흐려진다.
const picker = document.createElement("div");
picker.className = "b09-hint";
picker.style.display = "flex";
picker.style.alignItems = "center";
picker.style.gap = "8px";
picker.style.flexWrap = "wrap";
const label = document.createElement("span");
label.textContent = "유가 적용 범위";
const select = document.createElement("select");
const national = document.createElement("option");
national.value = "";
national.textContent = "전국 공시가";
national.selected = !fuel.region;
select.append(national);
for (const region of fuel.regions) {
const option = document.createElement("option");
option.value = region.code;
option.textContent = `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`;
option.selected = region.code === fuel.region;
select.append(option);
}
select.disabled = fuel.regions.length === 0;
select.addEventListener("change", () => {
void saveFactorChoices(projectId, { fuel_region: select.value })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
});
picker.append(label, select);
body.append(picker);
if (fuel.regions.length === 0) {
for (const scope of fuel.scopes) {
if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`));
}
} else {
body.append(
note(
fuel.region
? `${fuel.region_name} 공시가로 서 있습니다 — 기계 연료비가 그 값으로 다시 섭니다.`
: "전국 공시가로 서 있습니다 — 현장 시도를 고르면 그 지역 값으로 바뀝니다.",
),
);
}
if (fuel.region_missing) body.append(note(`⚠ ${fuel.region_missing}`));
body.append(note(fuel.note));
}
/** 기초자료 탭 아래쪽 — A9·A10 두 장. */
export function drawPriceSourcesSections(
body: HTMLElement,
data: PriceSourcesDto,
projectId: string,
reload: () => void,
): void {
const comparison = data.material_comparison;
body.append(head(`자재단가대비표 (${comparison.rows.length})`));
if (comparison.rows.length <= 1) {
body.append(
note(
"⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.",
),
);
}
if (comparison.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(
comparisonTable(
comparison.slot_names,
comparison.rows,
data.provenance?.sheets?.material_comparison,
),
);
}
for (const text of comparison.notes) body.append(note(text));
baseReferenceSections(
body,
data.base_reference,
projectId,
reload,
data.provenance?.sheets?.base_reference_labor,
data.provenance?.sheets?.base_reference_fuel,
);
}
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
export function drawPriceSourcesPending(body: HTMLElement): void {
body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
}
/* =============================================================================
* 산출 조건 — 품셈이 범위로 준 계수·장비 규격 (사용자 확정 ① 딸림 지시, 2026-09-09)
*
* 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」
* 라는 지시 그대로다. 고를 수 있는 것은 **원문에 적힌 값뿐**이고, 왜 그 값인지를
* 칸 밑에 그대로 적는다.
* ========================================================================== */
export interface FactorOption {
key: string;
value?: string;
label: string;
note?: string;
}
export interface RangeFactorRow {
key: string;
work_item_code: string;
work_item_name: string;
factor: string;
raw_cell: string;
chosen: string;
value: string;
is_default: boolean;
options: FactorOption[];
basis: string[];
}
export interface MachineChoiceRow {
work_item_code: string;
work_item_name: string;
chosen: string;
default: string;
is_default: boolean;
source: string;
options: FactorOption[];
basis: string[];
}
/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙는다(산림품셈 1-2-6). */
export interface MiscMaterialRow {
percent: string;
min: string;
max: string;
basis: string[];
/** 주재료비가 선 일위대가 수 — 0 이면 넣어도 붙을 밑수가 없다. */
base_items: number;
base_note: string;
}
/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 선다(산림품셈 10-4). */
export interface TransportRow {
distance_km: string;
road: string;
roads: Array<{ key: string; label: string }>;
variants: Array<{ key: string; label: string; unit_price_krw: string }>;
basis: string[];
notes: string[];
}
/** 품의 할인·할증 26계열 — 안 고르면 안 붙는다(산림품셈 1-4). */
export interface LaborSurchargeRow {
chosen: Record<string, string>;
total_percent: string;
reasons: string[];
series: Array<{
key: string;
title: string;
section: string;
source_note: string;
options: FactorOption[];
}>;
basis: string[];
}
export interface FactorChoicesDto {
status: string;
ranges: RangeFactorRow[];
machines: MachineChoiceRow[];
misc_material?: MiscMaterialRow;
transport?: TransportRow;
labor_surcharge?: LaborSurchargeRow;
notes: string[];
}
export async function fetchFactorChoices(projectId: string): Promise<FactorChoicesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`factors ${response.status}`);
return (await response.json()) as FactorChoicesDto;
}
export async function saveFactorChoices(
projectId: string,
body: {
range_factor_choices?: Record<string, string>;
machine_choices?: Record<string, string>;
misc_material_percent?: string;
fuel_region?: string;
transport_distance_km?: string;
transport_road?: string;
labor_surcharge?: Record<string, string>;
},
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const message = await response
.json()
.then((body: { message?: string }) => body.message ?? "")
.catch(() => "");
throw new Error(message || `factors save ${response.status}`);
}
}
/**
* 숫자 칸 하나 — **빈 칸이 기본**이다. [적용]을 눌러야 저장된다.
*
* ⚠ 고르는 칸(`picker`)과 달리 여기는 **사용자가 값을 짓는 자리**라 누를 때만 보낸다 —
* 타자 한 자마다 보내면 「2」를 치는 도중에 2% 로 저장돼 버린다.
*/
function percentBox(
label: string,
value: string,
placeholder: string,
onApply: (text: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-hint";
wrap.style.display = "flex";
wrap.style.alignItems = "center";
wrap.style.gap = "8px";
wrap.style.flexWrap = "wrap";
const name = document.createElement("span");
name.style.fontWeight = "600";
name.textContent = label;
const input = document.createElement("input");
input.type = "number";
input.step = "0.1";
input.min = "0";
input.value = value;
input.placeholder = placeholder;
input.style.width = "72px";
const unit = document.createElement("span");
unit.textContent = "%";
const apply = document.createElement("button");
apply.type = "button";
apply.textContent = "적용";
apply.addEventListener("click", () => onApply(input.value.trim()));
wrap.append(name, input, unit, apply);
return wrap;
}
function picker(
label: string,
options: FactorOption[],
chosen: string,
onPick: (key: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-hint";
wrap.style.display = "flex";
wrap.style.alignItems = "center";
wrap.style.gap = "8px";
wrap.style.flexWrap = "wrap";
const name = document.createElement("span");
name.style.fontWeight = "600";
name.textContent = label;
const select = document.createElement("select");
for (const option of options) {
const item = document.createElement("option");
item.value = option.key;
item.textContent = option.label;
item.selected = option.key === chosen;
select.append(item);
}
select.addEventListener("change", () => onPick(select.value));
wrap.append(name, select);
return wrap;
}
/**
* 산출 조건 구역 — 기초자료 탭 맨 위에 선다.
*
* ⚠ **기본값으로 돌고 있음을 숨기지 않는다** — 조용히 기본으로 돌면 사용자는 그것이
* 잠정인 줄도 모른다(타설 방식에서 이미 겪은 자리).
*/
export function drawFactorChoices(
body: HTMLElement,
data: FactorChoicesDto,
projectId: string,
reload: () => void,
): void {
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
for (const row of data.ranges) {
const title = `${row.work_item_name} 작업효율(${row.factor})`;
body.append(
picker(title, row.options, row.chosen, (key) => {
void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then(
reload,
);
}),
);
body.append(
note(
`품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` +
(row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"),
),
);
for (const line of row.basis) body.append(note(line));
}
for (const row of data.machines) {
body.append(
picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => {
void saveFactorChoices(projectId, {
machine_choices: { [row.work_item_code]: key },
}).then(reload);
}),
);
body.append(
note(
row.source === "note"
? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다."
: "품셈 표가 정한 장비입니다." +
(row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."),
),
);
for (const line of row.basis) body.append(note(line));
}
const misc = data.misc_material;
if (misc) {
body.append(
percentBox("공구손료·잡재료 (주재료비의)", misc.percent, "비움", (text) => {
void saveFactorChoices(projectId, { misc_material_percent: text })
.then(reload)
.catch((error: Error) => {
body.append(note(`⚠ ${error.message}`));
});
}),
);
body.append(
note(
misc.percent
? `지금 ${misc.percent}% 로 붙고 있습니다 — 칸을 비우고 [적용]하면 도로 안 붙습니다.`
: `비어 있어 안 붙고 있습니다 — 넣을 수 있는 값은 ${misc.min}~${misc.max}% 입니다.`,
),
);
if (misc.base_note) body.append(note(misc.base_note));
for (const line of misc.basis) body.append(note(line));
}
const transport = data.transport;
if (transport) {
body.append(
percentBox(
"기계 수송 거리 (인근 시·군·구청 → 현장, 편도 ㎞)",
transport.distance_km,
"비움",
(text) => {
void saveFactorChoices(projectId, { transport_distance_km: text })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
},
),
);
const roadOptions: FactorOption[] = [
{ key: "", label: "안 고름" },
...transport.roads.map((road) => ({ key: road.key, label: road.label })),
];
body.append(
picker("수송 도로 구분", roadOptions, transport.road, (key) => {
void saveFactorChoices(projectId, { transport_road: key })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
}),
);
for (const variant of transport.variants) {
body.append(
note(
variant.unit_price_krw
? `${variant.label} — 회당 ${variant.unit_price_krw}원`
: `${variant.label} — 아직 안 섬`,
),
);
}
for (const line of transport.notes) body.append(note(`⚠ ${line}`));
for (const line of transport.basis) body.append(note(line));
body.append(
note(
"⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.",
),
);
}
const surcharge = data.labor_surcharge;
if (surcharge) {
body.append(head("품의 할인·할증 (산림품셈 1-4) — 고른 것만 붙습니다"));
body.append(
note(
Number(surcharge.total_percent) === 0
? "지금 한 계열도 안 골라 한 원도 안 움직이고 있습니다."
: `지금 ${surcharge.total_percent}% 가 품에 붙고 있습니다 — ${surcharge.reasons.join(" · ")}`,
),
);
for (const line of surcharge.basis) body.append(note(line));
for (const item of surcharge.series) {
const options: FactorOption[] = [{ key: "", label: "안 고름" }, ...item.options];
body.append(
picker(`${item.section}`, options, surcharge.chosen[item.key] ?? "", (key) => {
void saveFactorChoices(projectId, { labor_surcharge: { [item.key]: key } })
.then(reload)
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
}),
);
if (item.source_note) body.append(note(item.source_note));
}
}
for (const line of data.notes) body.append(note(line));
}