Files
Aislo/common_util/common_util_formula_doc.ts
T
eomsangdonandClaude Opus 5.5 53210f9a7a feat(810-2): 원파일 구조 줄 → 수식 문서 변환 · 원파일 우선 올림 · 전수 대조
- 새 M02_Doc_NativeEst(EST 산근 블록 → 가져온 로직 · 단가 \x03 · 변수 \x11 · 줄참조 \x0c 표지로 정함 · 변수 이름 그대로 · 분수 그림 공식 = 위아래 줄 칸 · 기계 · 산근 · 견적 세 줄 = 로직 값묶음 한 줄 · 소계 61 = 합 밖 · 율 58 · 62 = 덧줄 · 못 읽은 줄 = 보인 값 얼림 + 참고 표지 · 품셈 조건 글 = 추정 표지)
- 새 M02_Doc_FromNative(원파일 읽기 · 둘 꼴 · 블록 번 = 엑셀 길과 같음 · STmate = 원문 그대로 M02_San_Emit.stmate · 같은 공사 판정 · EST 일위대가 → 실무자료 호표 꼴)
- M02_Sangeun_FromHopyo — 산근 자리 칸 원파일 · 원파일 우선 emit · 안쪽 산근 원파일 코드로 · upload_native(같은 공사 엑셀 HP 에 붙음 · 없으면 새 HP)
- M02_Hopyo_Router 올림 — .estx · .stc 이면 upload_native(한 덩이)
- M02_Doc_Import — 원파일 문서 소계 덧줄 비목 칸 비움 · 수 하나 변수 출처 원파일 · 머리 원파일
- 수식 문서 셈 짝(master_doc · master_doc_calc ↔ common_util_formula_doc · _calc) — 합밖 + 비목 비움 = 금액만(소계 보임) · 그 밖 그대로
- M02_San_Estx 구조 줄 색인(Index) 칸 · 어디서 왔나 카드 「EST · 원파일」
- 전수 대조 — EST 산근 1,849 · STC 371 블록 수식 문서 셈 = 원파일 저장값(STC 계약단가 82 = 낙찰률 버림 뒤) · 표지 없는 틀린 줄 0 · 목록 docs/raw/요구_1104/_목록_810-2_원파일변환.xlsx
- 시험 test_810_2_native_doc(전수 · 꼴 · 문서 셈 거울 · 올림 분기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 20:11:03 +09:00

625 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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) && !(l.합밖 && !l.비목))
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;
}