- 새 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>
625 lines
22 KiB
TypeScript
625 lines
22 KiB
TypeScript
/* =============================================================================
|
||
* 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(" |