Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts
T

274 lines
11 KiB
TypeScript
Raw 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.
/* =============================================================================
* M01_MasterData_UI_Logic_Calc.ts
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchAuto,
runCalc,
runText,
searchElements,
type CalcAnswer,
type CalcLine,
type TextAnswer,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { tx } from "./M01_MasterData_UI_Logic_Text";
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
const KEY_RE = /^[A-Z]{2}\d{6}$/;
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
/** 로직마다 다시 그려도 남는 이름 캐시 — 같은 키를 두 번 묻지 않음 */
const nameCache = new Map<string, string | null>();
async function keyName(key: string): Promise<string | null> {
if (nameCache.has(key)) return nameCache.get(key)!;
const group = KEY_GROUP[key.slice(0, 2)];
if (!group) return null;
let name: string | null = null;
try {
const found = await searchElements(group, key); // q = 키 그대로 — 「키」 칸 부분일치라 그 줄만 걸림
const hit = found.items.find((it) => it.ref === key);
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
} catch {
name = null;
}
nameCache.set(key, name);
return name;
}
export interface CalcContext {
/** 저장된 키 — 새 로직은 null */
savedKey: string | null;
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
file?: string;
row: LogicRow;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines: (lines: CalcLine[] | null) => void;
/** 읽는 식 줄(`/text`)도 같이 받고 싶을 때 — 호출이 `onLines` 보다 먼저 */
onText?: (answer: TextAnswer) => void;
}
const SUMS = ["노무비", "재료비", "경비", "계"];
/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) · 견본으로 채운 입력 이름 — 사용자가 고치면 빠짐 */
const autoAsked = new WeakSet<Record<string, string>>();
const sampled = new WeakMap<Record<string, string>, Set<string>>();
/** 로직을 열 때마다 새로 부르는 미리보기 — 늦게 온 응답이 새 로직을 덮지 않게 순번으로 거름 */
let previewSeq = 0;
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const marks = sampled.get(ctx.values) ?? new Set<string>();
const edited = (name: string, tag?: HTMLElement): void => {
marks.delete(name);
tag?.remove();
};
// 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 —
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
const optionText = (o: string | number): string =>
(typeof o === "string" && nameCache.get(o)) || String(o);
const fields = (ctx.row.입력 ?? []).map((spec) => {
const name = spec.이름;
let control: HTMLElement;
const picks = spec.고르기;
const tag = marks.has(name)
? el("span", { className: "m01-logic__tag", text: tx("Calc_Sample") })
: undefined;
if (picks?.length) {
const pick = createSelectField({
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
value: ctx.values[name] ?? "",
compact: true,
onChange: (v) => {
ctx.values[name] = v;
edited(name, tag);
},
});
control = pick.root;
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
if (keys.length) {
void Promise.all(keys.map(keyName)).then(() => {
pick.setOptions(
["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
ctx.values[name] ?? "",
);
});
}
} else {
const box = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.범위) box.placeholder = `${spec.범위[0]} ${spec.범위[1]}`;
box.value = ctx.values[name] ?? "";
box.addEventListener("input", () => {
ctx.values[name] = box.value;
edited(name, tag);
});
control = box;
}
const label = spec.단위 ? `${name} (${spec.단위})` : name;
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
const wide = label.length > 10;
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), ...(tag ? [tag] : []), control],
});
});
const out = el("div", { className: "m01-logic__calc-out" });
const mySeqAtOpen = ctx.onText ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
if (ctx.onText) {
const mySeq = ++previewSeq;
const draft = ctx.dirty() || ctx.savedKey === null;
const body = {
key: ctx.savedKey ?? ctx.row.,
inputs: {},
...(draft ? { row: ctx.row, file: ctx.file } : {}),
};
void runText(body).then((answer) => {
if (manualRun || mySeq !== previewSeq) return; // [계산] 을 눌렀거나 다른 로직이 이미 열림
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
ctx.onText?.(answer);
});
}
if (!fields.length && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산
queueMicrotask(() => {
if (previewSeq === mySeqAtOpen) host.querySelector("button")?.click();
});
} else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values);
void fetchAuto(ctx.savedKey).then((auto) => {
const names = (ctx.row.입력 ?? [])
.map((spec) => spec.이름)
.filter((n) => auto[n] !== undefined && auto[n] !== null && !(ctx.values[n] ?? "").trim());
if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림
for (const n of names) ctx.values[n] = String(auto[n]);
sampled.set(ctx.values, new Set([...marks, ...names]));
buildCalc(host, ctx); // 채운 값·견본 딱지를 새로 그리고 바로 계산(결과 · 텍스트 수식까지)
host.querySelector("button")?.click();
});
}
const run = async (): Promise<void> => {
manualRun = true;
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 body = {
key: ctx.savedKey ?? ctx.row.,
inputs,
...(draft ? { row: ctx.row, file: ctx.file } : {}),
};
const answer = await runCalc(body);
if (ctx.onText) ctx.onText(await runText(body));
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
out.replaceChildren(...answerView(answer, draft, (ctx.row.결과단위 ?? "").startsWith("원")));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
host.replaceChildren(
el("h3", { text: tx("Calc_Title") }),
...(fields.length
? [el("div", { className: "m01-logic__head", children: fields })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
out,
);
}
/** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */
function answerView(answer: CalcAnswer, draft: boolean, money: 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: formatMoney(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: formatMoney(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: money ? formatMoney(answer.result) : 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;
}