/* ============================================================================= * 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; 값칸: Record; 범위규칙?: string | null; 줄: Record[]; 주: 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; /** 고정 칸 값 */ fixed: Record; /** 조건 칸 선택 목록(표 줄에서 저절로) */ options: Record; /** 결과단위 「원/㎡」 */ 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, 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, ): 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 = { 노무: "노무비", 재료: "재료비" }; /** 사람 줄 노임 합 글 — 「(수량 * 노임 키 + …)」 · 사람 줄이 없으면 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(); 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 { const first = a.table.줄[0] ?? {}; const out: Record = {}; 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; }