Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Wizard_Model.ts
T

249 lines
8.5 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_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;
}