/* ============================================================================= * M01_LB_Check.ts * 로직 만들기 판 — 검사(정함 7 · 설계초안 4절) * ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈) * ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복 * ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패 * 시험표 줄은 sessionStorage 초안만(저장 안 함) * * 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 · * 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈. * DOM 없음. * ========================================================================== */ import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api"; import { lbCalcNow, type LbTables } from "./M01_LB_Api"; import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks"; type Cell = string | number | boolean | null | [number | null, number | null]; export interface CheckTable { 키?: string; 범위규칙?: string; 조건?: Record; 값칸?: Record; 줄: Record[]; } /* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); function match( table: CheckTable, row: Record, cond: string, want: unknown, ): boolean { if (!(cond in row)) return true; // 빈 조건 칸 = 무관 const have = row[cond] as Cell; if (table.조건?.[cond] === "범위") { if (!Array.isArray(have) || !isNum(want)) return false; const [low, high] = have; const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · "); const rule = head.split(" (")[0]; const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low); if (rule === "이상·이하") return above(false) && (high === null || want <= high); if (rule === "초과·이하" || rule === "이하") return above(true) && (high === null || want <= high); if (rule === "이상·미만") { const tops = table.줄 .map((r) => r[cond]) .filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null) .map((c) => c[1]); const closed = more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops); return above(false) && (high === null || want < high || (closed && want === high)); } return false; // 모르는 범위규칙 — 엔진은 멈춤 } if (isNum(have) && isNum(want)) return have === want; return typeof have === "string" && typeof want === "string" && have === want; } /** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */ export function findRows(table: CheckTable, conds: Record, want = ""): number[] { let hits = table.줄 .map((row, i) => ({ row, i })) .filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v))); const first = (table.범위규칙 ?? "").split(" · ")[0]; if (hits.length > 1 && (first === "이하" || first === "이상·이하")) { for (const c of Object.keys(conds)) { if (table.조건?.[c] !== "범위") continue; const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]); if (!ups.length || ups.includes(null)) continue; const low = Math.min(...(ups as number[])); hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low); } } if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row); return hits.map((h) => h.i); } /* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */ export interface Gap { table: string; /** 어느 표 조회에서 나왔나(화면 번호) */ lookup: string; conds: Record; kind: "없음" | "중복"; /** 중복이면 걸린 줄 차례 */ rows: number[]; } /** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */ function combos(lb: LbLogic, lk: LbLookup): Record[] | null { const lists: [string, unknown[]][] = []; for (const { col, from } of lk.conds) { if (from.k === "fixed") lists.push([col, [from.value]]); else if (from.k === "input") { const spec = lb.inputs.find((x) => x.이름 === from.name); if (!spec?.고르기?.length) return null; lists.push([col, spec.고르기]); } else return null; } let out: Record[] = [{}]; for (const [col, values] of lists) out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v }))); return out; } /** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */ export function gaps(lb: LbLogic, tables: Record): Gap[] { const out: Gap[] = []; const seen = new Set(); for (const lk of lb.lookups) { const table = tables[lk.table] as CheckTable | undefined; const sets = table?.줄 ? combos(lb, lk) : null; if (!table || !sets) continue; for (const conds of sets) { const rows = findRows(table, conds, lk.col); if (rows.length === 1) continue; const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`; if (seen.has(tag)) continue; seen.add(tag); out.push({ table: lk.table, lookup: lk.id, conds, kind: rows.length ? "중복" : "없음", rows, }); } } return out; } /** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */ export function gapSkipped(lb: LbLogic, tables: Record): string[] { return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id); } /* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */ /** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */ export type LbHit = number | null | "없음" | "중복"; export function lookupHits( lb: LbLogic, tables: Record, values: Record, ): Record { const out: Record = {}; for (const lk of lb.lookups) { const table = tables[lk.table] as CheckTable | undefined; const conds: Record = {}; let known = !!table?.줄; for (const { col, from } of lk.conds) { if (from.k === "fixed") conds[col] = from.value; else if (from.k === "input" && values[from.name] !== undefined) conds[col] = values[from.name]; else known = false; } if (!known || !table) { out[lk.id] = null; continue; } const rows = findRows(table, conds, lk.col); out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음"; } return out; } /* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */ export interface ExpectRow { /** 예시값 */ inputs: Record; /** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */ expect: Record; } export interface ExpectResult { ok: boolean; got: Record; /** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */ why: string | null; } /** 계산 답 → 볼 값 모음 */ export function pickValues(ans: CalcAnswer): Record { const got: Record = {}; if (!ans.ok) return got; if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"]; if (isNum(ans.result)) got["결과"] = ans.result; for (const line of ans.lines ?? []) { if (isNum(line.수량)) got[line.이름] = line.수량; if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액; } return got; } /** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */ export function sameAsExpect(got: number | undefined, expect: number): boolean { if (!isNum(got)) return false; const places = (String(expect).split(".")[1] ?? "").length; return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9; } type CalcFn = ( row: LogicRow, inputs: Record, tables: LbTables, ) => Promise; /** LB Api 바로 계산 — 화면 예시값 계산(0.3초 · 최신만)과 안 부딪힘 · 멈춤 까닭은 쉬운 말로 */ const calcNow: CalcFn = async (row, inputs, tables) => { let plain = ""; const ans = await lbCalcNow(row, inputs, tables, (p) => (plain = p)); if (!ans) throw new Error(plain); return ans.ok ? ans : { ok: false, reason: plain || ans.reason }; }; /** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */ export async function runExpect( lb: LbLogic, tables: LbTables, rows: ExpectRow[], calc: CalcFn = calcNow, ): Promise { const row = writeLogic(lb); const out: ExpectResult[] = []; for (const r of rows) { try { const ans = await calc(row, r.inputs, tables); const got = pickValues(ans); const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v)); out.push({ ok, got, why: ans.ok ? null : ans.reason }); } catch (e) { out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) }); } } return out; } /* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */ const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`; export function loadExpectRows(logic: string): ExpectRow[] { try { const raw = sessionStorage.getItem(draftKey(logic)); const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : []; return Array.isArray(rows) ? rows : []; } catch { return []; } } export function saveExpectRows(logic: string, rows: ExpectRow[]): void { try { if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows)); else sessionStorage.removeItem(draftKey(logic)); } catch { /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */ } }