Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
249 lines
8.5 KiB
TypeScript
249 lines
8.5 KiB
TypeScript
/* =============================================================================
|
||
* 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<string, Bind> }
|
||
| { 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<string, InputMeta>;
|
||
extras: Extra[];
|
||
round: string;
|
||
}
|
||
|
||
export const BASES: Record<string, string> = {
|
||
노무비: "노무비",
|
||
재료비: "재료비",
|
||
경비: "경비",
|
||
"노무비+재료비": "(노무비 + 재료비)",
|
||
"노무비+재료비+경비": "(노무비 + 재료비 + 경비)",
|
||
};
|
||
export const COSTS = ["노무비", "재료비", "경비"];
|
||
export const COST_OF: Record<LineKind, string> = {
|
||
인력: "노무비",
|
||
재료: "재료비",
|
||
기계: "경비",
|
||
로직: "",
|
||
};
|
||
|
||
/** 표 통째 — 고르기 목록·조건 종류를 셈할 때 씀(모달이 읽어 두는 곳) */
|
||
export const tableInfo = new Map<string, TableInfo>();
|
||
|
||
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<string, InputShape>();
|
||
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<string, unknown> = {};
|
||
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;
|
||
}
|