Files
Aislo/M01_MasterData/LogicBuilder/M01_LB_Check.ts
T
eomsangdonandClaude Opus 5.5 f62689dff1 feat(M01): 로직 만들기 판 왼쪽 원문 Source · Check 를 LB 타입에 맞춤(PLAN 35-2)
- renderSource: 표 이름 · 원문번호 · 기준 · 격자 · 역할 칩 · 걸린 줄 · 해당 없음/중복 · 칸 고침(on.edit) · 주 · 사용처
- Check: runExpect(lb, tables, rows) · 같은 조건 두 칸 조회는 빈틈 한 번
- 시험 test_m01_lb_check.py 9 통과 · LogicBuilder 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 11:47:53 +09:00

267 lines
11 KiB
TypeScript

/* =============================================================================
* 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 { lbCalcLater, 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<string, string>;
값칸?: Record<string, string>;
줄: Record<string, unknown>[];
}
/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
function match(
table: CheckTable,
row: Record<string, unknown>,
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<string, unknown>, 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<string, unknown>;
kind: "없음" | "중복";
/** 중복이면 걸린 줄 차례 */
rows: number[];
}
/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */
function combos(lb: LbLogic, lk: LbLookup): Record<string, unknown>[] | 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<string, unknown>[] = [{}];
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<string, unknown>): Gap[] {
const out: Gap[] = [];
const seen = new Set<string>();
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, unknown>): 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<string, unknown>,
values: Record<string, unknown>,
): Record<string, LbHit> {
const out: Record<string, LbHit> = {};
for (const lk of lb.lookups) {
const table = tables[lk.table] as CheckTable | undefined;
const conds: Record<string, unknown> = {};
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<string, unknown>;
/** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */
expect: Record<string, number>;
}
export interface ExpectResult {
ok: boolean;
got: Record<string, number>;
/** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */
why: string | null;
}
/** 계산 답 → 볼 값 모음 */
export function pickValues(ans: CalcAnswer): Record<string, number> {
const got: Record<string, number> = {};
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<string, unknown>,
tables: LbTables,
) => Promise<CalcAnswer>;
/** LB Api 계산을 한 번 기다림 — 차례로 부르므로 「최신만」 에 안 걸림 */
const calcOnce: CalcFn = (row, inputs, tables) =>
new Promise((resolve, reject) =>
lbCalcLater(row, inputs, tables, (ans, plain) =>
ans ? resolve(ans) : reject(new Error(plain ?? "")),
),
);
/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */
export async function runExpect(
lb: LbLogic,
tables: LbTables,
rows: ExpectRow[],
calc: CalcFn = calcOnce,
): Promise<ExpectResult[]> {
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 {
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */
}
}