산림품셈 10-4 · 건설품셈 8-1-3 은 회당 단가 산출식만 주고 대수·횟수를 정하지 않음(전수 확인). 지어내지 않고 설계 입력으로 닫음. · parse_trips — 비거나 0 이면 없음. 기본값을 두지 않음(1 회로 안 때움) · transport_amount — 회수 × 회당 단가. 회수가 없으면 None(0 원으로 안 채움) · TRIPS_NOTE — 「원문에 공식 없음 · 설계 입력 · 비우면 금액이 안 섬」 사유 · 도는 자리 넷 — 저장(PUT estimation/factors) · 산출 조건 ② · 산출 조건 화면(회당 N원 × M회) · 비면 transport_notes 에 사유가 붙어 못 채운 자리로 뜸 · 화면에 있던 「회수는 여기서 안 정합니다」 안내는 칸으로 바뀌어 걷음 잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1976 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* B09_Estimation_UI_Factors.ts
|
||
* 산출 조건 — 품셈이 범위로 준 계수·장비 규격·공구손료·수송·품 할증 (사용자 확정 ① 딸림 지시, 2026-09-09)
|
||
*
|
||
* - 옛 `B09_Estimation_UI_BaseData.ts` 의 산출 조건 구역을 그대로 옮김(PLAN 12장 옛 탭 옮기기).
|
||
* - ⚠ **계산 입력**이다 — 고르면 PUT `/estimation/factors` 로 저장되고 단가가 다시 섬.
|
||
* 고를 수 있는 것은 원문에 적힌 값뿐이고, 왜 그 값인지를 칸 밑에 그대로 적음.
|
||
* - ⚠ 기본값으로 돌고 있음을 숨기지 않음 — 조용히 기본으로 돌면 잠정인 줄도 모름.
|
||
* ========================================================================== */
|
||
|
||
import { API_BASE_URL } from "@config/config_frontend";
|
||
import { el } from "./B09_Estimation_UI_Sheet";
|
||
import { head, note } from "./B09_Estimation_UI_Table";
|
||
|
||
export interface FactorOption {
|
||
key: string;
|
||
value?: string;
|
||
label: string;
|
||
note?: string;
|
||
}
|
||
|
||
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[];
|
||
}
|
||
|
||
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). */
|
||
interface MiscMaterialRow {
|
||
percent: string;
|
||
min: string;
|
||
max: string;
|
||
basis: string[];
|
||
base_items: number;
|
||
base_note: string;
|
||
}
|
||
|
||
/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 섬(산림품셈 10-4). */
|
||
interface TransportRow {
|
||
distance_km: string;
|
||
road: string;
|
||
roads: Array<{ key: string; label: string }>;
|
||
trips: string;
|
||
trips_note: string;
|
||
variants: Array<{ key: string; label: string; unit_price_krw: string; amount_krw: string }>;
|
||
basis: string[];
|
||
notes: string[];
|
||
}
|
||
|
||
/** 품의 할인·할증 26계열 — 안 고르면 안 붙음(산림품셈 1-4). */
|
||
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 {
|
||
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;
|
||
transport_trips?: 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((payload: { message?: string }) => payload.message ?? "")
|
||
.catch(() => "");
|
||
throw new Error(message || `factors save ${response.status}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 숫자 칸 하나 — **빈 칸이 기본**. [적용]을 눌러야 저장됨.
|
||
* ⚠ 고르는 칸과 달리 사용자가 값을 짓는 자리라 누를 때만 보냄(「2」를 치는 도중에 2% 로 저장되지 않게).
|
||
*/
|
||
function percentBox(
|
||
label: string,
|
||
value: string,
|
||
placeholder: string,
|
||
onApply: (text: string) => void,
|
||
): HTMLElement {
|
||
const wrap = el("div", "b09s-hint b09s-inline");
|
||
const input = el("input");
|
||
input.type = "number";
|
||
input.step = "0.1";
|
||
input.min = "0";
|
||
input.value = value;
|
||
input.placeholder = placeholder;
|
||
input.style.width = "72px";
|
||
const apply = el("button", "", "적용");
|
||
apply.type = "button";
|
||
apply.addEventListener("click", () => onApply(input.value.trim()));
|
||
wrap.append(el("span", "b09s-head", label), input, el("span", "", "%"), apply);
|
||
return wrap;
|
||
}
|
||
|
||
export function picker(
|
||
label: string,
|
||
options: FactorOption[],
|
||
chosen: string,
|
||
onPick: (key: string) => void,
|
||
): HTMLElement {
|
||
const wrap = el("div", "b09s-hint b09s-inline");
|
||
const select = el("select");
|
||
for (const option of options) {
|
||
const item = el("option", "", option.label);
|
||
item.value = option.key;
|
||
item.selected = option.key === chosen;
|
||
select.append(item);
|
||
}
|
||
select.addEventListener("change", () => onPick(select.value));
|
||
wrap.append(el("span", "b09s-head", label), select);
|
||
return wrap;
|
||
}
|
||
|
||
/** 산출 조건 구역 — 기초자료 탭 맨 위. `reload` = 저장 뒤 다시 받아 그리기. */
|
||
export function drawFactorChoices(
|
||
body: HTMLElement,
|
||
data: FactorChoicesDto,
|
||
projectId: string,
|
||
reload: () => void,
|
||
): void {
|
||
const save = (payload: Parameters<typeof saveFactorChoices>[1]): void => {
|
||
void saveFactorChoices(projectId, payload)
|
||
.then(reload)
|
||
.catch((error: Error) => body.append(note(`⚠ ${error.message}`)));
|
||
};
|
||
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
|
||
|
||
for (const row of data.ranges) {
|
||
body.append(
|
||
picker(`${row.work_item_name} 작업효율(${row.factor})`, row.options, row.chosen, (key) =>
|
||
save({ range_factor_choices: { [row.key]: key } }),
|
||
),
|
||
);
|
||
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) =>
|
||
save({ machine_choices: { [row.work_item_code]: key } }),
|
||
),
|
||
);
|
||
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) =>
|
||
save({ misc_material_percent: text }),
|
||
),
|
||
);
|
||
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) => save({ transport_distance_km: text }),
|
||
),
|
||
);
|
||
const roadOptions: FactorOption[] = [
|
||
{ key: "", label: "안 고름" },
|
||
...transport.roads.map((road) => ({ key: road.key, label: road.label })),
|
||
];
|
||
body.append(
|
||
picker("수송 도로 구분", roadOptions, transport.road, (key) => save({ transport_road: key })),
|
||
);
|
||
body.append(
|
||
percentBox("기계 수송 회수 (대수 × 왕복)", transport.trips, "비움", (text) =>
|
||
save({ transport_trips: text }),
|
||
),
|
||
);
|
||
for (const variant of transport.variants) {
|
||
body.append(
|
||
note(
|
||
!variant.unit_price_krw
|
||
? `${variant.label} — 아직 안 섬`
|
||
: variant.amount_krw
|
||
? `${variant.label} — 회당 ${variant.unit_price_krw}원 × ${transport.trips}회 = ${variant.amount_krw}원`
|
||
: `${variant.label} — 회당 ${variant.unit_price_krw}원 (회수를 넣으면 금액이 섭니다)`,
|
||
),
|
||
);
|
||
}
|
||
for (const line of transport.notes) body.append(note(`⚠ ${line}`));
|
||
for (const line of transport.basis) body.append(note(line));
|
||
body.append(note(transport.trips_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) =>
|
||
save({ labor_surcharge: { [item.key]: key } }),
|
||
),
|
||
);
|
||
if (item.source_note) body.append(note(item.source_note));
|
||
}
|
||
}
|
||
|
||
for (const line of data.notes) body.append(note(line));
|
||
}
|