방식 C 를 테스트 컨테이너에서 꺼내 로직 화면 기본 보기로 올림(옛 호표 표는 「고급」). 상자를 누르면 값·출처에 더해 쉬운 말 풀이와 원문 표 미리보기(걸린 줄 강조)가 펼쳐짐. 자체 로직은 [고치기] 로 상자 안에서 고치고 줄을 더하거나 지움 — 정본은 「본떠 만들기」 뒤. - UI_Test_C·_Model·_Style → UI_Logic_Flow·_Model·_Style - UI_Test_Pick 의 재료·기계 바꿔 고르기를 UI_Logic_Pick 에 합침 - 방식 B 의 설명 카드 로직을 UI_Logic_Note 로 옮겨 흐름 그림과 같이 씀 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
239 lines
7.4 KiB
TypeScript
239 lines
7.4 KiB
TypeScript
/* =============================================================================
|
||
* M01_MasterData_UI_Logic_Flow_Model.ts
|
||
* 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자.
|
||
* 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
|
||
*
|
||
* 순수 — DOM·서버를 안 씀(시험이 이 파일만 떼어 Node 로 돌림).
|
||
* 값은 모두 시험 계산 답(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음.
|
||
* ========================================================================== */
|
||
|
||
import type {
|
||
CalcAnswer,
|
||
CalcLine,
|
||
ElementBrief,
|
||
HoLine,
|
||
LogicRow,
|
||
NamedFormula,
|
||
} from "./M01_MasterData_UI_Logic_Api";
|
||
|
||
export type FlowKind = "입력" | "중간" | "수량" | "단가" | "덧줄" | "비목" | "계";
|
||
|
||
/** 흐름 상자 하나 — 누르면 `detail`(값과 출처) */
|
||
export interface FlowBox {
|
||
/** 다시 그려도 같은 id — 펼친 상자를 그대로 둠 */
|
||
id: string;
|
||
kind: FlowKind;
|
||
label: string;
|
||
value: string;
|
||
note?: string;
|
||
detail: [string, string][];
|
||
/** 로직이 로직을 부르는 줄 — 그 로직 키(펼치기) */
|
||
logic?: string;
|
||
/** 비목 — 상자가 많은 로직에서 묶기·접기 */
|
||
cost?: string;
|
||
/** 값이 없어 막힌 상자 */
|
||
bad?: boolean;
|
||
}
|
||
|
||
export interface FlowColumn {
|
||
kind: FlowKind;
|
||
boxes: FlowBox[];
|
||
}
|
||
|
||
export const COSTS = ["노무비", "재료비", "경비"];
|
||
|
||
export function fmt(value: unknown): string {
|
||
if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
|
||
if (value === null || value === undefined) return "";
|
||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||
}
|
||
|
||
/** 로직 줄의 결과가 돈인지 — 돈이면 호표·비목·계, 아니면 결과 식 하나 */
|
||
export const isMoney = (row: LogicRow): boolean =>
|
||
!("결과" in row) && (row.결과단위 ?? "").startsWith("원");
|
||
|
||
/** `로직(GC000268, 기계='…')` 에서 부르는 로직 키 */
|
||
export function logicRef(element: string): string | undefined {
|
||
return /로직\(\s*([A-Z]{1,2}\w+)/.exec(element)?.[1];
|
||
}
|
||
|
||
const keep = (pairs: [string, unknown][]): [string, string][] =>
|
||
pairs.filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => [k, fmt(v)]);
|
||
|
||
/** 파일에 있으나 화면 틀에 없는 칸(입력·호표의 출처·비고) */
|
||
const extra = (item: object, name: string): string | undefined =>
|
||
(item as Record<string, string | undefined>)[name];
|
||
|
||
export function buildFlow(
|
||
row: LogicRow,
|
||
answer: CalcAnswer | null,
|
||
prices: Record<string, ElementBrief | null>,
|
||
values: Record<string, string>,
|
||
): FlowColumn[] {
|
||
const ok = answer?.ok ? answer : null;
|
||
const lines = ok?.lines ?? [];
|
||
const middle = ok?.middle ?? {};
|
||
const columns: FlowColumn[] = [inputColumn(row, values)];
|
||
const mid = middleColumn(row, middle);
|
||
if (mid.boxes.length) columns.push(mid);
|
||
if (!isMoney(row)) {
|
||
columns.push(resultColumn(row, ok?.result));
|
||
return columns;
|
||
}
|
||
const ho = row.호표 ?? [];
|
||
columns.push(qtyColumn(ho, lines), priceColumn(ho, lines, prices));
|
||
const plus = extraColumn(row.덧줄 ?? [], lines, ho.length);
|
||
if (plus.boxes.length) columns.push(plus);
|
||
const sums = ok?.sums ?? null;
|
||
columns.push(costColumn(sums, lines), totalColumn(row, sums));
|
||
return columns;
|
||
}
|
||
|
||
function inputColumn(row: LogicRow, values: Record<string, string>): FlowColumn {
|
||
const boxes = (row.입력 ?? []).map((spec, i) => ({
|
||
id: `입력:${i}`,
|
||
kind: "입력" as const,
|
||
label: spec.이름,
|
||
value: values[spec.이름] ?? "",
|
||
note: spec.단위,
|
||
detail: keep([
|
||
["단위", spec.단위],
|
||
["고르기", (spec.고르기 ?? []).join(" · ")],
|
||
["범위", spec.범위 ? `${spec.범위[0]} ∼ ${spec.범위[1]}` : ""],
|
||
["출처", extra(spec, "출처")],
|
||
]),
|
||
}));
|
||
return { kind: "입력", boxes };
|
||
}
|
||
|
||
function middleColumn(row: LogicRow, middle: Record<string, unknown>): FlowColumn {
|
||
const boxes = (row.중간 ?? []).map((step, i) => ({
|
||
id: `중간:${i}`,
|
||
kind: "중간" as const,
|
||
label: step.이름,
|
||
value: fmt(middle[step.이름]),
|
||
note: step.식.startsWith("찾기(") ? "표 찾기" : "식",
|
||
detail: keep([
|
||
["식", step.식],
|
||
["출처", step.출처],
|
||
]),
|
||
}));
|
||
return { kind: "중간", boxes };
|
||
}
|
||
|
||
function qtyColumn(ho: HoLine[], lines: CalcLine[]): FlowColumn {
|
||
const boxes = ho.map((item, i) => ({
|
||
id: `수량:${i}`,
|
||
kind: "수량" as const,
|
||
label: item.이름 || item.요소,
|
||
value: lines[i] ? fmt(lines[i].수량) : "",
|
||
note: item.단위,
|
||
cost: item.비목,
|
||
detail: keep([
|
||
["식", item.수량],
|
||
["종류", item.종류],
|
||
["단위", item.단위],
|
||
["비고", extra(item, "비고")],
|
||
]),
|
||
}));
|
||
return { kind: "수량", boxes };
|
||
}
|
||
|
||
function priceColumn(
|
||
ho: HoLine[],
|
||
lines: CalcLine[],
|
||
prices: Record<string, ElementBrief | null>,
|
||
): FlowColumn {
|
||
const boxes = ho.map((item, i) => {
|
||
const line = lines[i];
|
||
const brief = prices[item.요소];
|
||
const price = line ? line.단가 : brief?.값;
|
||
return {
|
||
id: `단가:${i}`,
|
||
kind: "단가" as const,
|
||
label: item.이름 || item.요소,
|
||
value: line ? fmt(line.금액) : "",
|
||
note: price === undefined || price === null ? "단가 —" : `× ${fmt(price)}`,
|
||
cost: item.비목,
|
||
logic: logicRef(item.요소),
|
||
bad: !line && brief === null,
|
||
detail: keep([
|
||
["요소", item.요소],
|
||
["단가", price],
|
||
["금액", line?.금액],
|
||
["비목", item.비목 ?? Object.keys(line?.비목 ?? {}).join(" · ")],
|
||
["출처", line?.출처 ?? [brief?.이름, brief?.규격].filter(Boolean).join(" ")],
|
||
]),
|
||
};
|
||
});
|
||
return { kind: "단가", boxes };
|
||
}
|
||
|
||
function extraColumn(list: NamedFormula[], lines: CalcLine[], from: number): FlowColumn {
|
||
const boxes = list.map((item, i) => ({
|
||
id: `덧줄:${i}`,
|
||
kind: "덧줄" as const,
|
||
label: item.이름,
|
||
value: fmt(lines[from + i]?.금액),
|
||
note: item.비목,
|
||
cost: item.비목,
|
||
detail: keep([
|
||
["식", item.식],
|
||
["비목", item.비목],
|
||
["출처", item.출처],
|
||
]),
|
||
}));
|
||
return { kind: "덧줄", boxes };
|
||
}
|
||
|
||
function costColumn(sums: Record<string, number> | null, lines: CalcLine[]): FlowColumn {
|
||
const boxes = COSTS.map((cost) => ({
|
||
id: `비목:${cost}`,
|
||
kind: "비목" as const,
|
||
label: cost,
|
||
value: fmt(sums?.[cost]),
|
||
cost,
|
||
detail: lines
|
||
.filter((line) => line.비목?.[cost] !== undefined)
|
||
.map((line) => [line.이름, fmt(line.비목[cost])] as [string, string]),
|
||
}));
|
||
return { kind: "비목", boxes };
|
||
}
|
||
|
||
function totalColumn(row: LogicRow, sums: Record<string, number> | null): FlowColumn {
|
||
return {
|
||
kind: "계",
|
||
boxes: [
|
||
{
|
||
id: "계",
|
||
kind: "계",
|
||
label: row.결과단위 || "계",
|
||
value: fmt(sums?.계),
|
||
detail: keep([
|
||
...COSTS.map((c) => [c, sums?.[c]] as [string, unknown]),
|
||
["끝수", row.끝수],
|
||
["출처", row.출처],
|
||
]),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function resultColumn(row: LogicRow, result: unknown): FlowColumn {
|
||
return {
|
||
kind: "계",
|
||
boxes: [
|
||
{
|
||
id: "계",
|
||
kind: "계",
|
||
label: row.결과단위 || "결과",
|
||
value: fmt(result),
|
||
detail: keep([
|
||
["식", row.결과?.식],
|
||
["출처", row.출처],
|
||
]),
|
||
},
|
||
],
|
||
};
|
||
}
|