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>
This commit is contained in:
2026-11-07 18:41:39 +09:00
co-authored by Claude Opus 5.5
parent 2376e70fbb
commit f7038cc280
10 changed files with 4004 additions and 0 deletions
+624
View File
@@ -0,0 +1,624 @@
/* =============================================================================
* common_util_formula_doc.ts
* 단가산출 수식 문서 — 꼴 · 풀이(파스) · 글로 되돌림 · 기호 · 검사(PLAN 802-1 · 요구 664 · 요구서_664)
* 꼴 글 = resources/master_data/_수식문서_틀.md · 셈 = common_util_formula_doc_calc.ts
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc.py · 거울 시험 test_802_1_formula_doc.py
* (고칠 때 둘을 같이 고침) · 화면 · DOM · fetch 없음(브라우저 · 서버 Node 둘 다 부름)
* ========================================================================== */
export const COSTS = ["노무비", "재료비", "경비"] as const;
export const BUNDLE = [...COSTS, "계"] as const;
export const FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"] as const;
export const MONEY_NAMES = [...COSTS, "번지", "단계"];
export const RESERVED = [...BUNDLE, "번지", "단계", "그리고", "또는", ...FUNCS] as string[];
export const LINKS = ["숫자", "선택", "입력", "요소", "표", "단가산출", "식"];
export const WAYS = ["버림", "반올림", "올림"];
export const ENDS = ["비목버림", "큰나머지"];
export const CUT = {
줄: { 방법: "버림", 자리: 1 },
비목: { 방법: "버림", 자리: 0 },
끝전: "비목버림",
};
const PREC: Record<string, number> = {
또는: 1,
그리고: 2,
"<": 3,
"<=": 3,
">": 3,
">=": 3,
"==": 3,
"!=": 3,
"+": 4,
"-": 4,
"*": 5,
"/": 5,
};
const SHOW: Record<string, string> = { "*": "×", "/": "÷" };
const ALIAS: Record<string, string> = {
"×": "*",
"÷": "/",
"−": "-",
"≤": "<=",
"≥": ">=",
"≠": "!=",
};
const BASE = "\\p{L}[\\p{L}\\p{N}′']*";
const RUN = "[\\p{L}\\p{N}′']+";
const PART = `(?:\\{[^{}]*\\}|${RUN})`;
const ID_SRC = `${BASE}(?:_${PART})?(?:\\^${PART})?`;
const ID_AT = new RegExp(ID_SRC, "uy");
const ID_FULL = new RegExp(`^${ID_SRC}$`, "u");
const SPLIT = new RegExp(`^(${BASE})(?:_(${PART}))?(?:\\^(${PART}))?$`, "u");
const RUN_FULL = new RegExp(`^${RUN}$`, "u");
const NUM_AT = /\d+(?:\.\d+)?/y;
const FIELD_AT = new RegExp(RUN, "uy");
export const HOLE = /\{([^{}]+)\}/g;
export type Value = number | string | boolean | Record<string, unknown>;
export type Node =
| ["num", number, string]
| ["str", string]
| ["var", string]
| ["field", Node, string]
| ["neg", Node]
| ["pct", Node]
| ["call", string, Node[]]
| ["bin", string, Node, Node];
export interface Rule {
방법: string;
자리?: number;
}
export interface Link {
갈래?: string;
값?: unknown;
목록?: unknown[];
범위?: [number | null, number | null];
키?: string;
표?: string;
칸?: string;
단위?: string;
재료?: Record<string, unknown>;
지역?: string;
계약종별?: string;
조건?: Record<string, string>;
입력?: Record<string, string>;
}
export interface DocVar {
열쇠?: string;
기호?: string;
아래?: string;
위?: string;
이름?: string;
단위?: string;
연결?: Link;
}
export interface MoneyLine {
이름?: string;
비목?: string;
수량?: string | null;
단가?: string;
단위?: string;
묶음?: string;
기호?: string;
자리?: Rule;
번지?: string;
합밖?: boolean;
}
export interface FormulaDoc {
꼴?: string;
판?: number;
키?: string;
이름?: string;
결과단위?: string;
변수?: DocVar[];
식?: { 기호?: string; 식?: string }[];
결과?: string;
금액?: MoneyLine[];
끝수?: { 줄?: Rule; 비목?: Rule; 계?: Rule; 끝전?: string };
단계?: { 이름?: string; 단?: number; 줄?: string[]; 제외?: boolean }[];
조정?: { 배율?: Record<string, number>; 옮김?: string };
}
export interface Mark {
갈래: string;
글: string;
자리?: string;
기호?: string;
}
export class DocError extends Error {}
// ── 기호 ──────────────────────────────────────────────────────────────
const part = (text: string): string => (RUN_FULL.test(text) ? text : `{${text}}`);
/** 기호 · 아래 · 위 → 식 글 이름(`Q_시공^표`) */
export function symId(sign: string, low = "", up = ""): string {
let out = sign ?? "";
if (low) out += `_${part(low)}`;
if (up) out += `^${part(up)}`;
return out;
}
/** 식 글 이름 → [기호, 아래, 위] */
export function splitId(text: string): [string, string, string] {
const m = SPLIT.exec(text ?? "");
if (!m) return [text, "", ""];
const strip = (s?: string): string => (s ? (s.startsWith("{") ? s.slice(1, -1) : s) : "");
return [m[1], strip(m[2]), strip(m[3])];
}
export const varId = (v: DocVar): string => symId(v.기호 ?? "", v.아래 ?? "", v.위 ?? "");
export const goodId = (text: string): boolean =>
ID_FULL.test(text ?? "") && !RESERVED.includes(text);
// ── 풀이 ──────────────────────────────────────────────────────────────
class Parser {
i = 0;
constructor(readonly s: string) {}
fail(why: string): never {
throw new DocError(`${why} — 「${this.s}」 ${this.i}자`);
}
ws(): void {
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
}
op(...words: string[]): string | null {
this.ws();
for (const w of words) {
if (!this.s.startsWith(w, this.i)) continue;
if (w === "그리고" || w === "또는") {
ID_AT.lastIndex = this.i;
const m = ID_AT.exec(this.s);
if (m && m[0].length !== w.length) continue;
}
this.i += w.length;
return ALIAS[w] ?? w;
}
return null;
}
need(word: string): void {
if (!this.op(word)) this.fail(`「${word}」 없음`);
}
parse(): Node {
this.ws();
if (this.i >= this.s.length) this.fail("빈 식");
const node = this.expr();
this.ws();
if (this.i !== this.s.length) this.fail("식 끝에 남은 글자");
return node;
}
expr(): Node {
let node = this.both();
while (this.op("또는")) node = ["bin", "또는", node, this.both()];
return node;
}
both(): Node {
let node = this.compare();
while (this.op("그리고")) node = ["bin", "그리고", node, this.compare()];
return node;
}
compare(): Node {
const node = this.add();
const o = this.op("<=", ">=", "==", "!=", "≤", "≥", "≠", "<", ">");
return o ? ["bin", o, node, this.add()] : node;
}
add(): Node {
let node = this.mul();
for (let o = this.op("+", "-", "−"); o; o = this.op("+", "-", "−"))
node = ["bin", o, node, this.mul()];
return node;
}
mul(): Node {
let node = this.unary();
for (let o = this.op("*", "/", "×", "÷"); o; o = this.op("*", "/", "×", "÷"))
node = ["bin", o, node, this.unary()];
return node;
}
unary(): Node {
if (this.op("-", "−")) return ["neg", this.unary()];
let node = this.primary();
for (;;) {
this.ws();
if (this.s.startsWith(".", this.i)) {
this.i++;
node = ["field", node, this.field()];
} else if (this.s.startsWith("%", this.i)) {
this.i++;
node = ["pct", node];
} else return node;
}
}
field(): string {
if (this.s.startsWith("'", this.i)) return this.quoted();
FIELD_AT.lastIndex = this.i;
const m = FIELD_AT.exec(this.s);
if (!m) this.fail("칸 이름 없음");
this.i += m[0].length;
return m[0];
}
quoted(): string {
const end = this.s.indexOf("'", this.i + 1);
if (end < 0) this.fail("작은따옴표 안 닫힘");
const text = this.s.slice(this.i + 1, end);
this.i = end + 1;
return text;
}
primary(): Node {
this.ws();
if (this.op("(")) {
const node = this.expr();
this.need(")");
return node;
}
if (this.s.startsWith("'", this.i)) return ["str", this.quoted()];
NUM_AT.lastIndex = this.i;
const n = NUM_AT.exec(this.s);
if (n) {
this.i += n[0].length;
return ["num", Number(n[0]), n[0]];
}
ID_AT.lastIndex = this.i;
const m = ID_AT.exec(this.s);
if (!m) this.fail("기호 · 수 없음");
this.i += m[0].length;
const word = m[0];
this.ws();
if (this.s.startsWith("(", this.i)) {
if (!(FUNCS as readonly string[]).includes(word)) this.fail(`모르는 함수 「${word}」`);
this.i++;
const items = [this.expr()];
while (this.op(",")) items.push(this.expr());
this.need(")");
return ["call", word, items];
}
if (word === "그리고" || word === "또는" || (FUNCS as readonly string[]).includes(word))
this.fail(`「${word}」 자리 아님`);
return ["var", word];
}
}
/** 식 글 → 나무 · 틀리면 DocError */
export function parse(text: unknown): Node {
if (typeof text === "number") return ["num", text, String(text)];
return new Parser(String(text ?? "")).parse();
}
export function* walk(node: Node): Generator<Node> {
yield node;
switch (node[0]) {
case "bin":
yield* walk(node[2]);
yield* walk(node[3]);
break;
case "neg":
case "pct":
case "field":
yield* walk(node[1]);
break;
case "call":
for (const x of node[2]) yield* walk(x);
break;
}
}
/** 식이 부르는 이름(차례 · 겹침 없이 · 칸 이름 빼고) */
export function names(node: Node): string[] {
const out: string[] = [];
for (const n of walk(node)) if (n[0] === "var" && !out.includes(n[1])) out.push(n[1]);
return out;
}
// ── 글로 되돌림 ───────────────────────────────────────────────────────
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
/** 수 → 글(파이썬 Decimal 꼴 · 지수 없음) */
export function numText(n: Node & { 0: "num" }): string {
return n[2];
}
/** 나무 → 식 글(× ÷ · 나무 묶음 그대로 · 괄호는 꼭 필요한 만큼) */
export function textOf(node: Node): string {
switch (node[0]) {
case "num":
return node[2];
case "str":
return `'${node[1]}'`;
case "var":
return node[1];
case "field": {
const base = prec(node[1]) < 9 ? `(${textOf(node[1])})` : textOf(node[1]);
const name = RUN_FULL.test(node[2]) ? node[2] : `'${node[2]}'`;
return `${base}.${name}`;
}
case "pct":
return prec(node[1]) < 9 ? `(${textOf(node[1])})%` : `${textOf(node[1])}%`;
case "neg":
return node[1][0] === "bin" ? `-(${textOf(node[1])})` : `-${textOf(node[1])}`;
case "call":
return `${node[1]}(${node[2].map(textOf).join(", ")})`;
case "bin": {
const [, op, a, b] = node;
const mine = PREC[op];
let left = textOf(a);
let right = textOf(b);
if (prec(a) < mine || (mine === 3 && prec(a) === 3)) left = `(${left})`;
if (prec(b) <= mine) right = `(${right})`;
return `${left} ${SHOW[op] ?? op} ${right}`;
}
}
}
/** 식 글 다듬기 — 못 풀면 그대로 */
export function tidy(text: string): string {
try {
return textOf(parse(text));
} catch (e) {
if (e instanceof DocError) return String(text);
throw e;
}
}
function swap(node: Node, old: string, now: string): Node {
switch (node[0]) {
case "var":
return node[1] === old ? ["var", now] : node;
case "bin":
return ["bin", node[1], swap(node[2], old, now), swap(node[3], old, now)];
case "neg":
case "pct":
return [node[0], swap(node[1], old, now)];
case "field":
return ["field", swap(node[1], old, now), node[2]];
case "call":
return ["call", node[1], node[2].map((x) => swap(x, old, now))];
default:
return node;
}
}
/** 식 글 안 이름 바꿈 — 못 푸는 글은 그대로 */
export function renameText(text: string | null | undefined, old: string, now: string) {
if (text === null || text === undefined || text === "") return text;
let node: Node;
try {
node = parse(text);
} catch (e) {
if (e instanceof DocError) return text;
throw e;
}
return names(node).includes(old) ? textOf(swap(node, old, now)) : text;
}
export const holes = (text: unknown): string[] =>
[...String(text ?? "").matchAll(HOLE)].map((m) => m[1]);
/** 연결이 부르는 이름 — 키 · 재료 조건 `{이름}` · 지역 · 계약종별 · 조건 · 넘길 입력 식 */
export function linkNames(link: Link): string[] {
const out: string[] = [];
const add = (n: string): void => {
if (!out.includes(n)) out.push(n);
};
for (const t of [link.키, link.표, ...Object.values(link.재료 ?? {})]) holes(t).forEach(add);
for (const slot of ["지역", "계약종별"] as const) if (link[slot]) add(link[slot] as string);
for (const slot of ["조건", "입력"] as const)
for (const t of Object.values(link[slot] ?? {})) {
try {
names(parse(t)).forEach(add);
} catch (e) {
if (!(e instanceof DocError)) throw e;
}
}
return out;
}
/** 이름 → 정의 자리 목록(식 · 연결 · 금액) — 둘 이상 = 겹침 */
export function defined(doc: FormulaDoc): Map<string, string[]> {
const out = new Map<string, string[]>();
const put = (k: string, w: string): void => {
out.set(k, [...(out.get(k) ?? []), w]);
};
for (const v of doc.변수 ?? []) if ((v.연결?.갈래 ?? "식") !== "식") put(varId(v), "연결");
for (const l of doc.식 ?? []) put(String(l.기호 ?? ""), "식");
for (const l of doc.금액 ?? []) if (l.기호) put(String(l.기호), "금액");
return out;
}
const fixHoles = (text: string, old: string, now: string): string =>
text.replace(HOLE, (_, h: string) => `{${h === old ? now : h}}`);
/** 기호 · 첨자 바꿈 — 변수표 · 식 줄 머리 · 모든 식 · 연결 안 이름까지(제자리) */
export function rename(doc: FormulaDoc, old: string, sign: string, low = "", up = ""): FormulaDoc {
const now = symId(sign, low, up);
if (!goodId(now)) throw new DocError(`기호 꼴 아님 「${now}」`);
if (now !== old && defined(doc).has(now)) throw new DocError(`같은 기호 있음 「${now}」`);
for (const v of doc.변수 ?? []) {
if (varId(v) === old) Object.assign(v, { 기호: sign, 아래: low, 위: up });
const link = v.연결;
if (!link) continue;
if (link.키) link.키 = fixHoles(link.키, old, now);
if (link.표) link.표 = fixHoles(link.표, old, now);
if (link.재료)
for (const [k, x] of Object.entries(link.재료))
if (typeof x === "string") link.재료[k] = fixHoles(x, old, now);
if (link.지역 === old) link.지역 = now;
if (link.계약종별 === old) link.계약종별 = now;
for (const slot of ["조건", "입력"] as const) {
const box = link[slot];
if (box) for (const k of Object.keys(box)) box[k] = renameText(box[k], old, now) as string;
}
}
for (const l of doc.식 ?? []) {
if (l.기호 === old) l.기호 = now;
l.식 = renameText(l.식, old, now) as string;
}
for (const l of doc.금액 ?? []) {
if (l.기호 === old) l.기호 = now;
l.수량 = renameText(l.수량, old, now) as string | null;
l.단가 = renameText(l.단가, old, now) as string;
}
if (doc.결과 === old) doc.결과 = now;
return doc;
}
/** 빈 문서 — 빈 화면에서 시작(기본 끝수만 적어 둠) */
export function blankDoc(key = "", name = "", unit = "원"): FormulaDoc {
return {
꼴: "수식문서",
판: 1,
키: key,
이름: name,
결과단위: unit,
변수: [],
식: [],
금액: [],
끝수: { 줄: { ...CUT.줄 }, 비목: { ...CUT.비목 }, 끝전: CUT.끝전 },
};
}
// ── 검사 ──────────────────────────────────────────────────────────────
const mark = (갈래: string, 글: string, 자리 = "", 기호 = ""): Mark => ({
갈래,
글,
...(자리 ? { 자리 } : {}),
...(기호 ? { 기호 } : {}),
});
function goodWay(rule: unknown): boolean {
const r = rule as Rule | null;
const at = r?.자리 ?? 0;
return (
!!r &&
typeof r === "object" &&
WAYS.includes(r.방법) &&
typeof at === "number" &&
at >= 0 &&
at <= 6
);
}
/** 돌고 도는 정의(이름 → 부르는 이름들) — 고리마다 한 번 */
export function loops(uses: Map<string, string[]>): string[][] {
const out: string[][] = [];
const seen = new Set<string>();
const done = new Set<string>();
const go = (name: string, path: string[]): void => {
const at = path.indexOf(name);
if (at >= 0) {
const loop = path.slice(at);
const key = [...loop].sort().join("");
if (!seen.has(key)) {
seen.add(key);
out.push(loop);
}
return;
}
if (done.has(name) || !uses.has(name)) return;
for (const n of uses.get(name) ?? []) go(n, [...path, name]);
done.add(name);
};
for (const name of [...uses.keys()]) go(name, []);
return out;
}
/** 정적 검사 — 식 틀림 · 정의 필요 · 겹침 · 순환 · 기호 꼴 · 연결 꼴 · 금액 줄 · 끝수(셈 없음) */
export function check(doc: FormulaDoc): Mark[] {
const out: Mark[] = [];
const defs = defined(doc);
const moneySyms = new Set((doc.금액 ?? []).filter((x) => x.기호).map((x) => String(x.기호)));
for (const [name, where] of defs) {
if (!goodId(name)) out.push(mark("기호 꼴", `「${name}」 은 기호로 못 씀`, "", name));
if (where.length > 1)
out.push(mark("겹침", `「${name}」 정의가 ${where.join(" · ")} 에 둘 이상`, "", name));
}
const uses = new Map<string, string[]>();
const need = (text: unknown, where: string, money: boolean, head = ""): string[] => {
let got: string[];
try {
got = names(parse(text));
} catch (e) {
if (!(e instanceof DocError)) throw e;
out.push(mark("식 틀림", e.message, where, head));
return [];
}
for (const n of got) {
if (MONEY_NAMES.includes(n) && !money)
out.push(mark("금액 이름", `「${n}」 은 금액 줄에서만`, where, head));
else if (
!defs.has(n) &&
!(BUNDLE as readonly string[]).includes(n) &&
!MONEY_NAMES.includes(n)
)
out.push(mark("정의 필요", `「${n}」 정의 필요`, where, n));
else if (moneySyms.has(n) && !money)
out.push(mark("금액 이름", `「${n}」(금액 줄 기호)는 금액 줄에서만`, where, head));
}
return got;
};
for (const v of doc.변수 ?? []) {
const link = v.연결 ?? {};
const me = varId(v);
const kind = link.갈래 ?? "식";
if (!LINKS.includes(kind)) {
out.push(mark("연결 꼴", `「${me}」 연결 갈래 「${kind}」`, "", me));
continue;
}
if (kind === "식" && !defs.has(me)) out.push(mark("정의 필요", `「${me}」 식 줄 없음`, "", me));
const got: string[] = [];
for (const slot of ["조건", "입력"] as const)
for (const [k, t] of Object.entries(link[slot] ?? {}))
got.push(...need(t, `${me} ${slot} ${k}`, false, me));
const all = linkNames(link);
for (const h of all)
if (!got.includes(h) && !defs.has(h))
out.push(mark("정의 필요", `「${h}」 정의 필요`, me, h));
uses.set(me, all);
if (kind === "선택" && !Array.isArray(link.목록))
out.push(mark("연결 꼴", `「${me}」 선택 목록 없음`, "", me));
if (kind === "표" && !(link.표 && link.칸))
out.push(mark("연결 꼴", `「${me}」 표 · 칸 없음`, "", me));
if (kind === "요소" && !(link.키 || link.재료))
out.push(mark("연결 꼴", `「${me}」 요소 없음`, "", me));
if (kind === "단가산출" && !link.키)
out.push(mark("연결 꼴", `「${me}」 단가산출 없음`, "", me));
}
for (const l of doc.식 ?? []) {
const head = String(l.기호 ?? "");
if (!head) out.push(mark("식 틀림", "식 줄 기호 없음"));
uses.set(head, [...(uses.get(head) ?? []), ...need(l.식, head, false, head)]);
}
(doc.금액 ?? []).forEach((l, at) => {
const where = `금액 ${l.이름 || at + 1}`;
if (![...COSTS, "묶음"].includes(l.비목 as string))
out.push(mark("금액 줄", `${where} 비목 「${l.비목}」`));
if (l.수량 !== null && l.수량 !== undefined && l.수량 !== "")
need(l.수량, `${where} 수량`, true);
need(l.단가, `${where} 단가`, true);
if (l.자리 !== undefined && l.자리 !== null && !goodWay(l.자리))
out.push(mark("끝수", `${where} 자리 「${JSON.stringify(l.자리)}」`));
});
const cut = doc.끝수 ?? {};
for (const k of ["줄", "비목", "계"] as const)
if (cut[k] !== undefined && cut[k] !== null && !goodWay(cut[k]))
out.push(mark("끝수", `끝수 ${k} 「${JSON.stringify(cut[k])}」`));
if (cut.끝전 !== undefined && cut.끝전 !== null && !ENDS.includes(cut.끝전))
out.push(mark("끝수", `끝전 「${cut.끝전}」 — ${ENDS.join(" · ")}`));
if (doc.결과 && !defs.has(doc.결과))
out.push(mark("정의 필요", `결과 「${doc.결과}」 정의 필요`, "", doc.결과));
for (const loop of loops(uses))
out.push(mark("순환", [...loop, loop[0]].join(" → "), "", loop[0]));
return out;
}
+603
View File
@@ -0,0 +1,603 @@
/* =============================================================================
* common_util_formula_doc_calc.ts
* 단가산출 수식 문서 셈(PLAN 802-1 · 요구서_664 · 꼴 resources/master_data/_수식문서_틀.md)
* calcDoc(doc, links, given?) — 기호는 필요할 때 셈(차례 무관) · 예외 없음:
* 정의 필요 · 순환 · 0 나눔 · 식 틀림 = 표지 + 금액 「—」(null) · 값 없음 = 그 기호를 쓰는 줄만 비움
* 금액 = 줄 비목마다 끝수 · 합밖 · 제외 단계 · 앞 줄 누계 · 번지 · 단계 → 비목 끝수 → 끝전 → 계 끝수 → 조정
* 표 찾기(findRow · tableValue) = common_util_formula_doc_table.ts
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_calc.py · master_doc_link.py
* · 거울 시험 test_802_1_formula_doc.py(고칠 때 둘을 같이 고침) · 화면 · DOM · fetch 없음
* ========================================================================== */
import {
BUNDLE,
COSTS,
CUT,
DocError,
HOLE,
MONEY_NAMES,
check,
parse,
varId,
type DocVar,
type FormulaDoc,
type Mark,
type Node,
type Rule,
} from "./common_util_formula_doc";
const EPS = 1e-7; // B09_Estimation_Calc_Base EPS 와 같음
const DEEP = 20;
export class Gap extends Error {}
export class Broken extends Error {}
export class Blank {
constructor(readonly why: string) {}
}
export class Stop {
constructor(
readonly why: string,
readonly kind = "멈춤",
readonly sign = "",
) {}
}
type Bundle = Record<string, number>;
type Val = number | string | boolean | Bundle | Record<string, Bundle> | Blank | Stop;
type Payload = Record<string, unknown>;
export interface DocLinks {
element(p: Payload): unknown;
table(p: Payload): unknown;
doc(key: string): FormulaDoc | null | undefined;
}
export interface DocAnswer {
끝수: Record<string, unknown>;
값: Record<string, unknown>;
빈값: Record<string, string>;
표지: Mark[];
셈됨: boolean;
결과?: number | null;
까닭?: string;
줄?: Record<string, unknown>[];
노무비?: number | null;
재료비?: number | null;
경비?: number | null;
계?: number | null;
묶음?: Record<string, unknown>[];
머리?: string;
단계?: Record<string, unknown>[];
}
const bad = (v: unknown): v is Blank | Stop => v instanceof Blank || v instanceof Stop;
export const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
const isObj = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === "object" && !(v instanceof Blank) && !(v instanceof Stop);
// ── 수 ────────────────────────────────────────────────────────────────
function scaled(value: number, places: number, fn: (x: number) => number): number {
const a = Math.abs(value);
const out = fn(a * 10 ** places) / 10 ** places;
return value < 0 ? -out : out;
}
const WAY: Record<string, (x: number) => number> = {
버림: (x) => Math.floor(x + EPS),
올림: (x) => Math.ceil(x - EPS),
반올림: (x) => Math.floor(x + 0.5 + EPS),
};
/** 끝수 한 번 — 엑셀 ROUNDDOWN · ROUNDUP · ROUND 꼴 */
export function cut(value: number | null | undefined, rule?: Rule | null): number | null {
if (value === null || value === undefined) return null;
if (!rule) return value;
return scaled(value, Math.trunc(rule.자리 ?? 0), WAY[rule.방법]);
}
/** 구멍(`{기호}`)에 넣는 글 */
export const plainText = (v: unknown): string =>
isNum(v) ? String(Number(v.toPrecision(15))) : String(v);
export const same = (a: unknown, b: unknown): boolean =>
isNum(a) && isNum(b)
? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b))
: typeof a === typeof b && a === b;
export function binary(op: string, a: unknown, b: unknown): unknown {
if (op === "==") return same(a, b);
if (op === "!=") return !same(a, b);
const nums = (isNum(a) || typeof a === "boolean") && (isNum(b) || typeof b === "boolean");
const x = Number(a);
const y = Number(b);
if (["<", "<=", ">", ">="].includes(op)) {
if (!(nums || (typeof a === "string" && typeof b === "string"))) throw new TypeError(op);
const lt = nums ? x < y && !same(x, y) : (a as string) < (b as string);
const eq = nums ? same(x, y) : a === b;
return op === "<" ? lt : op === "<=" ? lt || eq : op === ">" ? !lt && !eq : !lt;
}
if (op === "+" && typeof a === "string" && typeof b === "string") return a + b;
if (!nums) throw new TypeError(op);
return op === "+" ? x + y : op === "-" ? x - y : op === "*" ? x * y : x / y;
}
// ── 셈 한 벌 ──────────────────────────────────────────────────────────
type Memo = Map<string, DocAnswer>;
class Run {
vars = new Map<string, DocVar>();
lines = new Map<string, string | undefined>();
values = new Map<string, Val>();
busy: string[] = [];
marks: Mark[] = [];
moneySyms: Set<string>;
constructor(
readonly doc: FormulaDoc,
readonly links: DocLinks,
readonly given: Record<string, unknown>,
readonly stack: string[],
readonly memo: Memo,
) {
for (const v of doc.변수 ?? []) this.vars.set(varId(v), v);
for (const l of doc.식 ?? []) this.lines.set(String(l.기호 ?? ""), l.식);
this.moneySyms = new Set((doc.금액 ?? []).filter((x) => x.기호).map((x) => String(x.기호)));
}
mark(stop: Stop): Stop {
const one: Mark = { 갈래: stop.kind, 글: stop.why, ...(stop.sign ? { 기호: stop.sign } : {}) };
if (!this.marks.some((m) => JSON.stringify(m) === JSON.stringify(one))) this.marks.push(one);
return stop;
}
get(name: string, scope?: Record<string, Val>): Val {
if (scope && name in scope) return scope[name];
if (MONEY_NAMES.includes(name) || this.moneySyms.has(name))
return this.mark(new Stop(`「${name}」 은 금액 줄에서만`, "금액 이름", name));
if (this.values.has(name)) return this.values.get(name) as Val;
const at = this.busy.indexOf(name);
if (at >= 0)
return this.mark(new Stop([...this.busy.slice(at), name].join(" → "), "순환", name));
this.busy.push(name);
let value: Val;
try {
value = this.define(name);
} finally {
this.busy.pop();
}
if (value instanceof Stop) this.mark(value);
this.values.set(name, value);
return value;
}
define(name: string): Val {
const v = this.vars.get(name);
const kind = v?.연결?.갈래 ?? "식";
if (this.lines.has(name) && kind !== "식")
return new Stop(`「${name}」 정의가 식 줄 · 연결 둘`, "겹침", name);
if (this.lines.has(name)) return this.expr(this.lines.get(name), name);
if (!v || kind === "식") return new Stop(`「${name}」 정의 필요`, "정의 필요", name);
return this.link(v, name);
}
expr(text: unknown, where = "", scope?: Record<string, Val>): Val {
let node: Node;
try {
node = parse(text);
} catch (e) {
if (!(e instanceof DocError)) throw e;
return this.mark(new Stop(e.message, "식 틀림", where));
}
return this.ev(node, scope, where);
}
arith(fn: () => unknown, where: string): Val {
try {
const got = fn();
if (typeof got === "number" && !Number.isFinite(got))
return this.mark(new Stop(`${where} — 수 아닌 값을 셈`, "셈 틀림", where));
return got as Val;
} catch (e) {
if (!(e instanceof TypeError)) throw e;
return this.mark(new Stop(`${where} — 수 아닌 값을 셈`, "셈 틀림", where));
}
}
ev(node: Node, scope: Record<string, Val> | undefined, where: string): Val {
switch (node[0]) {
case "num":
case "str":
return node[1];
case "var":
return this.get(node[1], scope);
case "field": {
const base = this.ev(node[1], scope, where);
if (bad(base)) return base;
if (!isObj(base) || !(node[2] in base))
return this.mark(new Stop(`칸 「${node[2]}」 없음`, "칸 없음", where));
const got = base[node[2]];
return got === null || got === undefined
? new Blank(`칸 「${node[2]}」 비어 있음`)
: (got as Val);
}
case "neg": {
const a = this.ev(node[1], scope, where);
return bad(a) ? a : this.arith(() => binary("-", 0, a), where);
}
case "pct": {
const a = this.ev(node[1], scope, where);
return bad(a) ? a : this.arith(() => binary("/", a, 100), where);
}
case "call":
return this.call(node[1], node[2], scope, where);
case "bin": {
const op = node[1];
const a = this.ev(node[2], scope, where);
if (bad(a)) return a;
if ((op === "그리고" && !a) || (op === "또는" && a)) return a;
const b = this.ev(node[3], scope, where);
if (bad(b) || op === "그리고" || op === "또는") return b;
if (op === "/" && isNum(b) && b === 0)
return this.mark(new Stop(`${where} — 0 나눔`, "0 나눔", where));
return this.arith(() => binary(op, a, b), where);
}
}
}
call(name: string, items: Node[], scope: Record<string, Val> | undefined, where: string): Val {
if (name === "만약") {
if (items.length !== 3) return this.mark(new Stop("만약(조건, 참, 거짓)", "식 틀림", where));
const test = this.ev(items[0], scope, where);
if (bad(test)) return test;
return this.ev(test ? items[1] : items[2], scope, where);
}
const values = items.map((x) => this.ev(x, scope, where));
const b = values.find(bad);
if (b) return b;
if (name === "최소" || name === "최대")
return this.arith(() => {
if (!values.every(isNum)) throw new TypeError(name);
return (name === "최소" ? Math.min : Math.max)(...(values as number[]));
}, where);
if (values.length > 2) return this.mark(new Stop(`${name}(값, 자리)`, "식 틀림", where));
return this.arith(() => {
const places = values.length > 1 ? values[1] : 0;
if (!isNum(values[0]) || !isNum(places)) throw new TypeError(name);
return cut(values[0], { 방법: name, 자리: Math.trunc(places) });
}, where);
}
fill(text: string): string | Blank | Stop {
let hit: Blank | Stop | null = null;
const out = text.replace(HOLE, (_, h: string) => {
const got = this.get(h);
if (bad(got)) {
hit = hit ?? got;
return "";
}
return plainText(got);
});
return hit ?? out;
}
link(v: DocVar, name: string): Val {
const link = v.연결 ?? {};
const kind = link.갈래;
const own = v.열쇠;
if (kind === "숫자" || kind === "입력" || kind === "선택") {
const given = !!own && own in this.given;
let value = (given ? this.given[own as string] : link.값) as Val | null | undefined;
if (kind === "숫자" && isObj(value)) {
// 손 묶음(노 · 재 · 경 — 엑셀 값 그대로)
const box = value as Record<string, unknown>;
if (!COSTS.every((k) => isNum(box[k])))
return new Stop(`「${name}」 묶음 값은 노무비 · 재료비 · 경비 수`, "연결 꼴", name);
return bundleOf(Object.fromEntries(COSTS.map((k) => [k, box[k] as number])));
}
if (value === null || value === undefined || value === "")
return new Blank(`「${v.이름 || name}」 값 비어 있음`);
if (kind === "입력" && typeof value === "string") {
const n = Number(value.trim().replace(/,/g, ""));
if (value.trim() !== "" && Number.isFinite(n)) value = n;
}
if (kind === "선택" && !given) {
const hit = (link.목록 ?? []).find((x) => plainText(x) === plainText(value));
if (hit === undefined)
return new Stop(`「${name}」 값 「${plainText(value)}」 목록 밖`, "목록 밖", name);
value = hit as Val;
}
if (kind === "입력" && link.범위 && isNum(value)) {
const [low, high] = link.범위;
if ((low !== null && value < low) || (high !== null && value > high))
return new Stop(
`「${name}」 ${plainText(value)} 범위 [${low}, ${high}] 밖`,
"범위 밖",
name,
);
}
return value;
}
const p: Payload = {};
const hits: (Blank | Stop)[] = [];
const keep = (x: Val | string): Val | string => {
if (bad(x)) hits.push(x);
return x;
};
for (const key of ["키", "표"] as const) if (link[key]) p[key] = keep(this.fill(link[key]!));
if (link.재료 && typeof link.재료 === "object") {
const box: Record<string, unknown> = {};
for (const [k, x] of Object.entries(link.재료))
box[k] = typeof x === "string" ? keep(this.fill(x)) : x;
p.재료 = box;
}
for (const slot of ["지역", "계약종별"] as const)
if (link[slot]) p[slot] = keep(this.get(link[slot]!));
for (const slot of ["조건", "입력"] as const)
if (link[slot] && typeof link[slot] === "object") {
const box: Record<string, unknown> = {};
for (const [k, t] of Object.entries(link[slot]!)) box[k] = keep(this.expr(t, name));
p[slot] = box;
}
if (hits.length) return hits[0];
if (link.칸) p.칸 = link.칸;
if (link.단위) p.단위 = link.단위;
try {
if (kind === "요소") return this.links.element(p) as Val;
if (kind === "표") return this.links.table(p) as Val;
if (kind === "단가산출") return this.sub(p, name);
} catch (e) {
if (e instanceof Gap) return new Blank(e.message);
if (e instanceof Broken) return new Stop(`「${name}」 ${e.message}`, "연결 끊김", name);
throw e;
}
return new Stop(`「${name}」 연결 갈래 「${kind}」`, "연결 꼴", name);
}
sub(p: Payload, name: string): Val {
const key = String(p.키);
if (this.stack.includes(key) || this.stack.length > DEEP)
return new Stop(`단가산출 부름 돌고 돎 — ${[...this.stack, key].join(" → ")}`, "순환", name);
const doc = this.links.doc(key);
if (!doc) throw new Broken(`없는 단가산출 「${key}」`);
const given = (p.입력 ?? {}) as Record<string, unknown>;
const memoKey = `${key}|${Object.keys(given)
.sort()
.map((k) => `${k}=${plainText(given[k])}`)
.join("|")}`;
if (!this.memo.has(memoKey))
this.memo.set(memoKey, calcDoc(doc, this.links, given, [...this.stack, key], this.memo));
const got = this.memo.get(memoKey) as DocAnswer;
if (!got.셈됨) {
// 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤
const why = got.까닭 || got.표지.map((m) => m.글).join(" · ");
if (got.까닭) return new Blank(`단가산출 「${key}」 못 셈 — ${why}`);
return new Stop(`단가산출 「${key}」 못 셈 — ${why}`, "부름 멈춤", name);
}
let value: Val;
if ("결과" in got) value = got.결과 as number;
else {
value = Object.fromEntries(BUNDLE.map((k) => [k, got[k] as number]));
}
const col = p.칸 as string | undefined;
if (col) {
if (!isObj(value) || !(col in value)) throw new Broken(`칸 「${col}」 없음`);
return (value as Bundle)[col];
}
return value;
}
}
// ── 금액 ──────────────────────────────────────────────────────────────
const zero = (): Bundle => ({ 노무비: 0, 재료비: 0, 경비: 0 });
const addTo = (acc: Bundle, parts: Bundle): void => {
for (const [k, v] of Object.entries(parts))
if (k in acc && v !== null && v !== undefined) acc[k] += v;
};
const bundleOf = (acc: Bundle): Bundle => ({ ...acc, 계: COSTS.reduce((s, k) => s + acc[k], 0) });
type Step = NonNullable<FormulaDoc["단계"]>[number];
function coverOf(doc: FormulaDoc): [Step, Set<number>][] {
const where = new Map<string, number>();
(doc.금액 ?? []).forEach((x, i) => {
if (x.이름) where.set(x.이름, i);
});
const out: [Step, Set<number>][] = [];
let open: [number, Set<number>][] = [];
for (const st of doc.단계 ?? []) {
const level = Number(st.단 ?? 1) || 1;
const mine = new Set((st.줄 ?? []).filter((n) => where.has(n)).map((n) => where.get(n)!));
const keep: [number, Set<number>][] = [];
for (const [low, got] of open)
if (low < level) got.forEach((i) => mine.add(i));
else keep.push([low, got]);
open = st.제외 ? keep : [...keep, [level, mine]];
out.push([st, mine]);
}
return out;
}
type StepRow = Bundle & { 이름?: string; 단?: number; 제외?: boolean };
function stepsOf(cover: [Step, Set<number>][], outs: { 비목: Bundle }[]): StepRow[] {
return cover.map(([st, mine]) => {
const acc = zero();
[...mine].sort((a, b) => a - b).forEach((i) => i < outs.length && addTo(acc, outs[i].비목));
return {
이름: st.이름,
단: st.단,
...bundleOf(acc),
...(st.제외 ? { 제외: true } : {}),
} as StepRow;
});
}
function money(run: Run, doc: FormulaDoc, rules: Record<string, Rule>) {
const lines = doc.금액 ?? [];
const cover = coverOf(doc);
const skip = new Set<number>();
lines.forEach((x, i) => x.합밖 && skip.add(i));
for (const [st, mine] of cover) if (st.제외) mine.forEach((i) => skip.add(i));
const running = zero();
const syms: Record<string, Val> = {};
const outs: (Record<string, unknown> & { 비목: Bundle })[] = [];
let stopped = false;
lines.forEach((line, i) => {
const places: Record<string, Bundle> = {};
lines.slice(0, outs.length).forEach((l, j) => {
if (l.번지) addTo((places[String(l.번지)] ??= zero()), outs[j].비목);
});
const stepMap: Record<string, Bundle> = {};
for (const s of stepsOf(cover, outs))
stepMap[String(s.이름)] = Object.fromEntries(BUNDLE.map((k) => [k, s[k]])) as Bundle;
const scope: Record<string, Val> = {
...running,
번지: Object.fromEntries(Object.entries(places).map(([k, v]) => [k, bundleOf(v)])),
단계: stepMap,
...syms,
};
const where = String(line.이름 || `금액 ${i + 1}`);
const qty =
line.수량 !== null && line.수량 !== undefined && line.수량 !== ""
? run.expr(line.수량, where, scope)
: 1;
const price = run.expr(line.단가, where, scope);
const kind = line.비목 as string;
let parts: Bundle = {};
let why: string | null = null;
const stop = (text: string): void => {
stopped = true;
why = run.mark(new Stop(`${where} ${text}`, "셈 틀림", where)).why;
};
if (qty instanceof Stop || price instanceof Stop) {
stopped = true;
why = (qty instanceof Stop ? qty : (price as Stop)).why;
} else if (qty instanceof Blank) why = qty.why;
else if (!isNum(qty)) stop("수량이 수 아님");
else if (qty === 0) for (const k of kind === "묶음" ? COSTS : [kind]) parts[k] = 0;
else if (price instanceof Blank) why = price.why;
else if (kind === "묶음") {
if (isObj(price) && COSTS.every((k) => isNum((price as Bundle)[k])))
for (const k of COSTS) parts[k] = qty * (price as Bundle)[k];
else stop("묶음 줄 단가가 단가산출 묶음 아님");
} else if ((COSTS as readonly string[]).includes(kind) && isNum(price))
parts[kind] = qty * price;
else stop(isObj(price) ? "묶음 값 — 칸(.계 · .노무비 …) 필요" : "단가가 수 아님");
const rule = line.자리 ?? rules.줄;
parts = Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, cut(v, rule) as number]));
const keys = Object.keys(parts);
const amount = keys.length ? keys.reduce((s, k) => s + parts[k], 0) : null;
if (!skip.has(i)) addTo(running, parts);
if (line.기호) syms[String(line.기호)] = amount ?? new Blank(why ?? "빈 줄");
outs.push({
이름: line.이름 ?? "",
비목: parts,
수량: isNum(qty) ? qty : null,
단가: isNum(price) || isObj(price) ? price : null,
금액: amount,
...(why ? { 까닭: why } : {}),
...(skip.has(i) ? { 합밖: true } : {}),
...(line.묶음 ? { 묶음: line.묶음 } : {}),
});
});
return { outs, running, stopped, cover };
}
function settle(doc: FormulaDoc, rules: Record<string, Rule | string>, total: Bundle): Bundle {
const 비목 = rules.비목 as Rule;
let costs: Bundle = Object.fromEntries(COSTS.map((k) => [k, cut(total[k], 비목) as number]));
if (rules.끝전 === "큰나머지") {
const whole = COSTS.reduce((s, k) => s + total[k], 0);
const rest = Math.round((cut(whole, 비목) as number) - COSTS.reduce((s, k) => s + costs[k], 0));
const order = [...COSTS].sort(
(a, b) =>
costs[a] - total[a] - (costs[b] - total[b]) ||
[...COSTS].reverse().indexOf(a) - [...COSTS].reverse().indexOf(b),
);
for (const k of order.slice(0, rest)) costs[k] += 1; // 파이썬 order[:rest] 와 같음
}
if (rules.계) {
const r = rules.계 as Rule;
costs = Object.fromEntries(COSTS.map((k) => [k, cut(costs[k], r) as number]));
}
const adjust = doc.조정 ?? {};
for (const [k, rate] of Object.entries(adjust.배율 ?? {})) costs[k] = costs[k] * Number(rate);
if (adjust.옮김) {
const whole = COSTS.reduce((s, k) => s + costs[k], 0);
costs = Object.fromEntries(COSTS.map((k) => [k, k === adjust.옮김 ? whole : 0]));
}
return Object.fromEntries(COSTS.map((k) => [k, cut(costs[k], 비목) as number]));
}
function groupsOf(outs: { 비목: Bundle; 묶음?: unknown; 합밖?: unknown }[]) {
const order: string[] = [];
const acc = new Map<string, Bundle>();
for (const one of outs) {
const name = String(one.묶음 ?? "");
if (!acc.has(name)) {
order.push(name);
acc.set(name, zero());
}
if (!one.합밖) addTo(acc.get(name)!, one.비목);
}
return order.map((n) => ({ 이름: n, ...bundleOf(acc.get(n)!) }));
}
/** 끝수 — 문서 칸 › 기본(CUT) · 셈 답에 그대로 실음 */
export function rulesOf(doc: FormulaDoc): Record<string, Rule | string> {
const got = (doc.끝수 ?? {}) as Record<string, Rule | string | undefined>;
const base = CUT as Record<string, Rule | string>;
const out: Record<string, Rule | string> = {};
for (const k of ["줄", "비목", "끝전", "계"]) {
const v = got[k] || base[k];
if (v) out[k] = v;
}
return out;
}
const STOP_KINDS = ["기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"];
/** 수식 문서 셈 — given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음 */
export function calcDoc(
doc: FormulaDoc,
links: DocLinks,
given: Record<string, unknown> = {},
stack: string[] = [],
memo: Memo = new Map(),
): DocAnswer {
if (!stack.length && doc.키) stack = [String(doc.키)]; // 제 키 — 저를 부르면 순환
const run = new Run(doc, links, given, stack, memo);
for (const one of check(doc)) if (STOP_KINDS.includes(one.갈래)) run.marks.push(one);
const rules = rulesOf(doc);
for (const name of [...run.lines.keys(), ...run.vars.keys()]) if (name) run.get(name);
const out = { 끝수: rules } as unknown as DocAnswer;
if (doc.결과) {
const value = run.get(String(doc.결과));
out.결과 = isNum(value) ? value : null;
if (value instanceof Blank) out.까닭 = value.why;
out.셈됨 = isNum(value);
} else if ((doc.금액 ?? []).length) {
const got = money(run, doc, rules as Record<string, Rule>);
const stopped =
got.stopped || run.marks.some((m) => ["겹침", "금액 줄", "끝수"].includes(m.갈래));
const costs = stopped ? null : settle(doc, rules, got.running);
out.줄 = got.outs;
for (const k of COSTS) out[k] = costs ? costs[k] : null;
out.계 = costs ? COSTS.reduce((s, k) => s + costs[k], 0) : null;
out.묶음 = groupsOf(got.outs);
out.머리 = out.묶음.length > 1 ? "합계" : "계";
if (doc.단계?.length) out.단계 = stepsOf(got.cover, got.outs);
out.셈됨 = costs !== null;
} else {
run.marks.push({ 갈래: "빈 문서", 글: "금액 줄 · 결과 없음" });
Object.assign(out, { 줄: [], 노무비: null, 재료비: null, 경비: null, 계: null, 묶음: [] });
Object.assign(out, { 머리: "계", 셈됨: false });
}
out.값 = {};
out.빈값 = {};
for (const [k, v] of run.values) {
out.값[k] = bad(v) ? null : v;
if (v instanceof Blank) out.빈값[k] = v.why;
}
out.표지 = run.marks;
return out;
}
@@ -0,0 +1,114 @@
/* =============================================================================
* 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;
}