From f7038cc28029019f5e2ad2be618e2ff82dca96a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 18:41:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(802-1):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=88=98=EC=8B=9D=20=EB=AC=B8=EC=84=9C=20=EA=BC=B4?= =?UTF-8?q?=20=C2=B7=20=EA=B3=84=EC=82=B0=EA=B8=B0(=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=8D=AC=20=C2=B7=20TS=20=EC=A7=9D)=20=C2=B7=20=ED=91=9C?= =?UTF-8?q?=EC=A7=80=20=C2=B7=20=EA=BC=B4=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — 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 --- common_util/common_util_formula_doc.ts | 624 ++++++++++++++++++ common_util/common_util_formula_doc_calc.ts | 603 +++++++++++++++++ common_util/common_util_formula_doc_table.ts | 114 ++++ resources/master_data/_수식문서_틀.md | 120 ++++ resources/master_data/scripts/master_doc.py | 545 +++++++++++++++ .../master_data/scripts/master_doc_calc.py | 544 +++++++++++++++ .../master_data/scripts/master_doc_convert.py | 564 ++++++++++++++++ .../master_data/scripts/master_doc_link.py | 229 +++++++ resources/tester/helper_802_1_formula_doc.cjs | 73 ++ resources/tester/test_802_1_formula_doc.py | 588 +++++++++++++++++ 10 files changed, 4004 insertions(+) create mode 100644 common_util/common_util_formula_doc.ts create mode 100644 common_util/common_util_formula_doc_calc.ts create mode 100644 common_util/common_util_formula_doc_table.ts create mode 100644 resources/master_data/_수식문서_틀.md create mode 100644 resources/master_data/scripts/master_doc.py create mode 100644 resources/master_data/scripts/master_doc_calc.py create mode 100644 resources/master_data/scripts/master_doc_convert.py create mode 100644 resources/master_data/scripts/master_doc_link.py create mode 100644 resources/tester/helper_802_1_formula_doc.cjs create mode 100644 resources/tester/test_802_1_formula_doc.py diff --git a/common_util/common_util_formula_doc.ts b/common_util/common_util_formula_doc.ts new file mode 100644 index 000000000..c30c73108 --- /dev/null +++ b/common_util/common_util_formula_doc.ts @@ -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 = { + 또는: 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; +} diff --git a/common_util/common_util_formula_doc_calc.ts b/common_util/common_util_formula_doc_calc.ts new file mode 100644 index 000000000..4ff30a87a --- /dev/null +++ b/common_util/common_util_formula_doc_calc.ts @@ -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; +type Val = number | string | boolean | Bundle | Record | Blank | Stop; +type Payload = Record; + +export interface DocLinks { + element(p: Payload): unknown; + table(p: Payload): unknown; + doc(key: string): FormulaDoc | null | undefined; +} + +export interface DocAnswer { + 끝수: Record; + 값: Record; + 빈값: Record; + 표지: Mark[]; + 셈됨: boolean; + 결과?: number | null; + 까닭?: string; + 줄?: Record[]; + 노무비?: number | null; + 재료비?: number | null; + 경비?: number | null; + 계?: number | null; + 묶음?: Record[]; + 머리?: string; + 단계?: Record[]; +} + +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 => + !!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 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; + +class Run { + vars = new Map(); + lines = new Map(); + values = new Map(); + busy: string[] = []; + marks: Mark[] = []; + moneySyms: Set; + + constructor( + readonly doc: FormulaDoc, + readonly links: DocLinks, + readonly given: Record, + 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): 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): 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 | 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 | 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; + 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 = {}; + 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 = {}; + 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; + 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[number]; + +function coverOf(doc: FormulaDoc): [Step, Set][] { + const where = new Map(); + (doc.금액 ?? []).forEach((x, i) => { + if (x.이름) where.set(x.이름, i); + }); + const out: [Step, Set][] = []; + let open: [number, Set][] = []; + 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][] = []; + 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][], 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) { + const lines = doc.금액 ?? []; + const cover = coverOf(doc); + const skip = new Set(); + 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 = {}; + const outs: (Record & { 비목: Bundle })[] = []; + let stopped = false; + lines.forEach((line, i) => { + const places: Record = {}; + lines.slice(0, outs.length).forEach((l, j) => { + if (l.번지) addTo((places[String(l.번지)] ??= zero()), outs[j].비목); + }); + const stepMap: Record = {}; + for (const s of stepsOf(cover, outs)) + stepMap[String(s.이름)] = Object.fromEntries(BUNDLE.map((k) => [k, s[k]])) as Bundle; + const scope: Record = { + ...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, 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(); + 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 { + const got = (doc.끝수 ?? {}) as Record; + const base = CUT as Record; + const out: Record = {}; + 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 = {}, + 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); + 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; +} diff --git a/common_util/common_util_formula_doc_table.ts b/common_util/common_util_formula_doc_table.ts new file mode 100644 index 000000000..96b1a6497 --- /dev/null +++ b/common_util/common_util_formula_doc_table.ts @@ -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; + +export interface DocTable { + 키?: string; + 원문번호?: string; + 조건?: Record; + 범위규칙?: string; + 값칸?: Record; + 줄: Record[]; +} + +function matchRow(table: DocTable, row: Record, 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, + want?: string, +): Record { + 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, 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; +} diff --git a/resources/master_data/_수식문서_틀.md b/resources/master_data/_수식문서_틀.md new file mode 100644 index 000000000..7974922bf --- /dev/null +++ b/resources/master_data/_수식문서_틀.md @@ -0,0 +1,120 @@ +# 수식 문서 틀 — 단가산출 기초 정본 + +단가산출 기초 = 수식 문서 하나. 빈 문서에서 기호로 식을 쓰고 · 기호에 이름 · 연결을 정함. 계산기는 문서를 그대로 셈. + +- 셈 = `scripts/master_doc_calc.py` ↔ `common_util/common_util_formula_doc_calc.ts`(짝) +- 풀이 · 글 · 검사 = `scripts/master_doc.py` ↔ `common_util/common_util_formula_doc.ts`(짝) +- 표 찾기 · M01 연결 풀이 = `scripts/master_doc_link.py` ↔ `common_util/common_util_formula_doc_table.ts`(표 찾기 짝) +- 옛 로직 변환 = `scripts/master_doc_convert.py` · 빌더 `scripts/build_수식문서_802_2.py`(`--check`) +- 거울 · 꼴 시험 = `resources/tester/test_802_1_formula_doc.py` · 변환 대조 = `test_802_2_convert.py` + +## 1. 파일 + +- 자리 = `수식문서/수식_<옛 로직 파일 꼬리>.json` — 옛 로직 파일마다 한 벌(`로직_건설품셈_08장_건설기계.json` → `수식_건설품셈_08장_건설기계.json`). +- 머리 = `그룹`(「수식문서」) · `로직파일`(옛 로직 파일 이름) · `줄`(문서 목록 · 옛 로직 차례). +- UTF-8 · LF · 들여쓰기 2칸 · 변수 · 식 · 금액 줄 하나 = 한 줄. +- 옛 로직 JSON 과 따로 — 빌더만 씀(옛 로직 JSON 은 안 씀). + +## 2. 문서 + +```json +{ + "꼴": "수식문서", + "판": 1, + "키": "GC000211", + "이름": "합판거푸집 설치 및 해체", + "결과단위": "원/㎡", + "출처": "건설품셈 공통 6-3-1", + "변수": [ + {"열쇠": "사용횟수", "기호": "n", "아래": "사용", "위": "", "이름": "사용횟수", "단위": "", "연결": {"갈래": "선택", "목록": ["1회", "2회"], "값": "1회"}}, + {"열쇠": "시공량", "기호": "Q", "아래": "시공", "위": "", "이름": "시공량", "단위": "", "연결": {"갈래": "식"}}, + {"열쇠": "w_형틀목공", "기호": "w", "아래": "형틀목공", "위": "", "이름": "형틀목공노임", "단위": "", "연결": {"갈래": "요소", "키": "LB000007"}} + ], + "식": [ + {"기호": "Q_시공", "식": "Q_시공^표 × (1 - r_높이 ÷ 100)"} + ], + "금액": [ + {"이름": "형틀목공", "단위": "인", "비목": "노무비", "수량": "Q_형틀목공", "단가": "w_형틀목공"}, + {"이름": "공구손료", "단위": "식", "비목": "경비", "단가": "노무비 × 1 ÷ 100"} + ], + "끝수": {"줄": {"방법": "버림", "자리": 1}, "비목": {"방법": "버림", "자리": 0}, "끝전": "비목버림"} +} +``` + +- 돈 문서 = `금액` 줄 · 돈 아닌 문서(시간당 작업량 등) = `결과`(결과 기호) · `금액` 비움. +- `변수` = 변수표 — `열쇠`(다른 문서가 입력을 넘길 때 부르는 이름 · 안 바뀜) · `기호` · `아래` · `위`(첨자) · `이름`(국문) · `단위` · `연결` · `설명` · `출처`(있을 때). +- `식` = 식 줄 `기호 = 식` · 차례는 보이는 차례(셈은 차례 무관 — 기호를 필요할 때 셈). +- 식 줄 머리 기호의 변수 = 연결 갈래 「식」. 연결 갈래가 「식」 인데 식 줄이 없으면 「정의 필요」. + +## 3. 연결 갈래 + +| 갈래 | 칸 | 값 | +| --- | --- | --- | +| 숫자 | `값`(수 · 또는 묶음 `{노무비, 재료비, 경비}`) | 고정 수 · 손 묶음(엑셀 값) | +| 선택 | `목록` · `값` | 옵션 목록에서 고른 값(목록 밖 = 멈춤) | +| 입력 | `값` · `범위?` `[아래, 위]` | 직접 넣은 값(빈 값 = 값 없음 · 범위 밖 = 멈춤) | +| 요소 | `키`(LB · EQ · MO · MT … · `EQ:{기호}` 구멍) · `칸?`(기계 값 묶음 칸) · `재료?`(자재 고르기 조건 — 검색어 · 구분 · 상세구분 · 이름 · 규격 · 지역 · 계약종별) · `단위?`(금액 줄 단위 — 재료 단위 환산) · `지역?` · `계약종별?`(자재지역 · 전력 계약종별을 주는 기호) | 인력 · 재료 · 기계 · 유가 · 전력 · 환율 단가([단가 갱신] 대상) | +| 표 | `표`(소요량 · 계수 표 키) · `조건` `{조건 이름: 식}` · `칸` | 품셈 표 한 줄의 칸 값 | +| 단가산출 | `키`(다른 문서 · 구멍 됨) · `입력` `{그 문서 변수 열쇠: 식}` · `칸?`(노무비 · 재료비 · 경비 · 계) | 그 문서 셈 — 돈 = 묶음(칸 있으면 그 칸) · 돈 아님 = 결과 | +| 식 | — | 식 줄 | + +- 구멍 `{기호}` = 그 기호 값 글로 채움(`EQ:{S_기계}` · 재료 규격 `{D_관경}`). +- 단가산출 부름 값 = 그 문서의 끝수까지 셈한 값. 넘기지 않은 입력은 그 문서 값. 넘긴 선택 값은 목록 검사 안 함(부른 쪽 몫). + +## 4. 식 글 + +- 기호 = `기호` + `_아래` + `^위` — 첨자에 낱말 아닌 글이 들면 `_{…}`(`C_{m 3}`). +- 수(`3600` · `0.45` · 쉼표 없음) · 글(`'제물치장'`) · `+ - × ÷`(`* / −` 도 됨) · `%`(뒤에 붙임 = ÷ 100) · 괄호. +- 비교 `< <= > >= == !=`(`≤ ≥ ≠` 도 됨) · `그리고` · `또는`. +- 함수 — `만약(조건, 참, 거짓)`(탄 갈래만 셈) · `반올림(값, 자리)` · `올림(값, 자리)` · `버림(값, 자리)`(자리 없으면 0) · `최소(…)` · `최대(…)`. +- 칸 꺼냄 — `B_굴착기.경비`(묶음 값) · `번지.A01.재료비` · `단계.'소계'.계`. +- 금액 줄에서만 — `노무비` · `재료비` · `경비`(앞 줄까지 누계 · 끝수 뒤) · `번지` · `단계` · 앞 금액 줄 `기호`(그 줄 금액). +- 못 쓰는 기호 = `노무비 재료비 경비 계 번지 단계 그리고 또는` · 함수 이름. + +## 5. 금액 + +- 금액 줄 = `이름` · `규격?` · `단위` · `비목`(노무비 · 재료비 · 경비 · 묶음) · `수량?`(식 · 없으면 1) · `단가`(식) · `묶음?`(묶음 이름) · `기호?`(뒤 금액 줄이 부름) · `자리?` · `번지?` · `합밖?` · `출처?`. +- 줄 금액 = 수량 × 단가 → 비목마다 끝수(`자리` › 문서 `끝수.줄`) → 합. 비목 「묶음」 = 단가가 단가산출 묶음 → 노 · 재 · 경 각각 수량 ×. +- 수량 0 = 금액 0(단가가 비어도). 값 없음(빈 입력 · 표 줄 없음 · 요소 값 없음) = 그 줄만 비움 · 까닭 · 합에서 뺌. +- `합밖: true` = 번지엔 쌓되 비목 합 · 단계에서 뺌. `번지` = 금액번지 이름(그 줄 비목 금액이 쌓임). +- `단계`(문서 칸) = `[{이름, 단, 줄 [금액 줄 이름], 제외}]` — 단 1 = 제 줄 · 단 k = 제 줄 + 아직 안 담긴 아래 단 단계 · `제외` = 찍되 위 단계 · 비목 합에서 뺌. +- 묶음 = 금액 줄 `묶음` 이름이 같은 것끼리 — 묶음 하나 = 「계」 · 둘 이상 = 묶음마다 「소계」(줄 금액 합) + 「합계」. 묶음 소계는 보이기만 — 합계 = 아래 끝수 규칙. + +## 6. 끝수 — 문서 `끝수` 칸(새 문서에 기본을 적어 둠 · 셈 답 `끝수` 에 그대로 실음) + +| 칸 | 기본 | 뜻 | +| --- | --- | --- | +| `줄` | `{방법: 버림, 자리: 1}` | 금액 줄 비목마다(일위대가 금액란 0.1 원 · 품셈 1-2-2) · 줄 `자리` 가 이김 | +| `비목` | `{방법: 버림, 자리: 0}` | 비목 합(계금 1 원 · 미만 버림) | +| `끝전` | `비목버림` | `큰나머지` = 계를 원 버림 뒤 모자란 원을 원 미만 큰 비목부터 1 원씩(같으면 경 › 재 › 노) | +| `계` | 없음 | 비목 합 끝수 뒤 한 번 더(자리 맞춤) | + +- 차례 = 비목 합 끝수 → 끝전 → 계 끝수 → 조정 → 비목 끝수 · 계 = 노 + 재 + 경. +- `조정`(문서 칸) = `{배율: {비목: 수}, 옮김: 비목}` — 비목 × 배율 → 계 전부를 옮김 비목으로. +- 방법 = 버림 · 반올림 · 올림(엑셀 ROUNDDOWN · ROUND · ROUNDUP · 0 쪽 · 0 에서 먼 쪽) · 자리 0~6. +- 식 안 끝수(원문 「소수 셋째 자리 반올림」 · 옛 `표시`)는 식에 `반올림(…, 3)` 으로 적음 — 숨은 규칙 없음. + +## 7. 표지 — 무엇을 지워도 셈은 멈추지 않음 + +| 갈래 | 뜻 | 금액 | +| --- | --- | --- | +| 정의 필요 | 식이 부르는 기호에 식 줄 · 연결이 없음 | 그 기호를 쓰는 금액 · 결과 「—」 | +| 순환 | 기호 · 단가산출 부름이 돌고 돎 | 「—」 | +| 0 나눔 · 셈 틀림 | ÷ 0 · 글을 셈 · 묶음 값을 한 비목에 | 「—」 | +| 식 틀림 | 못 읽는 식 | 「—」 | +| 연결 끊김 · 연결 꼴 · 목록 밖 · 범위 밖 | 없는 키 · 칸 · 조건 · 목록 밖 값 | 「—」 | +| 겹침 · 기호 꼴 · 금액 줄 · 끝수 | 한 기호 정의 둘 · 못 쓰는 기호 · 비목 · 끝수 꼴 | 금액 전부 「—」 | +| 빈 문서 | 금액 줄 · 결과 없음 | 「—」 | + +- 값 없음은 표지가 아님 — 셈 답 `빈값` {기호: 까닭} · 줄 `까닭`. +- 셈 답 = `{값, 빈값, 줄, 노무비, 재료비, 경비, 계 | 결과, 묶음, 머리(계 · 합계), 단계?, 표지, 셈됨, 끝수}`. + +## 8. 옛 로직에서 옮김(802-2) + +- 입력 → 선택(고르기) · 입력(범위) · 값 = 수식 탭 기본값(`master_equation.given_of`). +- 중간 → 식 줄 · 식이 표 찾기 · 요소 · 로직 부름 하나뿐이면 그 기호에 바로 연결. +- `찾기(표, 조건).칸` → 표 연결 · 요소 키 · `EQ:{기계}.가격` → 요소 연결 · `로직(키, 입력).칸` → 단가산출 연결. +- 호표 → 금액 줄(인력 · 재료 = 요소 단가 · 재료 고르기 = 요소 `재료` · 로직 = 단가산출 묶음 · 비목 있으면 `.계`) · 덧줄 → 금액 줄(단가 = 식 · 단위 「식」) · `줄.'이름'` · 앞 덧줄 이름 → 그 금액 줄 `기호`. +- 끝수 결과 · 중간 `표시` → 식 안 `반올림` · 끝수 계 · 끝전 · 단계 · 조정 · 번지 · 합밖 · 자리 → 문서 칸 그대로. +- 기호 = 수식 탭 기호(800-1 · 800-5) · 한 문서 안 겹침 = 아래첨자 끝 숫자. +- 저장 = 대조(기본 옵션 + 옵션 표본)가 모두 같고 부르는 단가산출도 저장되는 문서만 · 다른 것 = `docs/raw/요구_1104/_목록_802-2_변환대조.xlsx`. diff --git a/resources/master_data/scripts/master_doc.py b/resources/master_data/scripts/master_doc.py new file mode 100644 index 000000000..99d8b73ad --- /dev/null +++ b/resources/master_data/scripts/master_doc.py @@ -0,0 +1,545 @@ +# -*- coding: utf-8 -*- +"""단가산출 수식 문서 — 꼴 · 풀이(파스) · 글로 되돌림 · 검사 (PLAN 802-1 · 요구 664 · 요구서_664). + +꼴 글 = `resources/master_data/_수식문서_틀.md` · 셈 = `master_doc_calc` +· 연결 풀이 = `master_doc_link`. +파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc.ts`(풀이 · 글 · 기호) · 거울 시험 +`resources/tester/test_802_1_formula_doc.py`. 고칠 때 둘을 같이 고침. + +식 글: 기호(`Q_시공^표` · `C_{m 3}`) · 수 · '글' · + - × ÷(* / − 도 됨) · % · 괄호 · 비교 +· 그리고 · 또는 · +함수(만약 · 올림 · 버림 · 반올림 · 최소 · 최대) · 칸 꺼냄(`B_굴착기.경비` · `번지.A01.재료비` · +`단계.'소계'.계`). eval 없음. +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +COSTS = ("노무비", "재료비", "경비") +BUNDLE = (*COSTS, "계") +FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대") +MONEY_NAMES = (*COSTS, "번지", "단계") # 금액 줄에서만 쓰는 이름(앞 줄까지 누계 · 번지 · 단계) +RESERVED = (*BUNDLE, "번지", "단계", "그리고", "또는", *FUNCS) +LINKS = ("숫자", "선택", "입력", "요소", "표", "단가산출", "식") +WAYS = ("버림", "반올림", "올림") +ENDS = ("비목버림", "큰나머지") +CUT = { # 기본 끝수 — 새 문서에 그대로 적어 둠(품셈 1-2-2 · 일위대가 0.1 원 · 계 1 원 · 미만 버림) + "줄": {"방법": "버림", "자리": 1}, + "비목": {"방법": "버림", "자리": 0}, + "끝전": "비목버림", +} +PREC = {"또는": 1, "그리고": 2, "<": 3, "<=": 3, ">": 3, ">=": 3, "==": 3, "!=": 3} +PREC |= {"+": 4, "-": 4, "*": 5, "/": 5} +SHOW = {"*": "×", "/": "÷"} +_ALIAS = {"×": "*", "÷": "/", "−": "-", "≤": "<=", "≥": ">=", "≠": "!="} + +_BASE = r"[^\W\d_](?:[^\W_]|[′'])*" +_RUN = r"(?:[^\W_]|[′'])+" +_PART = r"(?:\{[^{}]*\}|" + _RUN + ")" +ID = re.compile(_BASE + r"(?:_" + _PART + r")?(?:\^" + _PART + r")?") +_NUM = re.compile(r"\d+(?:\.\d+)?") +_FIELD = re.compile(r"(?:[^\W_]|[′'])+") +HOLE = re.compile(r"\{([^{}]+)\}") + + +class DocError(ValueError): + """식 틀림 — 글 · 자리.""" + + +# ── 기호 ────────────────────────────────────────────────────────────── +def _part(text: str) -> str: + return text if re.fullmatch(_RUN, text) else "{" + text + "}" + + +def sym_id(sign: str, low: str = "", up: str = "") -> str: + """기호 · 아래 · 위 → 식 글 이름(`Q_시공^표`) — 첨자에 낱말 아닌 글이 들면 `{…}`.""" + out = str(sign or "") + if low: + out += "_" + _part(str(low)) + if up: + out += "^" + _part(str(up)) + return out + + +def split_id(text: str) -> tuple[str, str, str]: + """식 글 이름 → (기호, 아래, 위).""" + m = re.fullmatch(r"(" + _BASE + r")(?:_(" + _PART + r"))?(?:\^(" + _PART + r"))?", text or "") + if not m: + return text, "", "" + strip = lambda s: (s[1:-1] if s.startswith("{") else s) if s else "" # noqa: E731 + return m.group(1), strip(m.group(2) or ""), strip(m.group(3) or "") + + +def var_id(var: dict) -> str: + return sym_id(var.get("기호") or "", var.get("아래") or "", var.get("위") or "") + + +def good_id(text: str) -> bool: + return bool(ID.fullmatch(text or "")) and text not in RESERVED + + +# ── 풀이 ────────────────────────────────────────────────────────────── +class _Parser: + def __init__(self, text: str): + self.s, self.i = str(text), 0 + + def fail(self, why: str): + raise DocError(f"{why} — 「{self.s}」 {self.i}자") + + def ws(self): + while self.i < len(self.s) and self.s[self.i].isspace(): + self.i += 1 + + def op(self, *words: str) -> str | None: + self.ws() + for w in words: + if self.s.startswith(w, self.i): + if w in ("그리고", "또는") and ID.match(self.s, self.i).end() != self.i + len(w): + continue # 「그리고값」 같은 이름 + self.i += len(w) + return _ALIAS.get(w, w) + return None + + def need(self, word: str): + if not self.op(word): + self.fail(f"「{word}」 없음") + + def parse(self): + self.ws() + if self.i >= len(self.s): + self.fail("빈 식") + node = self.expr() + self.ws() + if self.i != len(self.s): + self.fail("식 끝에 남은 글자") + return node + + def expr(self): + node = self.both() + while self.op("또는"): + node = ("bin", "또는", node, self.both()) + return node + + def both(self): + node = self.compare() + while self.op("그리고"): + node = ("bin", "그리고", node, self.compare()) + return node + + def compare(self): + node = self.add() + o = self.op("<=", ">=", "==", "!=", "≤", "≥", "≠", "<", ">") + return ("bin", o, node, self.add()) if o else node + + def add(self): + node = self.mul() + while o := self.op("+", "-", "−"): + node = ("bin", o, node, self.mul()) + return node + + def mul(self): + node = self.unary() + while o := self.op("*", "/", "×", "÷"): + node = ("bin", o, node, self.unary()) + return node + + def unary(self): + if self.op("-", "−"): + return ("neg", self.unary()) + node = self.primary() + while True: + self.ws() + if self.s.startswith(".", self.i): + self.i += 1 + node = ("field", node, self.field()) + elif self.s.startswith("%", self.i): + self.i += 1 + node = ("pct", node) + else: + return node + + def field(self) -> str: + if self.s.startswith("'", self.i): + return self.quoted() + m = _FIELD.match(self.s, self.i) + if not m: + self.fail("칸 이름 없음") + self.i = m.end() + return m.group() + + def quoted(self) -> str: + end = self.s.find("'", self.i + 1) + if end < 0: + self.fail("작은따옴표 안 닫힘") + text, self.i = self.s[self.i + 1 : end], end + 1 + return text + + def primary(self): + self.ws() + if self.op("("): + node = self.expr() + self.need(")") + return node + if self.s.startswith("'", self.i): + return ("str", self.quoted()) + m = _NUM.match(self.s, self.i) + if m: + self.i = m.end() + return ("num", Decimal(m.group())) + m = ID.match(self.s, self.i) + if not m: + self.fail("기호 · 수 없음") + self.i = m.end() + word = m.group() + self.ws() + if self.s.startswith("(", self.i): + if word not in FUNCS: + self.fail(f"모르는 함수 「{word}」") + self.i += 1 + items = [self.expr()] + while self.op(","): + items.append(self.expr()) + self.need(")") + return ("call", word, items) + if word in ("그리고", "또는") or word in FUNCS: + self.fail(f"「{word}」 자리 아님") + return ("var", word) + + +def parse(text) -> tuple: + """식 글 → 나무(튜플) · 틀리면 DocError.""" + if isinstance(text, (int, float, Decimal)) and not isinstance(text, bool): + return ("num", Decimal(str(text))) + return _Parser(str(text or "")).parse() + + +def walk(node): + yield node + kind = node[0] + if kind == "bin": + yield from walk(node[2]) + yield from walk(node[3]) + elif kind in ("neg", "pct", "field"): + yield from walk(node[1]) + elif kind == "call": + for x in node[2]: + yield from walk(x) + + +def names(node) -> list[str]: + """식이 부르는 이름(차례 · 겹침 없이) — 칸 이름은 빼고.""" + out: list[str] = [] + for n in walk(node): + if n[0] == "var" and n[1] not in out: + out.append(n[1]) + return out + + +# ── 글로 되돌림 ─────────────────────────────────────────────────────── +def num_text(d) -> str: + d = Decimal(str(d)) + text = format(d, "f") + return text + + +def _prec(node) -> int: + if node[0] == "bin": + return PREC[node[1]] + if node[0] == "neg": + return 6 + return 9 + + +def text_of(node) -> str: + """나무 → 식 글(× ÷ · 나무 묶음 그대로 · 괄호는 꼭 필요한 만큼).""" + kind = node[0] + if kind == "num": + return num_text(node[1]) + if kind == "str": + return f"'{node[1]}'" + if kind == "var": + return node[1] + if kind == "field": + base = text_of(node[1]) + if _prec(node[1]) < 9: + base = f"({base})" + name = node[2] if _FIELD.fullmatch(node[2]) else f"'{node[2]}'" + return f"{base}.{name}" + if kind == "pct": + inner = text_of(node[1]) + return f"({inner})%" if _prec(node[1]) < 9 else f"{inner}%" + if kind == "neg": + inner = text_of(node[1]) + return f"-({inner})" if node[1][0] == "bin" else f"-{inner}" + if kind == "call": + return f"{node[1]}({', '.join(text_of(x) for x in node[2])})" + _, op, a, b = node + mine = PREC[op] + left, right = text_of(a), text_of(b) + if _prec(a) < mine or (mine == 3 and _prec(a) == 3): + left = f"({left})" + if _prec(b) <= mine: + right = f"({right})" + return f"{left} {SHOW.get(op, op)} {right}" + + +def tidy(text) -> str: + """식 글 다듬기(풀이 → 되돌림) — 못 풀면 그대로.""" + try: + return text_of(parse(text)) + except DocError: + return str(text) + + +def _swap(node, old: str, new: str): + kind = node[0] + if kind == "var": + return ("var", new) if node[1] == old else node + if kind == "bin": + return ("bin", node[1], _swap(node[2], old, new), _swap(node[3], old, new)) + if kind in ("neg", "pct"): + return (kind, _swap(node[1], old, new)) + if kind == "field": + return ("field", _swap(node[1], old, new), node[2]) + if kind == "call": + return ("call", node[1], [_swap(x, old, new) for x in node[2]]) + return node + + +def rename_text(text, old: str, new: str) -> str: + """식 글 안 이름 바꿈 — 못 푸는 글은 그대로.""" + if text is None or text == "": + return text + try: + node = parse(text) + except DocError: + return text + return text_of(_swap(node, old, new)) if old in names(node) else text + + +def holes(text) -> list[str]: + return HOLE.findall(str(text or "")) + + +def link_texts(link: dict): + """연결 안 식 글 자리 — (칸, 열쇠, 글) · 조건 · 넘길 입력.""" + for slot in ("조건", "입력"): + for k, v in (link.get(slot) or {}).items(): + yield slot, k, v + + +def link_names(link: dict) -> list[str]: + """연결이 부르는 이름 — 키 · 재료 조건 안 `{이름}` · 지역 · 계약종별 · 조건 · 넘길 입력 식.""" + out: list[str] = [] + for text in (link.get("키"), link.get("표"), *(link.get("재료") or {}).values()): + out += [h for h in holes(text) if h not in out] + for slot in ("지역", "계약종별"): + if link.get(slot) and link[slot] not in out: + out.append(link[slot]) + for _, _, text in link_texts(link): + try: + out += [n for n in names(parse(text)) if n not in out] + except DocError: + pass + return out + + +def rename(doc: dict, old: str, sign: str, low: str = "", up: str = "") -> dict: + """기호 · 첨자 바꿈 — 변수표 · 식 줄 머리 · 모든 식 · 연결 안 이름까지. + 제자리 고침 · 같은 것 돌려줌.""" + new = sym_id(sign, low, up) + if not good_id(new): + raise DocError(f"기호 꼴 아님 「{new}」") + if new != old and new in defined(doc): + raise DocError(f"같은 기호 있음 「{new}」") + for var in doc.get("변수") or []: + if var_id(var) == old: + var["기호"], var["아래"], var["위"] = sign, low, up + link = var.get("연결") or {} + for key in ("키", "표"): + if link.get(key): + link[key] = HOLE.sub( + lambda m: "{" + (new if m.group(1) == old else m.group(1)) + "}", link[key] + ) + if isinstance(link.get("재료"), dict): + link["재료"] = { + k: HOLE.sub( + lambda m: "{" + (new if m.group(1) == old else m.group(1)) + "}", str(v) + ) + if isinstance(v, str) + else v + for k, v in link["재료"].items() + } + for slot in ("지역", "계약종별"): + if link.get(slot) == old: + link[slot] = new + for slot in ("조건", "입력"): + if isinstance(link.get(slot), dict): + link[slot] = {k: rename_text(v, old, new) for k, v in link[slot].items()} + for line in doc.get("식") or []: + if line.get("기호") == old: + line["기호"] = new + line["식"] = rename_text(line.get("식"), old, new) + for line in doc.get("금액") or []: + if line.get("기호") == old: + line["기호"] = new + for k in ("수량", "단가"): + line[k] = rename_text(line.get(k), old, new) + if doc.get("결과") == old: + doc["결과"] = new + return doc + + +# ── 정의 · 검사 ─────────────────────────────────────────────────────── +def defined(doc: dict) -> dict[str, list[str]]: + """이름 → 정의 자리 목록(「식」 · 「연결」 · 「금액」) — 둘 이상 = 겹침.""" + out: dict[str, list[str]] = {} + for var in doc.get("변수") or []: + if (var.get("연결") or {}).get("갈래", "식") != "식": + out.setdefault(var_id(var), []).append("연결") + for line in doc.get("식") or []: + out.setdefault(str(line.get("기호") or ""), []).append("식") + for line in doc.get("금액") or []: + if line.get("기호"): + out.setdefault(str(line["기호"]), []).append("금액") + return out + + +def _mark(kind: str, text: str, where: str = "", sign: str = "") -> dict: + return { + "갈래": kind, + "글": text, + **({"자리": where} if where else {}), + **({"기호": sign} if sign else {}), + } + + +def check(doc: dict) -> list[dict]: + """정적 검사(셈 없음) — 식 틀림 · 정의 필요 · 겹침 · 순환 · 기호 꼴 · 연결 꼴. + 금액 줄 · 끝수 꼴도 봄.""" + out: list[dict] = [] + defs = defined(doc) + money_syms = {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")} + for name, where in defs.items(): + if not good_id(name): + out.append(_mark("기호 꼴", f"「{name}」 은 기호로 못 씀", sign=name)) + if len(where) > 1: + out.append( + _mark("겹침", f"「{name}」 정의가 {' · '.join(where)} 에 둘 이상", sign=name) + ) + uses: dict[str, list[str]] = {} + + def need(text, where: str, money: bool, head: str = "") -> list[str]: + try: + got = names(parse(text)) + except DocError as e: + out.append(_mark("식 틀림", str(e), where, head)) + return [] + for n in got: + if n in MONEY_NAMES and not money: + out.append(_mark("금액 이름", f"「{n}」 은 금액 줄에서만", where, head)) + elif n not in defs and n not in BUNDLE and n not in MONEY_NAMES: + out.append(_mark("정의 필요", f"「{n}」 정의 필요", where, n)) + elif n in money_syms and not money: + out.append( + _mark("금액 이름", f"「{n}」(금액 줄 기호)는 금액 줄에서만", where, head) + ) + return got + + for var in doc.get("변수") or []: + link, me = var.get("연결") or {}, var_id(var) + kind = link.get("갈래", "식") + if kind not in LINKS: + out.append(_mark("연결 꼴", f"「{me}」 연결 갈래 「{kind}」", sign=me)) + continue + if kind == "식" and me not in defs: + out.append(_mark("정의 필요", f"「{me}」 식 줄 없음", sign=me)) + got = [] + for slot, key, text in link_texts(link): + got += need(text, f"{me} {slot} {key}", False, me) + for h in link_names(link): + if h not in got and h not in defs: + out.append(_mark("정의 필요", f"「{h}」 정의 필요", me, h)) + uses[me] = link_names(link) + if kind == "선택" and not isinstance(link.get("목록"), list): + out.append(_mark("연결 꼴", f"「{me}」 선택 목록 없음", sign=me)) + if kind == "표" and not (link.get("표") and link.get("칸")): + out.append(_mark("연결 꼴", f"「{me}」 표 · 칸 없음", sign=me)) + if kind == "요소" and not (link.get("키") or link.get("재료")): + out.append(_mark("연결 꼴", f"「{me}」 요소 없음", sign=me)) + if kind == "단가산출" and not link.get("키"): + out.append(_mark("연결 꼴", f"「{me}」 단가산출 없음", sign=me)) + for line in doc.get("식") or []: + head = str(line.get("기호") or "") + if not head: + out.append(_mark("식 틀림", "식 줄 기호 없음")) + uses[head] = uses.get(head, []) + need(line.get("식"), head, False, head) + for at, line in enumerate(doc.get("금액") or []): + where = f"금액 {line.get('이름') or at + 1}" + if line.get("비목") not in (*COSTS, "묶음"): + out.append(_mark("금액 줄", f"{where} 비목 「{line.get('비목')}」")) + for k in ("수량", "단가"): + if k == "단가" or line.get(k) not in (None, ""): + need(line.get(k), f"{where} {k}", True) + if line.get("자리") is not None and not _way(line["자리"]): + out.append(_mark("끝수", f"{where} 자리 「{line['자리']}」")) + cut = doc.get("끝수") or {} + for k in ("줄", "비목", "계"): + if cut.get(k) is not None and not _way(cut[k]): + out.append(_mark("끝수", f"끝수 {k} 「{cut[k]}」")) + if cut.get("끝전") not in (None, *ENDS): + out.append(_mark("끝수", f"끝전 「{cut['끝전']}」 — {' · '.join(ENDS)}")) + if doc.get("결과") and doc["결과"] not in defs: + out.append(_mark("정의 필요", f"결과 「{doc['결과']}」 정의 필요", sign=doc["결과"])) + for loop in loops(uses): + out.append(_mark("순환", " → ".join([*loop, loop[0]]), sign=loop[0])) + return out + + +def _way(rule) -> bool: + return ( + isinstance(rule, dict) + and rule.get("방법") in WAYS + and isinstance(rule.get("자리", 0), (int, Decimal)) + and 0 <= int(rule.get("자리", 0)) <= 6 + ) + + +def loops(uses: dict[str, list[str]]) -> list[list[str]]: + """돌고 도는 정의(이름 → 부르는 이름들) — 고리마다 한 번.""" + out, seen, done = [], set(), set() + + def go(name: str, path: list[str]): + if name in path: + loop = path[path.index(name) :] + key = frozenset(loop) + if key not in seen: + seen.add(key) + out.append(loop) + return + if name in done or name not in uses: + return + for n in uses[name]: + go(n, [*path, name]) + done.add(name) + + for name in list(uses): + go(name, []) + return out + + +def blank(key: str = "", name: str = "", unit: str = "원") -> dict: + """빈 문서 — 빈 화면에서 시작(변수 · 식 · 금액 없음 · 기본 끝수만 적어 둠).""" + return { + "꼴": "수식문서", + "판": 1, + "키": key, + "이름": name, + "결과단위": unit, + "변수": [], + "식": [], + "금액": [], + "끝수": {k: (dict(v) if isinstance(v, dict) else v) for k, v in CUT.items()}, + } diff --git a/resources/master_data/scripts/master_doc_calc.py b/resources/master_data/scripts/master_doc_calc.py new file mode 100644 index 000000000..41ac3da0a --- /dev/null +++ b/resources/master_data/scripts/master_doc_calc.py @@ -0,0 +1,544 @@ +# -*- coding: utf-8 -*- +"""단가산출 수식 문서 셈 (PLAN 802-1 · 요구서_664 · 꼴 `_수식문서_틀.md`). + +수식 문서를 그대로 셈 — 옛 로직 엔진(`master_formula`)을 안 부름. 기호는 필요할 때 셈(차례 무관) · +무엇을 지워도 예외 없음: 정의 필요 · 순환 · 0 나눔 · 식 틀림 = 표지 + 금액 「—」(None) · +값 없음(빈 입력 · 표 줄 없음 · 요소 값 없음) = 그 기호를 쓰는 줄만 비움(까닭). +연결 풀이(표 · 요소 · 단가산출 문서 찾기)는 `links`(M01 = `master_doc_link.Links`). +파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_calc.ts` · 거울 시험 +`resources/tester/test_802_1_formula_doc.py`. 수는 Decimal +· 금액 버림은 엑셀 ROUNDDOWN(작은 오차 무시). +""" + +from __future__ import annotations + +import math +from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation + +import master_doc as md + +COSTS, BUNDLE = md.COSTS, md.BUNDLE +EPS = 1e-7 # B09_Estimation_Calc_Base.EPS 와 같음 +DEEP = 20 # 단가산출 부름 깊이 +_QUANT = {"버림": ROUND_DOWN, "반올림": ROUND_HALF_UP, "올림": ROUND_UP} + + +class Gap(Exception): + """연결 값 없음 — 그 기호를 쓰는 줄만 비움.""" + + +class Broken(Exception): + """연결 끊김(없는 키 · 칸 · 조건) — 셈 멈춤 · 금액 「—」.""" + + +class Blank: + def __init__(self, why: str): + self.why = why + + +class Stop: + def __init__(self, why: str, kind: str = "멈춤", sign: str = ""): + self.why, self.kind, self.sign = why, kind, sign + + +def _bad(v) -> bool: + return isinstance(v, (Blank, Stop)) + + +# ── 수 ──────────────────────────────────────────────────────────────── +def rounddown(value: float, places: int = 0) -> float: + """엑셀 ROUNDDOWN(B09 `rounddown` 과 같음).""" + a, scale = abs(value), 10**places + out = math.floor(a * scale + EPS) / scale + return -out if value < 0 else out + + +def cut(value, rule: dict | None, plain: bool = False): + """끝수 한 번 — 버림 = 엑셀 ROUNDDOWN(금액 끝수). + plain = Decimal 자리 맞춤(식 함수 · 계 끝수).""" + if value is None or not rule: + return value + way, places = rule.get("방법"), int(rule.get("자리") or 0) + if way == "버림" and not plain: + return Decimal(str(rounddown(float(value), places))) + return Decimal(value).quantize(Decimal(1).scaleb(-places), rounding=_QUANT[way]) + + +def plain_text(v) -> str: + """구멍(`{기호}`)에 넣는 글 — 수는 군더더기 없이.""" + return format(v.normalize(), "f") if isinstance(v, Decimal) else str(v) + + +def number(v): + if isinstance(v, bool): + return v + if isinstance(v, (int, float)): + return Decimal(str(v)) + return v + + +# ── 셈 한 벌 ────────────────────────────────────────────────────────── +class _Run: + def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict): + self.doc, self.links, self.stack, self.memo = doc, links, stack, memo + self.given = given or {} + self.vars = {md.var_id(v): v for v in doc.get("변수") or []} + self.lines = {str(x.get("기호") or ""): x for x in doc.get("식") or []} + self.values: dict[str, object] = {} + self.busy: list[str] = [] + self.marks: list[dict] = [] + self.money_syms = {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")} + + def mark(self, stop: Stop) -> Stop: + one = {"갈래": stop.kind, "글": stop.why, **({"기호": stop.sign} if stop.sign else {})} + if one not in self.marks: + self.marks.append(one) + return stop + + # 이름 값 + def get(self, name: str, scope: dict | None = None): + if scope is not None and name in scope: + return scope[name] + if name in md.MONEY_NAMES or name in self.money_syms: + return self.mark(Stop(f"「{name}」 은 금액 줄에서만", "금액 이름", name)) + if name in self.values: + return self.values[name] + if name in self.busy: + loop = self.busy[self.busy.index(name) :] + return self.mark(Stop(" → ".join([*loop, name]), "순환", name)) + self.busy.append(name) + try: + value = self._define(name) + finally: + self.busy.pop() + if isinstance(value, Stop): + self.mark(value) + self.values[name] = value + return value + + def _define(self, name: str): + var, line = self.vars.get(name), self.lines.get(name) + kind = ((var or {}).get("연결") or {}).get("갈래", "식") + if line is not None and kind != "식": + return Stop(f"「{name}」 정의가 식 줄 · 연결 둘", "겹침", name) + if line is not None: + return self.expr(line.get("식"), name) + if var is None or kind == "식": + return Stop(f"「{name}」 정의 필요", "정의 필요", name) + return self.link(var, name) + + def expr(self, text, where: str = "", scope: dict | None = None): + try: + node = md.parse(text) + except md.DocError as e: + return self.mark(Stop(str(e), "식 틀림", where)) + return self.ev(node, scope, where) + + # 식 + def ev(self, node, scope, where): + kind = node[0] + if kind in ("num", "str"): + return node[1] + if kind == "var": + return self.get(node[1], scope) + if kind == "field": + base = self.ev(node[1], scope, where) + if _bad(base): + return base + if not isinstance(base, dict) or node[2] not in base: + return self.mark(Stop(f"칸 「{node[2]}」 없음", "칸 없음", where)) + got = base[node[2]] + return Blank(f"칸 「{node[2]}」 비어 있음") if got is None else got + if kind == "neg": + a = self.ev(node[1], scope, where) + return a if _bad(a) else self.arith(lambda: -a, where) + if kind == "pct": + a = self.ev(node[1], scope, where) + return a if _bad(a) else self.arith(lambda: a / 100, where) + if kind == "call": + return self.call(node, scope, where) + op = node[1] + a = self.ev(node[2], scope, where) + if _bad(a): + return a + if op == "그리고" and not a or op == "또는" and a: + return a + b = self.ev(node[3], scope, where) + if _bad(b) or op in ("그리고", "또는"): + return b + if op == "/" and isinstance(b, Decimal) and b == 0: + return self.mark(Stop(f"{where} — 0 나눔", "0 나눔", where)) + return self.arith(lambda: _BIN[op](a, b), where) + + def arith(self, fn, where): + try: + got = fn() + except (TypeError, ArithmeticError): + return self.mark(Stop(f"{where} — 수 아닌 값을 셈", "셈 틀림", where)) + return got + + def call(self, node, scope, where): + name, items = node[1], node[2] + if name == "만약": + if len(items) != 3: + return self.mark(Stop("만약(조건, 참, 거짓)", "식 틀림", where)) + test = self.ev(items[0], scope, where) + if _bad(test): + return test + return self.ev(items[1] if test else items[2], scope, where) + values = [self.ev(x, scope, where) for x in items] + bad = next((v for v in values if _bad(v)), None) + if bad is not None: + return bad + if name in ("최소", "최대"): + return self.arith(lambda: (min if name == "최소" else max)(values), where) + if len(values) > 2: + return self.mark(Stop(f"{name}(값, 자리)", "식 틀림", where)) + places = values[1] if len(values) > 1 else 0 + return self.arith(lambda: cut(values[0], {"방법": name, "자리": int(places)}, True), where) + + # 연결 + def fill(self, text, where: str): + out, bad = str(text), None + + def one(m): + nonlocal bad + got = self.get(m.group(1)) + if _bad(got): + bad = bad or got + return "" + return plain_text(got) + + out = md.HOLE.sub(one, out) + return bad if bad is not None else out + + def link(self, var: dict, name: str): + link = var.get("연결") or {} + kind = link.get("갈래") + own = var.get("열쇠") + if kind in ("숫자", "입력", "선택"): + given = own in self.given if own else False + value = number(self.given[own] if given else link.get("값")) + if kind == "숫자" and isinstance(value, dict): # 손 묶음(노 · 재 · 경 — 엑셀 값 그대로) + got = {k: number(value.get(k)) for k in COSTS} + if not all(isinstance(v, Decimal) for v in got.values()): + return Stop(f"「{name}」 묶음 값은 노무비 · 재료비 · 경비 수", "연결 꼴", name) + return {**got, "계": sum(got.values())} + if value is None or value == "": + return Blank(f"「{var.get('이름') or name}」 값 비어 있음") + if kind == "입력" and isinstance(value, str): + try: + value = Decimal(value.strip().replace(",", "")) + except InvalidOperation: + pass + if kind == "선택" and not given: + options = [number(x) for x in link.get("목록") or []] + if value not in options and plain_text(value) not in [ + plain_text(x) for x in options + ]: + return Stop(f"「{name}」 값 「{plain_text(value)}」 목록 밖", "목록 밖", name) + value = next((x for x in options if plain_text(x) == plain_text(value)), value) + low_high = link.get("범위") + if kind == "입력" and low_high and isinstance(value, Decimal): + low, high = (number(x) for x in low_high) + if (low is not None and value < low) or (high is not None and value > high): + return Stop( + f"「{name}」 {plain_text(value)} 범위 [{low}, {high}] 밖", "범위 밖", name + ) + return value + payload, bad = {}, None + for key in ("키", "표"): + if link.get(key): + payload[key] = self.fill(link[key], name) + if isinstance(link.get("재료"), dict): + payload["재료"] = { + k: self.fill(v, name) if isinstance(v, str) else v for k, v in link["재료"].items() + } + for slot in ("지역", "계약종별"): + if link.get(slot): + payload[slot] = self.get(link[slot]) + for slot in ("조건", "입력"): + if isinstance(link.get(slot), dict): + payload[slot] = {k: self.expr(v, name) for k, v in link[slot].items()} + for v in (payload.get("키"), payload.get("표"), *(payload.get("재료") or {}).values()): + bad = bad or (v if _bad(v) else None) + for v in (payload.get("지역"), payload.get("계약종별")): + bad = bad or (v if _bad(v) else None) + for v in [*(payload.get("조건") or {}).values(), *(payload.get("입력") or {}).values()]: + bad = bad or (v if _bad(v) else None) + if bad is not None: + return bad + for k in ("칸", "단위"): + if link.get(k): + payload[k] = link[k] + try: + if kind == "요소": + return number(self.links.element(payload)) + if kind == "표": + return number(self.links.table(payload)) + if kind == "단가산출": + return self.sub(payload, name) + except Gap as e: + return Blank(str(e)) + except Broken as e: + return Stop(f"「{name}」 {e}", "연결 끊김", name) + return Stop(f"「{name}」 연결 갈래 「{kind}」", "연결 꼴", name) + + def sub(self, payload: dict, name: str): + key = payload["키"] + if key in self.stack or len(self.stack) > DEEP: + return Stop(f"단가산출 부름 돌고 돎 — {' → '.join([*self.stack, key])}", "순환", name) + doc = self.links.doc(key) + if doc is None: + raise Broken(f"없는 단가산출 「{key}」") + given = payload.get("입력") or {} + memo_key = (key, tuple(sorted((k, plain_text(v)) for k, v in given.items()))) + if memo_key not in self.memo: + self.memo[memo_key] = calc(doc, self.links, given, (*self.stack, key), self.memo) + got = self.memo[memo_key] + if not got["셈됨"]: # 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤 + why = got.get("까닭") or " · ".join(m["글"] for m in got["표지"]) + if got.get("까닭"): + return Blank(f"단가산출 「{key}」 못 셈 — {why}") + return Stop(f"단가산출 「{key}」 못 셈 — {why}", "부름 멈춤", name) + value = got["결과"] if "결과" in got else {k: got[k] for k in BUNDLE} + col = payload.get("칸") + if col: + if not isinstance(value, dict) or col not in value: + raise Broken(f"칸 「{col}」 없음") + return value[col] + return value + + +_BIN = { + "+": lambda a, b: a + b, + "-": lambda a, b: a - b, + "*": lambda a, b: a * b, + "/": lambda a, b: a / b, + "<": lambda a, b: a < b, + "<=": lambda a, b: a <= b, + ">": lambda a, b: a > b, + ">=": lambda a, b: a >= b, + "==": lambda a, b: a == b, + "!=": lambda a, b: a != b, +} + + +# ── 금액 ────────────────────────────────────────────────────────────── +def _zero() -> dict: + return dict.fromkeys(COSTS, Decimal(0)) + + +def _add(acc: dict, parts: dict) -> None: + for k, v in parts.items(): + if k in acc and v is not None: + acc[k] += v + + +def _bundle(acc: dict) -> dict: + return {**acc, "계": sum(acc[k] for k in COSTS)} + + +def _cover(doc: dict) -> list[tuple[dict, set]]: + """단계마다 (단계, 담은 줄 차례) — 단 1 = 제 줄 · 단 k = 제 줄 + 아직 안 담긴 아래 단 단계.""" + where = {x.get("이름"): i for i, x in enumerate(doc.get("금액") or []) if x.get("이름")} + out, open_ = [], [] + for st in doc.get("단계") or []: + level, mine = int(st.get("단") or 1), {where[n] for n in st.get("줄") or [] if n in where} + keep = [] + for low, got in open_: + if low < level: + mine |= got + else: + keep.append((low, got)) + open_ = keep if st.get("제외") else [*keep, (level, mine)] + out.append((st, mine)) + return out + + +def _steps(cover, outs: list[dict]) -> list[dict]: + rows = [] + for st, mine in cover: + acc = _zero() + for i in sorted(mine): + if i < len(outs): + _add(acc, outs[i]["비목"]) + rows.append( + {"이름": st.get("이름"), "단": st.get("단"), **_bundle(acc)} + | ({"제외": True} if st.get("제외") else {}) + ) + return rows + + +def _scope(doc: dict, outs: list[dict], running: dict, cover, syms: dict) -> dict: + places: dict[str, dict] = {} + for line, one in zip(doc.get("금액") or [], outs): + if line.get("번지"): + _add(places.setdefault(str(line["번지"]), _zero()), one["비목"]) + return { + **running, + "번지": {k: _bundle(v) for k, v in places.items()}, + "단계": {x["이름"]: {k: x[k] for k in BUNDLE} for x in _steps(cover, outs)}, + **syms, + } + + +def _money(run: _Run, doc: dict, rules: dict): + """금액 줄 차례대로 — 줄 비목마다 끝수 · 합밖 · 제외 단계 · 앞 줄 누계 · 번지 · 단계.""" + lines = doc.get("금액") or [] + cover = _cover(doc) + skip = {i for i, x in enumerate(lines) if x.get("합밖")} + for st, mine in cover: + if st.get("제외"): + skip |= mine + running, syms, outs, stopped = _zero(), {}, [], False + for i, line in enumerate(lines): + scope = _scope(doc, outs, dict(running), cover, syms) + where = str(line.get("이름") or f"금액 {i + 1}") + qty = ( + run.expr(line["수량"], where, scope) + if line.get("수량") not in (None, "") + else Decimal(1) + ) + price = run.expr(line.get("단가"), where, scope) + kind = line.get("비목") + parts, why = {}, None + if isinstance(qty, Stop) or isinstance(price, Stop): + stopped, why = True, (qty if isinstance(qty, Stop) else price).why + elif isinstance(qty, Blank): + why = qty.why + elif not isinstance(qty, Decimal): + stopped, why = True, run.mark(Stop(f"{where} 수량이 수 아님", "셈 틀림", where)).why + elif qty == 0: + parts = {k: Decimal(0) for k in (COSTS if kind == "묶음" else (kind,))} + elif isinstance(price, Blank): + why = price.why + elif kind == "묶음": + if isinstance(price, dict) and all(isinstance(price.get(k), Decimal) for k in COSTS): + parts = {k: qty * price[k] for k in COSTS} + else: + stopped, why = ( + True, + run.mark( + Stop(f"{where} 묶음 줄 단가가 단가산출 묶음 아님", "셈 틀림", where) + ).why, + ) + elif kind in COSTS and isinstance(price, Decimal): + parts = {kind: qty * price} + else: + what = ( + "묶음 값 — 칸(.계 · .노무비 …) 필요" + if isinstance(price, dict) + else "단가가 수 아님" + ) + stopped, why = True, run.mark(Stop(f"{where} {what}", "셈 틀림", where)).why + rule = line.get("자리") or rules["줄"] + parts = {k: cut(v, rule) for k, v in parts.items()} + amount = sum(parts.values(), Decimal(0)) if parts else None + if i not in skip: + _add(running, parts) + if line.get("기호"): + syms[str(line["기호"])] = amount if amount is not None else Blank(why or "빈 줄") + outs.append( + { + "이름": line.get("이름") or "", + "비목": parts, + "수량": qty if isinstance(qty, Decimal) else None, + "단가": price if isinstance(price, (Decimal, dict)) else None, + "금액": amount, + **({"까닭": why} if why else {}), + **({"합밖": True} if i in skip else {}), + **({"묶음": line["묶음"]} if line.get("묶음") else {}), + } + ) + return outs, running, stopped, cover + + +def _settle(doc: dict, rules: dict, total: dict) -> dict: + """비목 합 끝수 → 끝전(큰나머지) → 계 끝수 → 조정 → 비목 끝수.""" + costs = {k: cut(v, rules["비목"]) for k, v in total.items()} + if rules.get("끝전") == "큰나머지": + rest = int(cut(sum(total.values()), rules["비목"]) - sum(costs.values())) + order = sorted(COSTS, key=lambda k: (costs[k] - total[k], COSTS[::-1].index(k))) + for k in order[:rest]: + costs[k] += 1 + if rules.get("계"): + costs = {k: cut(v, rules["계"], True) for k, v in costs.items()} + adjust = doc.get("조정") or {} + for k, rate in (adjust.get("배율") or {}).items(): + costs[k] = costs[k] * number(rate) + if adjust.get("옮김"): + whole = sum(costs.values()) + costs = {k: whole if k == adjust["옮김"] else Decimal(0) for k in COSTS} + return {k: cut(v, rules["비목"]) for k, v in costs.items()} + + +def _groups(outs: list[dict]) -> list[dict]: + order: list[str] = [] + acc: dict[str, dict] = {} + for one in outs: + name = str(one.get("묶음") or "") + if name not in acc: + order.append(name) + acc[name] = _zero() + if not one.get("합밖"): + _add(acc[name], one["비목"]) + return [{"이름": n, **_bundle(acc[n])} for n in order] + + +def rules_of(doc: dict) -> dict: + """끝수 — 문서 칸 › 기본(`md.CUT`) · 셈 답에 그대로 실음(숨은 규칙 없음).""" + got = dict(doc.get("끝수") or {}) + return { + k: got.get(k) or md.CUT.get(k) + for k in ("줄", "비목", "끝전", "계") + if got.get(k) or md.CUT.get(k) + } + + +def calc( + doc: dict, links, given: dict | None = None, stack: tuple = (), memo: dict | None = None +) -> dict: + """수식 문서 셈 → {값, 빈값, 줄, 노무비 · 재료비 · 경비 · 계 | 결과, + 묶음, 머리, 단계?, 표지, 셈됨, 끝수}. + given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음.""" + memo = {} if memo is None else memo + stack = stack or ((str(doc["키"]),) if doc.get("키") else ()) # 제 키 — 저를 부르면 순환 + run = _Run(doc, links, given, stack, memo) + for one in md.check(doc): + if one["갈래"] in ("기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"): + run.marks.append(one) + rules = rules_of(doc) + for name in [*run.lines, *run.vars]: + if name: + run.get(name) + out: dict = {"끝수": rules} + has_money = bool(doc.get("금액")) + if doc.get("결과"): + value = run.get(str(doc["결과"])) + ok = isinstance(value, Decimal) + out |= {"결과": value if ok else None} + if isinstance(value, Blank): + out["까닭"] = value.why + out["셈됨"] = ok + elif has_money: + outs, running, stopped, cover = _money(run, doc, rules) + stopped = stopped or any(m["갈래"] in ("겹침", "금액 줄", "끝수") for m in run.marks) + costs = None if stopped else _settle(doc, rules, running) + out |= { + "줄": outs, + **{k: (costs or {}).get(k) for k in COSTS}, + "계": None if costs is None else sum(costs.values()), + "묶음": _groups(outs), + } + out["머리"] = "합계" if len(out["묶음"]) > 1 else "계" + if doc.get("단계"): + out["단계"] = _steps(cover, outs) + out["셈됨"] = costs is not None + else: + run.marks.append({"갈래": "빈 문서", "글": "금액 줄 · 결과 없음"}) + out |= {"줄": [], **dict.fromkeys(BUNDLE), "묶음": [], "머리": "계", "셈됨": False} + out["값"] = {k: (None if _bad(v) else v) for k, v in run.values.items()} + out["빈값"] = {k: v.why for k, v in run.values.items() if isinstance(v, Blank)} + out["표지"] = run.marks + return out diff --git a/resources/master_data/scripts/master_doc_convert.py b/resources/master_data/scripts/master_doc_convert.py new file mode 100644 index 000000000..68f53c06a --- /dev/null +++ b/resources/master_data/scripts/master_doc_convert.py @@ -0,0 +1,564 @@ +# -*- coding: utf-8 -*- +"""옛 M01 로직 줄 → 수식 문서 변환 (PLAN 802-2 · 요구서_664 1 장 3 · 꼴 `_수식문서_틀.md`). + +빌더 `build_수식문서_802_2.py` 가 부름 · 다시 돌리면 같은 결과. +- 기호 = 800-1 · 800-5 기호(수식 탭이 보이는 것 +· `master_equation.export` + `master_equation_sign.settle`) · + 못 내보내는 로직은 같은 규칙(`master_symbol`)으로 · 한 문서 안 겹침 = 아래첨자 끝 숫자. +- 입력 = 선택(고르기) · 입력 · 값 = 기본 옵션(`master_equation.given_of`) · 중간 = 식 줄 +· 한 마디 중간 + (표 찾기 · 요소 · 로직 부름 하나) = 그 기호에 바로 연결 · 표 찾기 · 요소 + · 로직 부름 = 기호 + 연결. +- 호표 = 금액 줄(수량 × 단가 · 단가 = 요소 · 재료 고르기 · 단가산출 묶음) +· 덧줄 = 금액 줄(단가 = 식) · + `줄.'이름'` · 앞 덧줄 이름 = 그 금액 줄 기호 · 끝수 결과 · 표시 = 식 안 반올림 + · 금액 끝수 = 문서 `끝수`. +옛 엔진 셈은 안 함(기호 · 이름 보기만 export 를 씀). +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +import master_doc as md +import master_equation as meq +import master_equation_edit as mee +import master_equation_sign as mes +import master_formula as mf +import master_material as mm +import master_symbol as msym + +_ERR = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError, IndexError) +SUBMAX = msym.SUBMAX + + +def _harvest(master, key: str) -> dict: + """수식 탭 내보내기 기호 — (꼴, 갈래, 식 | 열쇠) → 마디 · 못 내보내면 빈 것.""" + try: + ex = mes.settle(meq.export(master, key)) + except _ERR: + return {} + out: dict = {} + + def visit(t): + if isinstance(t, list): + for x in t: + visit(x) + elif isinstance(t, dict): + if t.get("꼴") in ("변수", "블록"): + out.setdefault((t["꼴"], t.get("갈래"), str(t.get("식") or t.get("열쇠"))), t) + for k, v in t.items(): + if k != "나무" and isinstance(v, (dict, list)): + visit(v) + + for line in ex.get("줄") or []: + visit(line.get("이름식")) + if isinstance(line.get("결과"), dict): + head = line["결과"] + out.setdefault(("머리", line.get("갈래"), str(head.get("열쇠"))), head) + return out + + +def _clean(text: str) -> str: + """기호 · 첨자 글 다듬기 — 낱말 아닌 글 뺌.""" + return re.sub(r"[^\w′'·]|_", "", str(text or "")) + + +class _Reg: + """기호 자리 — 열쇠마다 (기호, 아래, 위) 제안 → 겹침 없는 이름.""" + + def __init__(self): + self.props: dict[tuple, tuple[str, str, str]] = {} + self.ids: dict[tuple, str] = {} + self.used: set[str] = set() + self.notes: list[str] = [] + + def want(self, key: tuple, parts: tuple[str, str, str]) -> None: + self.props.setdefault(key, parts) + + def settle(self) -> None: + for key, (sign, low, up) in self.props.items(): + if key in self.ids: + continue + if not md.good_id(md.sym_id(sign, "", "")): + fixed = _clean(sign) + fixed = fixed if fixed and md.good_id(fixed) else "X" + self.notes.append(f"기호 「{sign}」 → 「{fixed}」") + sign = fixed + n, base = 1, low + while md.sym_id(sign, low, up) in self.used or not md.good_id(md.sym_id(sign, low, up)): + n += 1 + low = (base or "")[: SUBMAX - len(str(n))] + str(n) + if n > 99: + raise ValueError(f"기호 못 가름 {key}") + self.used.add(md.sym_id(sign, low, up)) + self.ids[key] = md.sym_id(sign, low, up) + + def id(self, key: tuple) -> str: + return self.ids.get(key, "?") + + +class _Conv: + def __init__(self, master, row: dict, given: dict): + self.master, self.row, self.given = master, row, given + self.key = str(row["키"]) + self.found = _harvest(master, self.key) + self.table = {v["열쇠"]: v for v in meq.table(row, master)} + self.reg = _Reg() + self.links: dict[tuple, dict] = {} # 열쇠 → 변수 칸(이름 · 단위 · 연결) + self.order: list[tuple] = [] + self.alias: dict[str, tuple] = {} # 한 마디 중간 식 → ('중간', 이름) + self.names = {s["이름"] for s in row.get("입력") or []} | { + s["이름"] for s in row.get("중간") or [] + } + self.kinds = {s["이름"]: "입력" for s in row.get("입력") or []} + self.kinds |= {s["이름"]: "중간" for s in row.get("중간") or []} + self.notes: list[str] = [] + self.used_lines: set[tuple] = set() # 식이 부른 금액 줄 — ('합' | '덧', 이름) + self.quiet = "" # 한 마디 중간 연결만 뜰 때(그 마디 식) — 기호 자리 안 잡음 + self.last: dict = {} + + # 기호 제안 + def _look(self, kind: str, raw: str) -> dict | None: + for k in (("변수", kind, raw), ("블록", None, raw), ("변수", "요소", raw)): + if k in self.found: + return self.found[k] + return None + + def _var(self, key: tuple, family: str, raw: str, name: str, link: dict, unit: str = "") -> str: + if self.quiet and self.quiet == raw: + self.quiet, self.last = "", link + return "?" + if key not in self.links: + node = self._look(family, raw) or {} + sign = str(node.get("기호") or "") + if sign and node.get("기호") != node.get("열쇠"): + parts = _merge(sign, str(node.get("아래") or ""), str(node.get("위") or "")) + else: + fam = {"블록": "결과"}.get(family, family) + parts = msym.lookup(name or raw, fam if fam in msym.FAMILY else "요소") + self.reg.want(key, parts) + self.order.append(key) + label = str(node.get("이름") or name or raw) + self.links[key] = { + "이름": label, + "단위": str(node.get("단위") or unit or ""), + "연결": link, + } + self.links[key]["연결"] = link # 두 번째 돌이 = 겹침 가른 이름으로 쓴 연결 + return self.reg.id(key) + + def own(self, name: str) -> str: + """입력 · 중간 이름 → 기호.""" + if name not in self.names: + self.notes.append(f"모르는 이름 「{name}」") + return name if md.good_id(name) else "X_" + _clean(name) + return self.reg.id((self.kinds[name], name)) + + def holes(self, text): + if not isinstance(text, str): + return text + return md.HOLE.sub(lambda m: "{" + self.own(m.group(1)) + "}", text) + + def _place(self) -> dict: + out = {} + if mm.REGION in self.kinds: + out["지역"] = self.own(mm.REGION) + if mm.TARIFF in self.kinds: + out["계약종별"] = self.own(mm.TARIFF) + return out + + # 식 + def emit(self, node, money: dict | None = None): + kind = node[0] + if kind in ("num", "str"): + return node + if kind == "name": + name = node[1] + if money is not None and name in md.COSTS: + return ("var", name) + if money is not None and name in money: + self.used_lines.add(money[name]) + return ("var", self.reg.id(money[name])) + return ("var", self.own(name)) + if kind == "ref": + return ("var", self.element(node[1])) + if kind == "neg": + return ("neg", self.emit(node[1], money)) + if kind == "bin": + return ("bin", node[1], self.emit(node[2], money), self.emit(node[3], money)) + if kind == "call": + return ("call", node[1], [self.emit(x, money) for x in node[2]]) + if kind == "find": + return ("var", self.find(node, None)) + if kind == "logic": + return ("var", self.block(node, None)) + base, col = node[1], node[2] + if base[0] == "find": + return ("var", self.find(base, col)) + if base[0] == "logic": + return ("var", self.block(base, col)) + if base[0] == "ref": + return ("var", self.element(base[1], col)) + if base[0] == "name" and base[1] == "줄" and money is not None: + self.used_lines.add(("합", col)) + return ("var", self.reg.id(("합", col))) + if base[0] == "field" and base[1][0] == "name" and base[1][1] in ("번지", "단계"): + return ("field", ("field", ("var", base[1][1]), base[2]), col) + return ("field", self.emit(base, money), col) + + def text(self, raw, money: dict | None = None) -> str: + return md.text_of(self.emit(mf.parse(raw), money)) + + # 연결 마디 + def find(self, node, col) -> str: + raw = mee.raw_text(node if col is None else ("field", node, col)) + if raw in self.alias: + return self.reg.id(self.alias[raw]) + link = {"갈래": "표", "표": self.holes(node[1])} + link["조건"] = {k: md.text_of(self.emit(v)) for k, v in node[2]} + if col is None: + self.notes.append(f"칸 없는 찾기 「{raw}」") + else: + link["칸"] = col + return self._var(("찾기", raw), "찾기", raw, col or raw, link) + + def block(self, node, col) -> str: + raw = mee.raw_text(node if col is None else ("field", node, col)) + if raw in self.alias: + return self.reg.id(self.alias[raw]) + link = {"갈래": "단가산출", "키": self.holes(node[1])} + link["입력"] = {k: md.text_of(self.emit(v)) for k, v in node[2]} + if col is not None: + link["칸"] = col + try: + sub = self.master.logic(mf.fill(node[1], {})) if "{" not in node[1] else {} + except _ERR: + sub = {} + unit = str(sub.get("결과단위") or "") + return self._var(("블록", raw), "블록", raw, str(sub.get("이름") or node[1]), link, unit) + + def element(self, ref: str, col: str | None = None, unit: str | None = None) -> str: + raw = ref if col is None else mee.raw_text(("field", ("ref", ref), col)) + if raw in self.alias and unit is None: + return self.reg.id(self.alias[raw]) + link = {"갈래": "요소", "키": self.holes(ref)} + if col is not None: + link["칸"] = col + if unit is not None: + link["단위"] = unit + link |= self._place() + try: + label = self.master.label(ref) if "{" not in ref else ref + except _ERR: + label = ref + return self._var(("요소", raw, unit), "요소", raw, label, link) + + def material(self, at: int, item: dict) -> str: + cond = {k: self.holes(v) for k, v in item["요소"].items()} + link = {"갈래": "요소", "재료": cond, "단위": item.get("단위") or ""} | self._place() + name = str(item.get("이름") or "") + return self._var(("재료", at), "요소", f"단가:{name}", f"{name}단가", link) + + def lone(self, raw: str): + """한 마디 식(표 찾기 · 요소 · 로직 부름 하나) — 마디 · 아니면 None.""" + try: + node = mf.parse(raw) + except mf.FormulaError: + return None + if node[0] in ("ref", "logic"): + return node + if node[0] == "field" and node[1][0] in ("find", "logic", "ref"): + return node + return None + + +def _merge(sign: str, low: str, up: str) -> tuple[str, str, str]: + """기호 칸에 첨자까지 든 것(「t_1」 · 「C_m」)을 가름.""" + if "_" in sign or "^" in sign: + head, low0, up0 = md.split_id(sign) + if head != sign: + return head, low0 + low, up or up0 + return sign, low, up + + +def _parts(var: dict) -> tuple[str, str, str]: + return _merge( + str(var.get("기호") or var["열쇠"]), str(var.get("아래") or ""), str(var.get("위") or "") + ) + + +def _line_parts(c: _Conv, name: str) -> tuple[str, str, str]: + head = c.found.get(("머리", "호표", name)) or c.found.get(("머리", "덧줄", name)) or {} + if head.get("기호") and head.get("기호") != name: + return str(head["기호"]), str(head.get("아래") or ""), str(head.get("위") or "") + return msym.lookup(name, "합") + + +def convert(master, row: dict, given: dict | None = None) -> tuple[dict, list[str]]: + """로직 줄 → (수식 문서, 알림). given = 기본 옵션(없으면 `given_of`).""" + given = meq.given_of(master, row) if given is None else given + c = _Conv(master, row, given) + for spec in row.get("입력") or []: + c.reg.want( + ("입력", spec["이름"]), _parts(c.table.get(spec["이름"], {"열쇠": spec["이름"]})) + ) + for step in row.get("중간") or []: + c.reg.want( + ("중간", step["이름"]), _parts(c.table.get(step["이름"], {"열쇠": step["이름"]})) + ) + for item in row.get("호표") or []: + c.reg.want(("합", str(item.get("이름") or "")), _line_parts(c, str(item.get("이름") or ""))) + for item in row.get("덧줄") or []: + c.reg.want(("덧", str(item.get("이름") or "")), _line_parts(c, str(item.get("이름") or ""))) + lone_steps = {} + for step in row.get("중간") or []: + node = c.lone(step["식"]) + if node is not None and step.get("표시") is None: + raw = mee.raw_text(node) + if raw not in c.alias: + c.alias[raw] = ("중간", step["이름"]) + lone_steps[step["이름"]] = node + doc = None + for _ in range(2): # 한 번 = 기호 모음 · 두 번 = 겹침 가른 이름으로 글 + c.reg.settle() + doc = _build(c, row, given, lone_steps) + c.notes += c.reg.notes + return doc, sorted(set(c.notes)) + + +def _link_of(c: _Conv, node) -> dict: + """한 마디 중간의 연결 — 찾기 · 요소 · 로직 부름.""" + alias = dict(c.alias) + c.alias, c.quiet, c.last = {}, mee.raw_text(node), {} + try: + c.emit(node) + return dict(c.last) + finally: + c.alias, c.quiet = alias, "" + + +def _build(c: _Conv, row: dict, given: dict, lone_steps: dict) -> dict: + variables: list[dict] = [] + for spec in row.get("입력") or []: + name = spec["이름"] + look = c.table.get(name, {}) + value = given.get(name) + if isinstance(value, (int, float)) and not isinstance(value, bool): + value = Decimal(str(value)) + if "고르기" in spec: + link = {"갈래": "선택", "목록": list(spec["고르기"]), "값": value} + else: + link = {"갈래": "입력", "값": value} + if spec.get("범위"): + link["범위"] = list(spec["범위"]) + variables.append(_var_row(c.reg.id(("입력", name)), name, look, spec, link)) + lines: list[dict] = [] + for step in row.get("중간") or []: + name = step["이름"] + look = c.table.get(name, {}) + me = c.reg.id(("중간", name)) + if name in lone_steps: + variables.append(_var_row(me, name, look, step, _link_of(c, lone_steps[name]))) + continue + try: + text = c.text(step["식"]) + except mf.FormulaError as e: + c.notes.append(f"중간 「{name}」 식 못 읽음 — {e}") + text = str(step["식"]) + if step.get("표시") is not None: + text = f"반올림({text}, {int(step['표시'])})" + variables.append(_var_row(me, name, look, step, {"갈래": "식"})) + lines.append({"기호": me, "식": text}) + doc = { + "꼴": "수식문서", + "판": 1, + "키": c.key, + "이름": row.get("이름") or "", + "결과단위": row.get("결과단위") or "", + "출처": row.get("출처") or "", + } + money: list[dict] = [] + if "결과" in row: + head = c.found.get(("머리", "결과", "결과")) or {} + sign = head.get("기호") if msym.is_symbol(str(head.get("기호") or "")) else "" + parts = (sign, str(head.get("아래") or ""), str(head.get("위") or "")) if sign else None + parts = parts or msym.rule(str(row.get("결과단위") or ""), "결과")[:3] + c.reg.want(("결과", "결과"), parts) + text = c.text(row["결과"]["식"]) + rule = row.get("끝수") + if isinstance(rule, dict) and rule.get("대상") == "결과": + text = f"{rule['방법']}({text}, {int(rule.get('자리') or 0)})" + me = c.reg.id(("결과", "결과")) + variables.append( + { + "열쇠": "결과", + **_split(me), + "이름": "결과", + "단위": row.get("결과단위") or "", + "연결": {"갈래": "식"}, + } + ) + lines.append({"기호": me, "식": text}) + doc["결과"] = me + else: + money = _money(c, row) + for key in c.order: + if key[0] in ("입력", "중간") or key in [("결과", "결과")]: + continue + if any(alias == key for alias in c.alias.values()): + continue + one = c.links[key] + me = c.reg.id(key) + variables.append( + { + "열쇠": me, + **_split(me), + "이름": one["이름"], + "단위": one["단위"], + "연결": one["연결"], + } + ) + doc |= {"변수": variables, "식": lines, "금액": money} + cut = {k: (dict(v) if isinstance(v, dict) else v) for k, v in md.CUT.items()} + rule = row.get("끝수") + if isinstance(rule, dict) and rule.get("대상") == "계": + cut["계"] = {"방법": rule["방법"], "자리": int(rule.get("자리") or 0)} + if row.get("끝전"): + cut["끝전"] = row["끝전"] + doc["끝수"] = cut + for k in ("단계", "조정"): + if row.get(k): + doc[k] = row[k] + return doc + + +def _split(me: str) -> dict: + sign, low, up = md.split_id(me) + return {"기호": sign, "아래": low, "위": up} + + +def _var_row(me: str, name: str, look: dict, spec: dict, link: dict) -> dict: + out = { + "열쇠": name, + **_split(me), + "이름": look.get("이름") or name, + "단위": spec.get("단위") or "", + } + for k in ("설명", "출처"): + if spec.get(k): + out[k] = spec[k] + out["연결"] = link + return out + + +def _hand(c: _Conv, at: int, item: dict) -> str: + """손 값 줄 — 값묶음(노 · 재 · 경) 또는 손 단가 = 숫자 연결.""" + name = str(item.get("이름") or "") + given = item.get("값묶음") + if given: + if set(given) < set(md.COSTS): + c.notes.append(f"호표 「{name}」 값묶음 일부 — 빠진 비목 0") + value = {k: Decimal(str(given.get(k) or 0)) for k in md.COSTS} + else: + value = Decimal(str(item["단가"])) + link = {"갈래": "숫자", "값": value} + return c._var(("손", at), "요소", f"단가:{name}", f"{name}단가", link) + + +def _group_name(items: list[dict]) -> str: + kinds = [] + for x in items: + k = x.get("종류") or str(x.get("이름") or "") + if k not in kinds: + kinds.append(k) + return " · ".join(kinds) + + +def _money(c: _Conv, row: dict) -> list[dict]: + items, adds = row.get("호표") or [], row.get("덧줄") or [] + out: list[dict] = [] + for at, item in enumerate(items): + name = str(item.get("이름") or "") + line = {"이름": name} + if item.get("규격"): + line["규격"] = item["규격"] + line["단위"] = item.get("단위") or "" + try: + qty = c.text(item["수량"]) + except mf.FormulaError as e: + c.notes.append(f"호표 「{name}」 수량 못 읽음 — {e}") + qty = str(item["수량"]) + kind = item.get("종류") + if item.get("값묶음") or item.get("단가") is not None: # M02 손 값(값묶음 · 손 단가) + price, line["비목"] = _hand(c, at, item), item.get("비목") or "묶음" + if line["비목"] != "묶음" and item.get("값묶음"): + price = f"{price}.계" + elif kind == "로직": + node = mf.parse(str(item["요소"])) + price = md.text_of(c.emit(node)) + bundle = node[0] == "logic" + if "비목" in item: + line["비목"] = item["비목"] + if bundle: + price = f"{price}.계" + else: + line["비목"] = "묶음" + elif isinstance(item.get("요소"), dict): + line["비목"] = item["비목"] + price = c.material(at, item) + else: + line["비목"] = item["비목"] + ref = str( + item["요소"] + ) # 품셈재료(MP)만 줄 단위로 값이 갈림 — 그 밖은 식 속 요소와 한 기호 + price = c.element( + ref, None, str(item.get("단위") or "") if ref[:2] == mm.LINKED else None + ) + line |= {"수량": qty, "단가": price} + for k in ("번지", "합밖", "자리", "출처"): + if k in item: + line[k] = item[k] + out.append(line) + seen: dict[str, tuple] = {} + for add in adds: + name = str(add.get("이름") or "") + try: + price = c.text(add["식"], dict(seen)) + except mf.FormulaError as e: + c.notes.append(f"덧줄 「{name}」 식 못 읽음 — {e}") + price = str(add["식"]) + line = {"이름": name, "단위": "식", "비목": add["비목"], "단가": price} + for k in ("번지", "합밖", "자리", "출처"): + if k in add: + line[k] = add[k] + out.append(line) + seen[name] = ("덧", name) + last = { + str(x.get("이름") or ""): i for i, x in enumerate(items) + } # 같은 이름 = 뒤 줄(옛 `줄` 묶음) + for i, line in enumerate(out): + key = ("합", line["이름"]) if i < len(items) else ("덧", line["이름"]) + if key in c.used_lines and (i >= len(items) or last[line["이름"]] == i): + line["기호"] = c.reg.id(key) + _groups(out, [*items, *adds]) + return out + + +def _groups(out: list[dict], items: list[dict]) -> None: + """묶음 — 단가산출 부름 줄마다 한 묶음 · 그 사이 다른 줄은 이어진 것끼리 · 묶음 하나면 안 둠.""" + runs: list[tuple[str, list[int]]] = [] + for i, item in enumerate(items): + if item.get("종류") == "로직": + runs.append((str(item.get("이름") or ""), [i])) + elif runs and runs[-1][0] == "" and items[runs[-1][1][-1]].get("종류") != "로직": + runs[-1][1].append(i) + else: + runs.append(("", [i])) + if len(runs) < 2: + return + for name, members in runs: + label = name or _group_name([items[i] for i in members]) + for i in members: + out[i]["묶음"] = label diff --git a/resources/master_data/scripts/master_doc_link.py b/resources/master_data/scripts/master_doc_link.py new file mode 100644 index 000000000..cbbc06d4f --- /dev/null +++ b/resources/master_data/scripts/master_doc_link.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""수식 문서 연결 풀이 — M01 마스터(PLAN 802-1 · 꼴 `_수식문서_틀.md` 3 장). + +`master_doc_calc.calc(doc, Links(...))` 가 부름. 셈 엔진(`master_formula.run` +· `evaluate`)은 안 부름 — +마스터 색인(`Master`) · 요소 값(준용 · 자재 고르기 · 단위 환산 = `master_material` +· `master_pick`)만 씀. +표 찾기(범위 규칙 · 여러 줄 가름)는 여기 +· TS 짝 = `common_util/common_util_formula_doc_calc.ts` `findRow`. +수식 문서 저장 자리 = `resources/master_data/수식문서/수식_<옛 로직 파일 꼬리>.json` +(로직 파일마다 한 벌). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path + +import master_doc as md +import master_doc_calc as mc +import master_formula as mf +import master_material as mm +import master_pick as mp + +FOLDER = Path(__file__).resolve().parents[1] / "수식문서" +_ARITH_OK = ("num", "bin", "neg") + + +def _arith(text: str): + """표 칸의 분수 · 식 글(「1/1.30」) → 수 · 아니면 글 그대로.""" + try: + node = md.parse(text) + except md.DocError: + return text + if any(n[0] not in _ARITH_OK for n in md.walk(node)): + return text + + def ev(n): + if n[0] == "num": + return n[1] + if n[0] == "neg": + return -ev(n[1]) + a, b = ev(n[2]), ev(n[3]) + return {"+": a + b, "-": a - b, "*": a * b}.get(n[1]) if n[1] != "/" else a / b + + try: + return ev(node) + except ArithmeticError: + return text + + +def _cell(value, col: str): + if value is None: + raise mc.Gap(f"칸 「{col}」 값 없음 — 관리자가 채울 값") + if isinstance(value, list): + raise mc.Broken(f"칸 「{col}」 값이 범위 — 입력으로 받아야 함") + if isinstance(value, str): + return _arith(value) + return value + + +# ── 표 찾기 ─────────────────────────────────────────────────────────── +def _match(table: dict, row: dict, cond: str, want) -> bool: + if cond not in row: # 빈 조건 칸 = 무엇이든 맞음 + return True + have = row[cond] + if table.get("조건", {}).get(cond) != "범위": + return have == want + low, high = have + rule, *more = str(table.get("범위규칙") or "초과·이하").split(" · ") + rule = rule.split(" (")[0] + if rule in ("이상·이하",): + return (low is None or want >= low) and (high is None or want <= high) + if rule in ("초과·이하", "이하"): + return (low is None or want > low) and (high is None or want <= high) + if rule == "이상·미만": + top = max(r[cond][1] for r in table["줄"] if r.get(cond) and r[cond][1] is not None) + closed = any("이하 포함" in x for x in more) and high == top + return (low is None or want >= low) and ( + high is None or want < high or (closed and want == high) + ) + raise mc.Broken(f"모르는 범위규칙 「{table.get('범위규칙')}」") + + +def find(table: dict, conds: dict, want: str | None = None) -> dict: + """조건에 맞는 한 줄 — 「이하」 · 「이상·이하」 는 위 끝이 가장 작은 줄. + 여럿이면 찾는 칸 있는 줄.""" + for cond in conds: + if cond not in table.get("조건", {}): + raise mc.Broken(f"표 {table.get('키')} 에 없는 조건 「{cond}」") + try: + hits = [r for r in table["줄"] if all(_match(table, r, c, v) for c, v in conds.items())] + except TypeError: + raise mc.Broken(f"표 {table.get('키')} 범위 조건에 수 아닌 값") from None + if len(hits) > 1 and str(table.get("범위규칙") or "").split(" · ")[0] in ("이하", "이상·이하"): + for c in conds: + ups = [r[c][1] for r in hits if table["조건"].get(c) == "범위" and r.get(c)] + if ups and None not in ups: + hits = [r for r in hits if not r.get(c) or r[c][1] == min(ups)] + if len(hits) > 1 and want: + hits = [r for r in hits if want in r] + if len(hits) != 1: + what = " · ".join(f"{c} {mc.plain_text(v)}" for c, v in conds.items()) + where = f"표 {table.get('키')}({table.get('원문번호')})" + raise mc.Gap(f"{where}에 {what} 인 줄이 " + ("없음" if not hits else f"{len(hits)}개")) + return hits[0] + + +# ── 연결 ────────────────────────────────────────────────────────────── +class Links: + """M01 마스터 연결 — 표 · 요소 · 단가산출 문서(`docs` = {키: 문서} · 없으면 저장 폴더).""" + + def __init__(self, master: mf.Master, docs: dict[str, dict] | None = None): + self.master = master + self.docs = load_all() if docs is None else docs + + def _get(self, ref: str) -> dict: + try: + return self.master.get(ref) + except mf.FormulaError as e: + raise mc.Broken(str(e)) from None + + def table(self, p: dict): + table = self._get(p["표"]) + if "줄" not in table or "조건" not in table: + raise mc.Broken(f"「{p['표']}」 표 아님") + row = find(table, p.get("조건") or {}, p.get("칸")) + if p["칸"] not in row: # 원문 「-」 = 그 줄에 값 칸 없음 + if p["칸"] not in (table.get("값칸") or {}) and not any( + p["칸"] in r for r in table["줄"] + ): + raise mc.Broken(f"표 {table.get('키')} 에 칸 「{p['칸']}」 없음") + raise mc.Gap(f"표 {table.get('키')} 고른 줄에 「{p['칸']}」 값 없음") + return _cell(row[p["칸"]], p["칸"]) + + def element(self, p: dict): + env = {} + if p.get("지역") is not None: + env[mm.REGION] = p["지역"] + if p.get("계약종별") is not None: + env[mm.TARIFF] = p["계약종별"] + if p.get("재료") is not None or p.get( + "단위" + ): # 금액 줄 단가 — 고르기 · 단위 환산 · 값 없음 = 빈 줄 + ref = p.get("재료") if p.get("재료") is not None else p["키"] + price, _, why, _ = mp.priced(self.master, {"단위": p.get("단위")}, ref, env, None) + if price is None: + raise mc.Gap(why or "단가 없음") + if isinstance(price, (dict, list)): + raise mc.Broken("단가가 묶음 · 범위") + return price + self._get(p["키"]) + try: + value, _ = mm.element(self.master, p["키"], env) + except mf.FormulaError as e: + raise mc.Broken(str(e)) from None + col = p.get("칸") + if col: + if not isinstance(value, dict): + raise mc.Broken(f"「{p['키']}」 값에 칸 없음") + if col not in value: + raise mc.Broken(f"「{p['키']}」 칸 「{col}」 없음") + return _cell(value[col], col) + if isinstance(value, dict): + raise mc.Broken(f"「{p['키']}」 값이 묶음 — 칸 필요") + return _cell(value, self.master.label(p["키"])) + + def doc(self, key: str) -> dict | None: + if key in self.docs: + return self.docs[key] + try: + row = self.master.get(key) + except mf.FormulaError: + return None + return self.docs.get(str(row.get("키"))) + + +# ── 저장 ────────────────────────────────────────────────────────────── +def file_of(logic_file: str) -> str: + """옛 로직 파일 이름 → 수식 문서 파일 이름(`로직_X.json` → `수식_X.json`).""" + return "수식_" + logic_file.split("_", 1)[1] + + +def load_file(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal) + + +def load_all(folder: Path = FOLDER) -> dict[str, dict]: + """저장 폴더 문서 전부 — {키: 문서}.""" + out: dict[str, dict] = {} + for path in sorted(folder.glob("수식_*.json")): + for doc in load_file(path).get("줄") or []: + out[str(doc.get("키"))] = doc + return out + + +def _norm(v): + if isinstance(v, Decimal): + if v == v.to_integral_value() and "E" not in str(v) and "." not in str(v): + return int(v) + return float(v) + if isinstance(v, dict): + return {k: _norm(x) for k, x in v.items()} + if isinstance(v, list): + return [_norm(x) for x in v] + return v + + +def _enc(v, depth: int, pad: str) -> str: + """깊이 4(변수 · 식 · 금액 줄 하나)부터 한 줄.""" + one = json.dumps(v, ensure_ascii=False, separators=(", ", ": ")) + if depth >= 4 or not isinstance(v, (dict, list)) or not v: + return one + inner, cut = pad + " ", ",\n" + if isinstance(v, list): + body = cut.join(inner + _enc(x, depth + 1, inner) for x in v) + return "[\n" + body + "\n" + pad + "]" + body = cut.join( + inner + json.dumps(k, ensure_ascii=False) + ": " + _enc(x, depth + 1, inner) + for k, x in v.items() + ) + return "{\n" + body + "\n" + pad + "}" + + +def dump(data) -> str: + """저장 글 — UTF-8 · LF · 들여쓰기 2칸 · 변수 · 식 · 금액 줄 하나 = 한 줄. + 수는 군더더기 없이.""" + return _enc(_norm(data), 0, "") + "\n" diff --git a/resources/tester/helper_802_1_formula_doc.cjs b/resources/tester/helper_802_1_formula_doc.cjs new file mode 100644 index 000000000..86dbe7c22 --- /dev/null +++ b/resources/tester/helper_802_1_formula_doc.cjs @@ -0,0 +1,73 @@ +/* 802-1 수식 문서 거울 헬퍼 — common_util_formula_doc*.ts 를 그 자리에서 트랜스파일해 Node 로 돌림. + * stdin = {문서들: {키: 문서}, 대상: [키], 기록: {열쇠: {값 | 빈 | 끊김}}, 글: [식 글], 표: [{표, 조건, 칸}]} + * stdout = {답: {키: calcDoc 답}, 글: [textOf 또는 {틀림}], 표: [tableValue 값 또는 {빈 | 끊김}]} + * 기록 열쇠 = 갈래 + "|" + 값 글(파이썬 `_canon` 과 같은 꼴). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const root = path.join(__dirname, "..", "..", "common_util"); +const doc = load(path.join(root, "common_util_formula_doc.ts")); +const calc = load(path.join(root, "common_util_formula_doc_calc.ts")); +const table = load(path.join(root, "common_util_formula_doc_table.ts")); + +function canon(v) { + if (typeof v === "number") return calc.plainText(v); + if (Array.isArray(v)) return v.map(canon); + if (v && typeof v === "object") { + const out = {}; + for (const k of Object.keys(v).sort()) out[k] = canon(v[k]); + return out; + } + return v; +} +const keyOf = (kind, p) => `${kind}|${JSON.stringify(canon(p))}`; + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const record = input.기록 || {}; +function answer(kind, p) { + const k = keyOf(kind, p); + if (!(k in record)) throw new calc.Broken(`기록 없음 ${k}`); + const got = record[k]; + if ("빈" in got) throw new calc.Gap(got.빈); + if ("끊김" in got) throw new calc.Broken(got.끊김); + return got.값; +} +const links = { + element: (p) => answer("요소", p), + table: (p) => answer("표", p), + doc: (key) => (input.문서들 || {})[key] || null, +}; +const out = { 답: {}, 글: [], 표: [] }; +for (const key of input.대상 || []) out.답[key] = calc.calcDoc(input.문서들[key], links); +for (const text of input.글 || []) { + try { + out.글.push(doc.textOf(doc.parse(text))); + } catch (e) { + if (!(e instanceof doc.DocError)) throw e; + out.글.push({ 틀림: true }); + } +} +for (const one of input.표 || []) { + try { + out.표.push({ 값: table.tableValue(one.표, { 조건: one.조건, 칸: one.칸 }) }); + } catch (e) { + if (e instanceof calc.Gap) out.표.push({ 빈: true }); + else if (e instanceof calc.Broken) out.표.push({ 끊김: true }); + else throw e; + } +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_802_1_formula_doc.py b/resources/tester/test_802_1_formula_doc.py new file mode 100644 index 000000000..8a938df17 --- /dev/null +++ b/resources/tester/test_802_1_formula_doc.py @@ -0,0 +1,588 @@ +"""802-1 단가산출 수식 문서 — 꼴 · 셈 · 지움 견딤 · 표지 · 거울(파이썬 ↔ TS) 지킴 시험. + +꼴 = `resources/master_data/_수식문서_틀.md` · 파이썬 = `master_doc*.py` · TS = `common_util_formula_doc*.ts`. +① 식 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파. +② 셈 — 입력 · 선택 · 숫자 · 식 · 만약 · % · 표 조건(범위 규칙) · 요소 · 단가산출 부름 · 묶음 소계 · 합계 · + 반올림 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖 · 결과 문서. +③ 표지 — 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 · 겹침 = 금액 「—」 · 예외 없음. +④ 지움 견딤 — 변수 · 식 줄 · 금액 줄 · 식 글 하나씩 모두 지워도 예외 0. +⑤ 거울 — 같은 문서 · 같은 연결 값으로 파이썬 · TS 답이 같음(실제 M01 문서 표본 + 위 문서들). +""" + +import copy +import importlib +import json +import os +import subprocess +from decimal import Decimal as D + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠 + +md = importlib.import_module("master_doc") +mc = importlib.import_module("master_doc_calc") +ml = importlib.import_module("master_doc_link") +cv = importlib.import_module("master_doc_convert") + +HERE = os.path.dirname(os.path.abspath(__file__)) +COSTS = ("노무비", "재료비", "경비", "계") + + +# ── 시험 연결 ───────────────────────────────────────────────────────── +TABLE = { + "키": "QX990001", + "원문번호": "9-9-9", + "조건": {"유형": "고르기", "두께": "범위"}, + "범위규칙": "초과·이하", + "값칸": {"품": "인", "율": "%"}, + "줄": [ + {"유형": "보통", "두께": [None, D(10)], "품": D("0.1"), "율": D(5)}, + {"유형": "보통", "두께": [D(10), D(20)], "품": D("0.2"), "율": D(6)}, + {"유형": "복잡", "두께": [None, D(20)], "품": D("0.3")}, + {"유형": "분수", "품": "1/1.30"}, + ], +} + + +class Fake: + """시험 연결 — 요소 {키: 값 | Gap 까닭} · 표 {키: 표} · 문서 {키: 문서}.""" + + def __init__(self, elements=None, tables=None, docs=None): + self.elements = elements or {} + self.tables = {"QX990001": TABLE, **(tables or {})} + self.docs = docs or {} + + def element(self, p): + key = p.get("키") or json.dumps(p.get("재료"), ensure_ascii=False, sort_keys=True) + if key not in self.elements: + raise mc.Broken(f"없는 요소 「{key}」") + got = self.elements[key] + if isinstance(got, str) and got.startswith("빈:"): + raise mc.Gap(got[2:]) + return got[p["칸"]] if p.get("칸") else got + + def table(self, p): + if p["표"] not in self.tables: + raise mc.Broken(f"없는 표 「{p['표']}」") + return ml.Links.table(_TableOnly(self.tables[p["표"]]), p) + + def doc(self, key): + return self.docs.get(key) + + +class _TableOnly: + def __init__(self, table): + self.t = table + + def _get(self, ref): + return self.t + + +def _var(sid, link, key=None, name=""): + sign, low, up = md.split_id(sid) + return { + "열쇠": key or sid, + "기호": sign, + "아래": low, + "위": up, + "이름": name or sid, + "연결": link, + } + + +def _doc(variables=(), lines=(), money=(), **more): + out = md.blank("GX990001", "시험") + out["변수"] = [_var(*v) if isinstance(v, tuple) else v for v in variables] + out["식"] = [{"기호": a, "식": b} for a, b in lines] + out["금액"] = list(money) + out.update(more) + return out + + +def _man(name, qty, price, kind="노무비", **more): + return {"이름": name, "단위": "인", "비목": kind, "수량": qty, "단가": price, **more} + + +def _calc(doc, links=None, given=None): + return mc.calc(doc, links or Fake(), given) + + +def _money(got): + return {k: got[k] for k in COSTS} + + +def SAMPLE(): + """합판거푸집 꼴 견본 — 선택 · 입력 · 표(범위) · 요소 · 식 · 만약 · % · 덧줄 꼴 금액.""" + return _doc( + [ + ("S_유형", {"갈래": "선택", "목록": ["보통", "복잡"], "값": "보통"}, "유형"), + ("t", {"갈래": "입력", "값": D(15), "범위": [0, 100]}, "두께"), + ( + "Q^표", + { + "갈래": "표", + "표": "QX990001", + "조건": {"유형": "S_유형", "두께": "t"}, + "칸": "품", + }, + ), + ("w_형틀", {"갈래": "요소", "키": "LB990001"}), + ("w_보통", {"갈래": "요소", "키": "LB990002"}), + ("r_할증", {"갈래": "숫자", "값": D(10)}), + ("Q_형틀", {"갈래": "식"}), + ], + [("Q_형틀", "Q^표 × (1 + 만약(S_유형 == '복잡', r_할증, 0)%)")], + [ + _man("형틀목공", "Q_형틀", "w_형틀", 기호="C_형틀"), + _man("보통인부", "0.05", "w_보통"), + {"이름": "공구손료", "단위": "식", "비목": "경비", "단가": "노무비 × 3%"}, + {"이름": "소모", "단위": "식", "비목": "재료비", "단가": "C_형틀 × 1 ÷ 3"}, + ], + ) + + +LABOR = {"LB990001": D(274978), "LB990002": D(165545)} + + +# ── ① 꼴 ────────────────────────────────────────────────────────────── +@pytest.mark.parametrize( + "text, want", + [ + ("a*b/c", "a × b ÷ c"), + ("a/(b*c)", "a ÷ (b × c)"), + ("a-(b-c)", "a - (b - c)"), + ("(a-b)-c", "a - b - c"), + ("-x*2", "-x × 2"), + ("-(a+b)", "-(a + b)"), + ("x %", "x%"), + ("(a+b)%", "(a + b)%"), + ("3600×q_s×K×f×E_s÷C_m", "3600 × q_s × K × f × E_s ÷ C_m"), + ("만약(S=='보통' 그리고 t<=10, 1, 0)", "만약(S == '보통' 그리고 t <= 10, 1, 0)"), + ("a ≤ b", "a <= b"), + ("(a= {"값", "표지", "셈됨", "끝수"} + if not got["셈됨"]: + assert got.get("계") is None and got.get("결과") is None + assert got["표지"] or got.get("까닭") + assert count > 300 + + +# ── ⑤ 거울 ──────────────────────────────────────────────────────────── +def _canon(v): + if isinstance(v, (D, int, float)) and not isinstance(v, bool): + return mc.plain_text(D(str(v))) + if isinstance(v, list): + return [_canon(x) for x in v] + if isinstance(v, dict): + return {k: _canon(v[k]) for k in sorted(v)} + return v + + +class Rec: + """연결 기록 — 같은 연결 값을 TS 에 넘김.""" + + def __init__(self, links): + self.links, self.log = links, {} + + def _keep(self, kind, p, fn): + key = f"{kind}|{json.dumps(_canon(p), ensure_ascii=False, separators=(',', ':'))}" + try: + got = fn(p) + except mc.Gap as e: + self.log[key] = {"빈": str(e)} + raise + except mc.Broken as e: + self.log[key] = {"끊김": str(e)} + raise + self.log[key] = {"값": got} + return got + + def element(self, p): + return self._keep("요소", p, self.links.element) + + def table(self, p): + return self._keep("표", p, self.links.table) + + def doc(self, key): + return self.links.doc(key) + + +def _ts(payload): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_802_1_formula_doc.cjs")], + input=json.dumps(ml._norm(payload), ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _same(a, b, where=""): + if isinstance(a, dict): + assert isinstance(b, dict) and set(a) == set(b), (where, sorted(a), sorted(b or {})) + for k in a: + _same(a[k], b[k], f"{where}.{k}") + elif isinstance(a, list): + assert isinstance(b, list) and len(a) == len(b), where + for i, (x, y) in enumerate(zip(a, b)): + _same(x, y, f"{where}[{i}]") + elif isinstance(a, (D, int, float)) and not isinstance(a, bool): + assert isinstance(b, (int, float)) and not isinstance(b, bool), (where, a, b) + assert float(a) == pytest.approx(b, rel=1e-9, abs=1e-9), (where, a, b) + else: + assert a == b, (where, a, b) + + +def test_거울_파이썬_TS_같은_답(): + links, _ = _real_docs(()) + picks = sorted(_ROWS)[::30] + list(REAL) + for key in picks: + links.doc(key) + rec = Rec(links) + want = {key: mc.calc(links.docs[key], rec) for key in picks} + all_docs = links.docs + fakes = { + "SAMPLE": (SAMPLE(), Fake(LABOR)), + "빈": (md.blank(), Fake()), + "순환": (_doc([], [("a", "b"), ("b", "a")], [_man("갑", "1", "a")]), Fake()), + "0나눔": (_doc([], [("a", "1 ÷ 0")], [_man("갑", "1", "a")]), Fake()), + } + for name, (doc, links) in fakes.items(): + one = Rec(links) + want[name] = mc.calc(doc, one) + rec.log.update(one.log) + all_docs[name] = doc + texts = [ + "a*b/c", + "-(a+b)%", + "만약(S=='x', 1, 2)", + "단계.'소계'.계 × 2", + "a +", + "C_{m 3}^표 ÷ 2", + ] + cases = [ + {"조건": {"유형": "보통", "두께": D(t)}, "칸": "품"} + for t in ("5", "10", "10.5", "20", "25") + ] + [{"조건": {"유형": "분수"}, "칸": "품"}, {"조건": {"유형": "복잡"}, "칸": "율"}] + got = _ts( + { + "문서들": all_docs, + "대상": list(want), + "기록": rec.log, + "글": texts, + "표": [{"표": TABLE, **c} for c in cases], + } + ) + for key, answer in want.items(): + _same(ml._norm(answer), got["답"][key], key) + for text, ts_text in zip(texts, got["글"]): + try: + assert md.text_of(md.parse(text)) == ts_text + except md.DocError: + assert ts_text == {"틀림": True} + for case, ts_value in zip(cases, got["표"]): + try: + py = {"값": ml.Links.table(_TableOnly(TABLE), {"표": "QX990001", **case})} + except mc.Gap: + py = {"빈": True} + _same(ml._norm(py), ts_value, str(case)) + assert len(want) >= 60