feat(M01): 새로 만들기 모달 — 걸음 틀 · 원문 절 · 호표 줄 고르기 · 중간 값 · 설계 입력 · 시험 계산 · 저장 자리
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Model.ts
|
||||
* 「새로 만들기」 — 만드는 중인 로직 한 벌(선택지) → 식 글자 → 로직 줄. 화면 없음(순수 함수)
|
||||
* 식은 사람이 치지 않음 — 고른 것을 여기서 엔진 문법(`_틀.md` 8장)으로 조립
|
||||
* ========================================================================== */
|
||||
|
||||
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import type { TableBrief, TableFull } 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, TableFull>();
|
||||
|
||||
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 [...new Set((info.줄 ?? []).map((r) => r[cond] as string | number))];
|
||||
}
|
||||
|
||||
/** 만드는 중인 것이 모든 식에서 받는 설계 입력 이름들(등장 차례) — 이름 · 모양 */
|
||||
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.book,
|
||||
상세구분: "자체",
|
||||
이름: 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));
|
||||
row.끝수 = d.round || null;
|
||||
} else row.결과 = { 식: qtyText(d.result) };
|
||||
return row;
|
||||
}
|
||||
Reference in New Issue
Block a user