Files
Aislo/M01_MasterData/LogicBuilder/M01_LB_Wizard_Build.ts
T

271 lines
9.5 KiB
TypeScript

/* =============================================================================
* M01_LB_Wizard_Build.ts
* 단계 안내가 쌓은 답 → 로직 한 줄(LogicRow) 짜기 — DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림)
* 화면 = `M01_LB_Wizard.ts` · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 글은 마스터 로직과 같은 꼴
* ========================================================================== */
import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
type Cell = string | number | [number | null, number | null] | null | undefined;
/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 */
export interface WTable {
key: string;
/** 표가 든 파일 */
file?: string;
이름: string;
기준: string;
출처: string;
조건: Record<string, string>;
값칸: Record<string, string>;
범위규칙?: string | null;
줄: Record<string, Cell>[];
주: string[];
}
export type Role = "조건" | "고정" | "결과" | "쓰지 않음";
/** 표 조회 율 — 숫자 · 입력값 · 조건 목록(입력값이 한계를 넘을 때만 다른 표에서 찾음) */
export type Rate =
| { t: "num"; v: number }
| { t: "input"; name: string }
| { t: "cond"; input: string; op: "<=" | "<"; limit: number; table: WTable; col: string };
export type Slot = { t: "num"; v: number } | { t: "input"; name: string };
/** 문장 틀 — 할증 · 나누기 · 최소/최대 · 올림 */
export type Mod =
| { k: "할증"; rate: Rate; src?: string }
| { k: "나누기"; by: Slot; src?: string }
| { k: "최소"; v: number; src?: string }
| { k: "최대"; v: number; src?: string }
| { k: "올림"; fn: "올림" | "버림" | "반올림"; places: number; src?: string };
export type LinkKind = "노무" | "재료" | "장비" | "일위대가" | "없음";
/** 결과 칸 하나의 항목 연결 */
export interface Link {
col: string;
kind: LinkKind;
name: string;
spec: string;
unit: string;
element: unknown;
needInputs?: LogicInput[];
}
/** 항목 연결 덧줄 — 비율(바탕의 % · 공구손료 등) · 기타(고정 금액) */
export type Extra =
| {
k: "비율";
name: string;
base: "노무비" | "재료비" | "경비" | "사람줄합";
rate: number;
cost: "노무비" | "재료비" | "경비";
}
| { k: "기타"; name: string; amount: number; cost: "노무비" | "재료비" | "경비" };
export interface WAnswers {
book: string;
section: string;
table: WTable;
roles: Record<string, Role>;
/** 고정 칸 값 */
fixed: Record<string, string | number>;
/** 조건 칸 선택 목록(표 줄에서 저절로) */
options: Record<string, (string | number)[]>;
/** 결과단위 「원/㎡」 */
resultUnit: string;
mods: Mod[];
links: Link[];
extras: Extra[];
name: string;
/** 금액 = 1㎡ 당 금액 · 값 = 다른 로직이 쓸 값(결과 식) */
kind: "금액" | "값";
valueCol: string;
}
/** 단계마다 같이 보는 것 — 화면이 바꾸면 changed() 로 알림(예시값 다시 셈) */
export interface WCtx {
a: WAnswers;
/** 고른 절의 다른 표들(조건 목록 율 찾기용) */
sectionTables: WTable[];
changed(): void;
}
const isWord = (t: string): boolean => /^[\p{L}\p{N}_]+$/u.test(t);
const word = (t: string): string => (isWord(t) ? t : `'${t}'`);
const num = (n: number): string => String(Number(n.toPrecision(12)));
const lit = (v: string | number): string => (typeof v === "number" ? num(v) : `'${v}'`);
/** 역할별 칸 이름 */
export const colsOf = (a: Pick<WAnswers, "roles">, role: Role): string[] =>
Object.keys(a.roles).filter((c) => a.roles[c] === role);
/** 기준 「1㎡」 「100㎡ 당」 「m당」 → 결과단위 「원/㎡」 「원/100㎡」 「원/m」 */
export function unitOfBase(base: string): string {
const per = base
.replace(/\s*당\s*$/, "")
.replace(/^1(?=\D)/, "")
.trim();
return `원/${per || "㎡"}`;
}
/** 표 조회 글 — 조건 칸은 같은 이름 입력값 · 고정 칸은 값 */
export function findText(a: WAnswers, col: string): string {
const args = [
...colsOf(a, "조건").map((c) => `${word(c)}=${word(c)}`),
...colsOf(a, "고정").map((c) => `${word(c)}=${lit(a.fixed[c] ?? "")}`),
];
return `찾기(${[a.table.key, ...args].join(", ")}).${word(col)}`;
}
/** 율 · 나누는 수 — 식 글 · 새로 필요한 중간 값 */
function rateText(
rate: Rate,
mids: { 이름: string; 식: string; 출처?: string }[],
src?: string,
): string {
if (rate.t === "num") return num(rate.v);
if (rate.t === "input") return word(rate.name);
const t = rate.table;
let name = rate.col;
while (mids.some((m) => m.이름 === name) || name === rate.input) name += "값";
const found = `찾기(${t.key}, ${word(rate.input)}=${word(rate.input)}).${word(rate.col)}`;
mids.push({
이름: name,
식: `만약(${word(rate.input)} ${rate.op} ${num(rate.limit)}, 0, ${found})`,
...(src ? { 출처: src } : {}),
});
return name;
}
/** 문장 틀을 차례로 씌운 식 글 — 중간 값은 mids 에 쌓음 */
export function valueText(
a: WAnswers,
col: string,
mids: { 이름: string; 식: string; 출처?: string }[],
rates: Map<Mod, string>,
): string {
let x = findText(a, col);
let atom = true;
const wrap = (): string => (atom ? x : `(${x})`);
for (const m of a.mods) {
if (m.k === "할증") {
if (!rates.has(m)) rates.set(m, rateText(m.rate, mids, m.src));
x = `${wrap()} * (1 + ${rates.get(m)} / 100)`;
atom = false;
} else if (m.k === "나누기") {
x = `${wrap()} / ${m.by.t === "num" ? num(m.by.v) : word(m.by.name)}`;
atom = false;
} else if (m.k === "최소" || m.k === "최대") {
x = `${m.k}(${x}, ${num(m.v)})`;
atom = true;
} else {
x = `${m.fn}(${x}, ${num(m.places)})`;
atom = true;
}
}
return x;
}
/** 입력값 모으기 — 표 조건 칸 · 율 · 나누는 수 · 장비 · 일위대가가 받는 값 */
function inputsOf(a: WAnswers): LogicInput[] {
const out: LogicInput[] = [];
const add = (i: LogicInput): void => {
if (!out.some((x) => x.이름 === i.이름)) out.push(i);
};
for (const c of colsOf(a, "조건")) {
const list = a.options[c];
add(list?.length && a.table.조건[c] !== "범위" ? { 이름: c, 고르기: list } : { 이름: c });
}
for (const m of a.mods) {
if (m.k === "할증" && m.rate.t === "input") add({ 이름: m.rate.name, 단위: "%" });
if (m.k === "할증" && m.rate.t === "cond") add({ 이름: m.rate.input });
if (m.k === "나누기" && m.by.t === "input") add({ 이름: m.by.name });
}
if (a.kind === "금액")
for (const l of a.links)
if (l.kind !== "없음" && a.roles[l.col] === "결과")
for (const n of l.needInputs ?? []) add(n);
return out;
}
const COST: Record<string, string> = { 노무: "노무비", 재료: "재료비" };
/** 사람 줄 노임 합 글 — 「(수량 * 노임 키 + …)」 · 사람 줄이 없으면 0 */
const personSum = (ho: HoLine[]): string => {
const terms = ho
.filter((h) => h.종류 === "인력")
.map(
(h) => `${/^[\p{L}\p{N}_.]+$/u.test(h.수량) ? h.수량 : `(${h.수량})`} * ${String(h.요소)}`,
);
return `(${terms.length ? terms.join(" + ") : "0"})`;
};
/** 답 → 로직 줄 */
export function buildRow(a: WAnswers): LogicRow {
const mids: { 이름: string; 식: string; 출처?: string }[] = [];
const rates = new Map<Mod, string>();
const base: LogicRow = {
키: "",
원문번호: a.section,
이름: a.name,
결과단위: a.resultUnit,
출처: `${a.book} ${a.section}`,
소유: "현장",
입력: [],
중간: [],
};
if (a.kind === "값") {
const col = a.valueCol || colsOf(a, "결과")[0];
const text = col ? valueText(a, col, mids, rates) : "0";
return { ...base, 입력: inputsOf(a), 중간: mids, 결과: { 식: text } };
}
const 호표: HoLine[] = a.links
.filter((l) => l.kind !== "없음" && a.roles[l.col] === "결과")
.map((l) => ({
종류: l.kind === "노무" ? "인력" : l.kind === "재료" ? "재료" : "로직",
요소: l.element as string,
이름: l.name,
...(l.spec ? { 규격: l.spec } : {}),
단위: l.unit,
수량: valueText(a, l.col, mids, rates),
...(COST[l.kind] ? { 비목: COST[l.kind] } : {}),
}));
const 덧줄 = a.extras.map((e) =>
e.k === "비율"
? {
이름: e.name,
식: `${e.base === "사람줄합" ? personSum(호표) : e.base} * ${num(e.rate)} / 100`,
비목: e.cost,
}
: { 이름: e.name, 식: num(e.amount), 비목: e.cost },
);
return { ...base, 입력: inputsOf(a), 중간: mids, 호표, 덧줄, 끝수: null };
}
/** 예시값 — 첫 표 줄에 걸리도록(선택 칸은 첫 줄 값 · 범위 칸은 그 안 수) · 새 입력값은 율 25 · 나누는 수 1 */
export function trialOf(a: WAnswers, row: LogicRow): Record<string, unknown> {
const first = a.table.줄[0] ?? {};
const out: Record<string, unknown> = {};
for (const i of row.입력) {
const v = first[i.이름];
if (Array.isArray(v)) out[i.이름] = v[0] ?? v[1] ?? 1;
else if (v !== undefined && v !== null) out[i.이름] = v;
else if (i.고르기?.length) out[i.이름] = i.고르기[0];
else {
const cond = a.mods.find(
(m) => m.k === "할증" && m.rate.t === "cond" && m.rate.input === i.이름,
);
out[i.이름] =
cond && cond.k === "할증" && cond.rate.t === "cond"
? cond.rate.limit + 1
: i.단위 === "%"
? 25
: 1;
}
}
return out;
}