- 역산: ESTX 미공표 추정 = 마지막 공표값 × 전체직종 평균 증가율 공표일마다 이어 곱·원 미만 버림 (5 직종 원 단위 일치 · 9 직종 앞 값 없음) - 건설노임 미공표 14 줄에 준용 칸(null) · 엔진은 값이 비면 준용 직종 값 · 호표 출처 「준용: <직종>」 · M01 계산표에 표시 - 새 파일: 엔지니어링노임 59 · 측량노임 20 · 건설사업관리노임 7 · SW노임 17 · 산림노임 2 (줄 수·값 합 ESTX 와 같음) - 인력_자체 49 → 새 노임 열쇠 18 · 인력_미확보 31 · 로직 참조 526 곳 - 로직 일괄 시험 계산: 통과 1011 → 1088 · 인력 값 없음 멈춤 176 → 84 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
/* =============================================================================
|
||
* M01_MasterData_UI_Logic_Calc.ts
|
||
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
|
||
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
|
||
* ========================================================================== */
|
||
|
||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||
import {
|
||
runCalc,
|
||
type CalcAnswer,
|
||
type CalcLine,
|
||
type LogicRow,
|
||
} from "./M01_MasterData_UI_Logic_Api";
|
||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||
|
||
export interface CalcContext {
|
||
book: string;
|
||
/** 저장된 열쇠 — 새 로직은 null */
|
||
savedKey: string | null;
|
||
file: string;
|
||
row: LogicRow;
|
||
dirty: () => boolean;
|
||
/** 로직마다 넣은 값 — 다시 그려도 남음 */
|
||
values: Record<string, string>;
|
||
onLines: (lines: CalcLine[] | null) => void;
|
||
}
|
||
|
||
const SUMS = ["노무비", "재료비", "경비", "계"];
|
||
|
||
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||
const fields = (ctx.row.입력 ?? []).map((spec) => {
|
||
const name = spec.이름;
|
||
let control: HTMLInputElement | HTMLSelectElement;
|
||
if (spec.고르기?.length) {
|
||
control = el("select", { className: "m01-logic__input" });
|
||
for (const option of ["", ...spec.고르기.map(String)]) {
|
||
control.append(el("option", { text: option, attrs: { value: option } }));
|
||
}
|
||
} else {
|
||
control = el("input", {
|
||
className: "m01-logic__input",
|
||
attrs: { type: "text", inputmode: "decimal" },
|
||
});
|
||
if (spec.범위) control.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
|
||
}
|
||
control.value = ctx.values[name] ?? "";
|
||
control.addEventListener("input", () => (ctx.values[name] = control.value));
|
||
control.addEventListener("change", () => (ctx.values[name] = control.value));
|
||
const label = spec.단위 ? `${name} (${spec.단위})` : name;
|
||
return el("label", {
|
||
className: "m01-logic__field",
|
||
children: [el("span", { text: label }), control],
|
||
});
|
||
});
|
||
const out = el("div", { className: "m01-logic__calc-out" });
|
||
const run = async (): Promise<void> => {
|
||
const inputs: Record<string, unknown> = {};
|
||
for (const spec of ctx.row.입력 ?? []) {
|
||
const raw = (ctx.values[spec.이름] ?? "").trim();
|
||
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
|
||
// 고르기는 원래 값(수면 수) 그대로 — 글 "35" 로 보내면 「고르기 밖」
|
||
const option = spec.고르기?.find((o) => String(o) === raw);
|
||
inputs[spec.이름] =
|
||
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
|
||
}
|
||
const draft = ctx.dirty() || ctx.savedKey === null;
|
||
try {
|
||
const answer = await runCalc({
|
||
book: ctx.book,
|
||
key: ctx.savedKey ?? ctx.row.열쇠,
|
||
inputs,
|
||
...(draft ? { row: ctx.row, file: ctx.file } : {}),
|
||
});
|
||
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
|
||
out.replaceChildren(...answerView(answer, draft));
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||
}
|
||
};
|
||
host.replaceChildren(
|
||
el("h3", { text: tx("Calc_Title") }),
|
||
...(fields.length
|
||
? fields
|
||
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
|
||
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
|
||
out,
|
||
);
|
||
}
|
||
|
||
function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
|
||
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
|
||
if (!answer.ok) {
|
||
return [
|
||
...note,
|
||
el("div", {
|
||
className: "m01-logic__reasons",
|
||
children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: answer.reason })],
|
||
}),
|
||
];
|
||
}
|
||
const parts: HTMLElement[] = [...note];
|
||
if (answer.lines) {
|
||
const rows = answer.lines.map((line) =>
|
||
el("tr", {
|
||
children: [
|
||
el("td", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
|
||
el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }),
|
||
el("td", { className: "m01-logic__money", text: formatNumber(line.금액) }),
|
||
],
|
||
}),
|
||
);
|
||
parts.push(
|
||
el("table", {
|
||
className: "m01-logic__grid",
|
||
children: [
|
||
el("thead", {
|
||
children: [
|
||
el("tr", {
|
||
children: [tx("Head_Name"), tx("Ho_Qty"), tx("Ho_Amount")].map((h) =>
|
||
el("th", { text: h }),
|
||
),
|
||
}),
|
||
],
|
||
}),
|
||
el("tbody", { children: rows }),
|
||
],
|
||
}),
|
||
);
|
||
}
|
||
if (answer.sums) {
|
||
parts.push(
|
||
el("dl", {
|
||
className: "m01-logic__sums",
|
||
children: SUMS.flatMap((k) => [
|
||
el("dt", { text: k === "계" ? tx("Calc_Sum") : k }),
|
||
el("dd", { className: "m01-logic__money", text: formatNumber(answer.sums?.[k] ?? 0) }),
|
||
]),
|
||
}),
|
||
);
|
||
}
|
||
if (answer.result !== undefined) {
|
||
parts.push(
|
||
el("dl", {
|
||
className: "m01-logic__sums",
|
||
children: [
|
||
el("dt", { text: tx("Calc_Result") }),
|
||
el("dd", { text: formatNumber(answer.result) }),
|
||
],
|
||
}),
|
||
);
|
||
}
|
||
const middle = Object.entries(answer.middle ?? {});
|
||
if (middle.length) {
|
||
parts.push(
|
||
el("h4", { text: tx("Middle_Title") }),
|
||
el("dl", {
|
||
className: "m01-logic__sums",
|
||
children: middle.flatMap(([k, v]) => [
|
||
el("dt", { text: k }),
|
||
el("dd", { text: formatNumber(v) }),
|
||
]),
|
||
}),
|
||
);
|
||
}
|
||
return parts;
|
||
}
|