/* ============================================================================= * 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 = { 또는: 1, 그리고: 2, "<": 3, "<=": 3, ">": 3, ">=": 3, "==": 3, "!=": 3, "+": 4, "-": 4, "*": 5, "/": 5, }; const SHOW: Record = { "*": "×", "/": "÷" }; const ALIAS: Record = { "×": "*", "÷": "/", "−": "-", "≤": "<=", "≥": ">=", "≠": "!=", }; 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; 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; 계약종별?: string; 조건?: Record; 입력?: Record; } 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 }; } 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 { 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 { const out = new Map(); 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[][] { const out: string[][] = []; const seen = new Set(); const done = new Set(); 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(); 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; }