무슨 일이 있었나 랩탑 줄의 병합 `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
1162 lines
40 KiB
TypeScript
1162 lines
40 KiB
TypeScript
/* =============================================================================
|
||
* 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));
|
||
}
|