/* ============================================================================= * common_util_formula_doc_table.ts * 수식 문서 표 찾기(PLAN 802-1) — 조건에 맞는 한 줄(범위 규칙) · 칸 값 · 분수 글 칸 * 파이썬 짝 = resources/master_data/scripts/master_doc_link.py(find · _cell · _arith) * · 거울 시험 test_802_1_formula_doc.py · 화면 · DOM · fetch 없음 * ========================================================================== */ import { DocError, parse, walk, type Node } from "./common_util_formula_doc"; import { Broken, Gap, binary, isNum, plainText, same } from "./common_util_formula_doc_calc"; type Payload = Record; export interface DocTable { 키?: string; 원문번호?: string; 조건?: Record; 범위규칙?: string; 값칸?: Record; 줄: Record[]; } function matchRow(table: DocTable, row: Record, cond: string, want: unknown) { if (!(cond in row)) return true; const have = row[cond]; if (table.조건?.[cond] !== "범위") return same(have, want); const [low, high] = have as [number | null, number | null]; if (!isNum(want) && (low !== null || high !== null)) throw new Broken(`표 ${table.키} 범위 조건에 수 아닌 값`); const w = want as number; const [first, ...more] = String(table.범위규칙 || "초과·이하").split(" · "); const rule = first.split(" (")[0]; const ge = low === null || w >= low; const gt = low === null || w > low; const le = high === null || w <= high; if (rule === "이상·이하") return ge && le; if (rule === "초과·이하" || rule === "이하") return gt && le; if (rule === "이상·미만") { const tops = table.줄 .map((r) => r[cond] as [number | null, number | null] | undefined) .filter((x) => x && x[1] !== null) .map((x) => x![1] as number); const closed = more.some((x) => x.includes("이하 포함")) && high === Math.max(...tops); return ge && (high === null || w < high || (closed && w === high)); } throw new Broken(`모르는 범위규칙 「${table.범위규칙}」`); } /** 조건에 맞는 한 줄 — 「이하」 · 「이상·이하」 는 위 끝이 가장 작은 줄 · 여럿이면 찾는 칸 있는 줄 */ export function findRow( table: DocTable, conds: Record, want?: string, ): Record { for (const c of Object.keys(conds)) if (!(c in (table.조건 ?? {}))) throw new Broken(`표 ${table.키} 에 없는 조건 「${c}」`); let hits = table.줄.filter((r) => Object.entries(conds).every(([c, v]) => matchRow(table, r, c, v)), ); const rule = String(table.범위규칙 ?? "").split(" · ")[0]; if (hits.length > 1 && (rule === "이하" || rule === "이상·이하")) for (const c of Object.keys(conds)) { const ups = hits .filter((r) => table.조건?.[c] === "범위" && r[c]) .map((r) => (r[c] as [number | null, number | null])[1]); if (ups.length && !ups.includes(null)) { const least = Math.min(...(ups as number[])); hits = hits.filter((r) => !r[c] || (r[c] as [unknown, number])[1] === least); } } if (hits.length > 1 && want) hits = hits.filter((r) => want in r); if (hits.length !== 1) { const what = Object.entries(conds) .map(([c, v]) => `${c} ${plainText(v)}`) .join(" · "); const where = `표 ${table.키}(${table.원문번호})`; throw new Gap(`${where}에 ${what} 인 줄이 ${hits.length ? `${hits.length}개` : "없음"}`); } return hits[0]; } /** 표 칸의 분수 · 식 글(「1/1.30」) → 수 · 아니면 글 그대로 */ export function cellArith(text: string): number | string { let node: Node; try { node = parse(text); } catch (e) { if (e instanceof DocError) return text; throw e; } for (const n of walk(node)) if (!["num", "bin", "neg"].includes(n[0])) return text; const ev = (n: Node): number => { if (n[0] === "num") return n[1]; if (n[0] === "neg") return -ev(n[1]); if (n[0] === "bin") return binary(n[1], ev(n[2]), ev(n[3])) as number; return NaN; }; const got = ev(node); return Number.isFinite(got) ? got : text; } /** 표 연결 값 — 줄 찾기 · 칸 · 빈 칸 = Gap · 범위 = Broken */ export function tableValue(table: DocTable, p: Payload): unknown { const col = String(p.칸); const row = findRow(table, (p.조건 ?? {}) as Record, col); if (!(col in row)) { if (!(col in (table.값칸 ?? {})) && !table.줄.some((r) => col in r)) throw new Broken(`표 ${table.키} 에 칸 「${col}」 없음`); throw new Gap(`표 ${table.키} 고른 줄에 「${col}」 값 없음`); } const v = row[col]; if (v === null || v === undefined) throw new Gap(`칸 「${col}」 값 없음 — 관리자가 채울 값`); if (Array.isArray(v)) throw new Broken(`칸 「${col}」 값이 범위 — 입력으로 받아야 함`); return typeof v === "string" ? cellArith(v) : v; }