/* ============================================================================= * M01_MasterData_UI_Test_C_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)[name]; export function buildFlow( row: LogicRow, answer: CalcAnswer | null, prices: Record, values: Record, ): 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): 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): 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, ): 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 | 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 | 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.출처], ]), }, ], }; }