Files
Aislo/common_util/common_util_formula_doc_table.ts
T
eomsangdonandClaude Opus 5.5 f7038cc280 feat(802-1): 단가산출 수식 문서 꼴 · 계산기(파이썬 · TS 짝) · 표지 · 꼴 문서
- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — 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>
2026-11-07 18:41:39 +09:00

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;
}