Files
Aislo/B09_Estimation/B09_Estimation_UI_Factors.ts
eomsangdonandClaude Opus 5 1400b7b761 feat(b09): 기계 수송비 회수(대수 × 왕복) 설계 입력 칸 — 원문에 공식이 없어 사유와 함께 엶
산림품셈 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
2026-09-14 20:13:25 +09:00

302 lines
10 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_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));
}