- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — resources/master_data/_수식문서_틀.md - 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파 · 정적 검사 = master_doc.py ↔ common_util_formula_doc.ts - 셈 = master_doc_calc.py ↔ common_util_formula_doc_calc.ts — 기호는 필요할 때 셈 · 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 = 표지 + 금액 없음 · 값 없음 = 그 줄만 비움 · 묶음 소계 · 합계 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 - M01 연결 풀이(표 찾기 · 요소 · 단가산출 문서) = master_doc_link.py ↔ common_util_formula_doc_table.ts(표 찾기) · 옛 엔진 셈 안 부름 - 옛 로직 변환 = master_doc_convert.py(802-2 빌더가 부름) - 시험 test_802_1_formula_doc 48(꼴 · 셈 · 지움 견딤 300 넘는 지운 문서 예외 0 · 표지 · 거울 파이썬 ↔ TS 60 문서) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
4.9 KiB
TypeScript
115 lines
4.9 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, unknown>;
|
|
|
|
export interface DocTable {
|
|
키?: string;
|
|
원문번호?: string;
|
|
조건?: Record<string, string>;
|
|
범위규칙?: string;
|
|
값칸?: Record<string, unknown>;
|
|
줄: Record<string, unknown>[];
|
|
}
|
|
|
|
function matchRow(table: DocTable, row: Record<string, unknown>, 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<string, unknown>,
|
|
want?: string,
|
|
): Record<string, unknown> {
|
|
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<string, unknown>, 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;
|
|
}
|