/* ============================================================================= * M01_MasterData_UI_Logic_Wizard_Model.ts * 「새로 만들기」 — 만드는 중인 로직 한 벌(선택지) → 식 글자 → 로직 줄. 화면 없음(순수 함수) * 식은 사람이 치지 않음 — 고른 것을 여기서 엔진 문법(`_틀.md` 8장)으로 조립 * ========================================================================== */ import type { HoLine, LogicInput, LogicRow, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import type { TableBrief, TableInfo } from "./M01_MasterData_UI_Logic_Wizard_Api"; export type Bind = { ask: true } | { ask: false; value: string }; export type Atom = | { kind: "value"; value: string } | { kind: "input"; name: string } | { kind: "table"; table: string; col: string; cond: Record } | { kind: "logic"; key: string; args: string }; export interface Qty { atom: Atom; /** 곱할 중간 값 이름(× (1 + 값 / 100)) — 없으면 "" */ times: string; } export interface Branch { input: string; op: "<=" | "<" | ">=" | ">"; than: string; then: Atom; } export interface Middle { name: string; branches: Branch[]; otherwise: Atom; } export type LineKind = "인력" | "재료" | "기계" | "로직"; export interface Line { kind: LineKind; /** 인력 = LB 키 · 기계 = EQ 키 · 재료 = 조건 묶음 · 로직 = 하위 로직 키 */ ref: string | { 구분: string; 상세구분?: string }; args: string; name: string; unit: string; qty: Qty; cost: string; } export interface Extra { name: string; base: string; pct: string; cost: string; } export interface InputMeta { desc: string; unit: string; min: string; max: string; } export interface Draft { name: string; unit: string; book: string; section: string; tables: TableBrief[]; middles: Middle[]; lines: Line[]; result: Qty; meta: Record; extras: Extra[]; round: string; } export const BASES: Record = { 노무비: "노무비", 재료비: "재료비", 경비: "경비", "노무비+재료비": "(노무비 + 재료비)", "노무비+재료비+경비": "(노무비 + 재료비 + 경비)", }; export const COSTS = ["노무비", "재료비", "경비"]; export const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비", 로직: "", }; /** 표 통째 — 고르기 목록·조건 종류를 셈할 때 씀(모달이 읽어 두는 곳) */ export const tableInfo = new Map(); export const emptyAtom = (): Atom => ({ kind: "value", value: "" }); export const emptyQty = (): Qty => ({ atom: emptyAtom(), times: "" }); export const emptyDraft = (): Draft => ({ name: "", unit: "원/㎡", book: "산림품셈", section: "", tables: [], middles: [], lines: [], result: emptyQty(), meta: {}, extras: [], round: "", }); export const isMoney = (unit: string): boolean => unit.trim().startsWith("원"); const isNumber = (v: string): boolean => v.trim() !== "" && !Number.isNaN(Number(v)); const name_ = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); const literal = (v: string): string => (isNumber(v) ? v.trim() : `'${v}'`); export function atomText(a: Atom): string { if (a.kind === "value") return a.value.trim(); if (a.kind === "input") return a.name; if (a.kind === "logic") return `로직(${a.key}${a.args.trim() ? `, ${a.args.trim()}` : ""})`; const conds = Object.entries(a.cond).map( ([n, b]) => `, ${name_(n)}=${b.ask ? n : literal(b.value)}`, ); return `찾기(${a.table}${conds.join("")}).${name_(a.col)}`; } export const qtyText = (q: Qty): string => q.times ? `${atomText(q.atom)} * (1 + ${q.times} / 100)` : atomText(q.atom); export function middleText(m: Middle): string { let out = atomText(m.otherwise); for (const b of [...m.branches].reverse()) { out = `만약(${b.input} ${b.op} ${b.than}, ${atomText(b.then)}, ${out})`; } return out; } /** 이 원자가 채워졌는지 — 다음으로 못 넘기는 까닭에 씀 */ export function atomReady(a: Atom): boolean { if (a.kind === "value") return isNumber(a.value); if (a.kind === "input") return a.name.trim() !== ""; if (a.kind === "logic") return a.key.trim() !== ""; return a.table !== "" && a.col !== ""; } export const qtyReady = (q: Qty): boolean => atomReady(q.atom); /** 쉬운 말 한 줄 — 미리보기 */ export function atomPlain(a: Atom): string { if (a.kind === "value") return a.value || "?"; if (a.kind === "input") return `입력 「${a.name}」`; if (a.kind === "logic") return `로직 ${a.key} 의 결과`; const asks = Object.entries(a.cond) .filter(([, b]) => b.ask) .map(([n]) => n); const table = tableInfo.get(a.table)?.이름 ?? a.table; return `표 「${table}」의 「${a.col}」${asks.length ? ` (${asks.join("·")} 에 따라)` : ""}`; } export const qtyPlain = (q: Qty): string => q.times ? `${atomPlain(q.atom)} × (1 + ${q.times}/100)` : atomPlain(q.atom); /** 조건 칸 하나가 받을 수 있는 값 — 표 줄에서 뽑음(없으면 undefined = 수 칸) */ export function condChoices(table: string, cond: string): (string | number)[] | undefined { const info = tableInfo.get(table); if (!info || info.조건?.[cond] !== "고르기") return undefined; return info.값?.[cond] as (string | number)[] | undefined; } /** 만드는 중인 것이 모든 식에서 받는 설계 입력 이름들(등장 차례) — 이름 · 모양 */ export interface InputShape { name: string; choices?: (string | number)[]; } export function collectInputs(d: Draft): InputShape[] { const found = new Map(); const add = (name: string, choices?: (string | number)[]): void => { const had = found.get(name); if (!had) found.set(name, { name, ...(choices ? { choices } : {}) }); else if (choices && had.choices) { const more = choices.filter((c) => !had.choices!.includes(c)); had.choices.push(...more); } }; const middleNames = new Set(d.middles.map((m) => m.name)); const seeAtom = (a: Atom): void => { if (a.kind === "input" && a.name && !middleNames.has(a.name)) add(a.name); if (a.kind === "table") { for (const [n, b] of Object.entries(a.cond)) if (b.ask) add(n, condChoices(a.table, n)); } }; for (const m of d.middles) { for (const b of m.branches) { if (!middleNames.has(b.input)) add(b.input); seeAtom(b.then); } seeAtom(m.otherwise); } if (isMoney(d.unit)) for (const l of d.lines) seeAtom(l.qty.atom); else seeAtom(d.result.atom); return [...found.values()]; } const inputOf = (s: InputShape, meta: InputMeta | undefined): LogicInput => { const out: LogicInput = { 이름: s.name }; const rest: Record = {}; if (meta?.desc.trim()) rest["설명"] = meta.desc.trim(); if (meta?.unit.trim()) out.단위 = meta.unit.trim(); if (s.choices) out.고르기 = s.choices; else if (isNumber(meta?.min ?? "") && isNumber(meta?.max ?? "")) { out.범위 = [Number(meta!.min), Number(meta!.max)]; } return Object.assign(out, rest); }; const extraOf = (e: Extra, source: string): NamedFormula => ({ 이름: e.name.trim(), 식: `${BASES[e.base] ?? e.base} * ${e.pct.trim()} / 100`, 비목: e.cost, 출처: source, }); /** 만드는 중인 것 → 로직 줄(키는 서버가 저장 때 붙임 — "") */ export function buildRow(d: Draft): LogicRow { const source = `${d.book} ${d.section}`.trim(); const row: LogicRow = { 키: "", 원문번호: d.section, 구분: "자체", 상세구분: "", 이름: d.name.trim(), 결과단위: d.unit.trim(), 출처: source, 소유: "현장", 입력: collectInputs(d).map((s) => inputOf(s, d.meta[s.name])), 중간: d.middles.map((m): NamedFormula => ({ 이름: m.name, 식: middleText(m), 출처: source })), }; if (isMoney(d.unit)) { row.호표 = d.lines.map((l): HoLine => { const ref = l.kind === "로직" ? `로직(${l.ref as string}${l.args.trim() ? `, ${l.args.trim()}` : ""})` : (l.ref as unknown as HoLine["요소"]); return { 종류: l.kind, 요소: ref, 이름: l.name, 단위: l.unit, 수량: qtyText(l.qty), ...(l.cost ? { 비목: l.cost } : {}), }; }); row.덧줄 = d.extras .filter((e) => e.name.trim() && isNumber(e.pct)) .map((e) => extraOf(e, source)); Object.assign(row, { 끝수: d.round ? { 대상: "계", 자리: 0, 방법: d.round } : null }); } else row.결과 = { 식: qtyText(d.result) }; return row; }