feat(802-1): 단가산출 수식 문서 꼴 · 계산기(파이썬 · TS 짝) · 표지 · 꼴 문서

- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — resources/master_data/_수식문서_틀.md
- 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파 · 정적 검사 = master_doc.py ↔ common_util_formula_doc.ts
- 셈 = master_doc_calc.py ↔ common_util_formula_doc_calc.ts — 기호는 필요할 때 셈 · 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 = 표지 + 금액 없음 · 값 없음 = 그 줄만 비움 · 묶음 소계 · 합계 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖
- M01 연결 풀이(표 찾기 · 요소 · 단가산출 문서) = master_doc_link.py ↔ common_util_formula_doc_table.ts(표 찾기) · 옛 엔진 셈 안 부름
- 옛 로직 변환 = master_doc_convert.py(802-2 빌더가 부름)
- 시험 test_802_1_formula_doc 48(꼴 · 셈 · 지움 견딤 300 넘는 지운 문서 예외 0 · 표지 · 거울 파이썬 ↔ TS 60 문서)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 18:41:39 +09:00
co-authored by Claude Opus 5.5
parent 2376e70fbb
commit f7038cc280
10 changed files with 4004 additions and 0 deletions
+624
View File
@@ -0,0 +1,624 @@
/* =============================================================================
* common_util_formula_doc.ts
* 단가산출 수식 문서 — 꼴 · 풀이(파스) · 글로 되돌림 · 기호 · 검사(PLAN 802-1 · 요구 664 · 요구서_664)
* 꼴 글 = resources/master_data/_수식문서_틀.md · 셈 = common_util_formula_doc_calc.ts
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc.py · 거울 시험 test_802_1_formula_doc.py
* (고칠 때 둘을 같이 고침) · 화면 · DOM · fetch 없음(브라우저 · 서버 Node 둘 다 부름)
* ========================================================================== */
export const COSTS = ["노무비", "재료비", "경비"] as const;
export const BUNDLE = [...COSTS, "계"] as const;
export const FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"] as const;
export const MONEY_NAMES = [...COSTS, "번지", "단계"];
export const RESERVED = [...BUNDLE, "번지", "단계", "그리고", "또는", ...FUNCS] as string[];
export const LINKS = ["숫자", "선택", "입력", "요소", "표", "단가산출", "식"];
export const WAYS = ["버림", "반올림", "올림"];
export const ENDS = ["비목버림", "큰나머지"];
export const CUT = {
줄: { 방법: "버림", 자리: 1 },
비목: { 방법: "버림", 자리: 0 },
끝전: "비목버림",
};
const PREC: Record<string, number> = {
또는: 1,
그리고: 2,
"<": 3,
"<=": 3,
">": 3,
">=": 3,
"==": 3,
"!=": 3,
"+": 4,
"-": 4,
"*": 5,
"/": 5,
};
const SHOW: Record<string, string> = { "*": "×", "/": "÷" };
const ALIAS: Record<string, string> = {
"×": "*",
"÷": "/",
"−": "-",
"≤": "<=",
"≥": ">=",
"≠": "!=",
};
const BASE = "\\p{L}[\\p{L}\\p{N}′']*";
const RUN = "[\\p{L}\\p{N}′']+";
const PART = `(?:\\{[^{}]*\\}|${RUN})`;
const ID_SRC = `${BASE}(?:_${PART})?(?:\\^${PART})?`;
const ID_AT = new RegExp(ID_SRC, "uy");
const ID_FULL = new RegExp(`^${ID_SRC}$`, "u");
const SPLIT = new RegExp(`^(${BASE})(?:_(${PART}))?(?:\\^(${PART}))?$`, "u");
const RUN_FULL = new RegExp(`^${RUN}$`, "u");
const NUM_AT = /\d+(?:\.\d+)?/y;
const FIELD_AT = new RegExp(RUN, "uy");
export const HOLE = /\{([^{}]+)\}/g;
export type Value = number | string | boolean | Record<string, unknown>;
export type Node =
| ["num", number, string]
| ["str", string]
| ["var", string]
| ["field", Node, string]
| ["neg", Node]
| ["pct", Node]
| ["call", string, Node[]]
| ["bin", string, Node, Node];
export interface Rule {
방법: string;
자리?: number;
}
export interface Link {
갈래?: string;
값?: unknown;
목록?: unknown[];
범위?: [number | null, number | null];
키?: string;
표?: string;
칸?: string;
단위?: string;
재료?: Record<string, unknown>;
지역?: string;
계약종별?: string;
조건?: Record<string, string>;
입력?: Record<string, string>;
}
export interface DocVar {
열쇠?: string;
기호?: string;
아래?: string;
위?: string;
이름?: string;
단위?: string;
연결?: Link;
}
export interface MoneyLine {
이름?: string;
비목?: string;
수량?: string | null;
단가?: string;
단위?: string;
묶음?: string;
기호?: string;
자리?: Rule;
번지?: string;
합밖?: boolean;
}
export interface FormulaDoc {
꼴?: string;
판?: number;
키?: string;
이름?: string;
결과단위?: string;
변수?: DocVar[];
식?: { 기호?: string; 식?: string }[];
결과?: string;
금액?: MoneyLine[];
끝수?: { 줄?: Rule; 비목?: Rule; 계?: Rule; 끝전?: string };
단계?: { 이름?: string; 단?: number; 줄?: string[]; 제외?: boolean }[];
조정?: { 배율?: Record<string, number>; 옮김?: string };
}
export interface Mark {
갈래: string;
글: string;
자리?: string;
기호?: string;
}
export class DocError extends Error {}
// ── 기호 ──────────────────────────────────────────────────────────────
const part = (text: string): string => (RUN_FULL.test(text) ? text : `{${text}}`);
/** 기호 · 아래 · 위 → 식 글 이름(`Q_시공^표`) */
export function symId(sign: string, low = "", up = ""): string {
let out = sign ?? "";
if (low) out += `_${part(low)}`;
if (up) out += `^${part(up)}`;
return out;
}
/** 식 글 이름 → [기호, 아래, 위] */
export function splitId(text: string): [string, string, string] {
const m = SPLIT.exec(text ?? "");
if (!m) return [text, "", ""];
const strip = (s?: string): string => (s ? (s.startsWith("{") ? s.slice(1, -1) : s) : "");
return [m[1], strip(m[2]), strip(m[3])];
}
export const varId = (v: DocVar): string => symId(v.기호 ?? "", v.아래 ?? "", v.위 ?? "");
export const goodId = (text: string): boolean =>
ID_FULL.test(text ?? "") && !RESERVED.includes(text);
// ── 풀이 ──────────────────────────────────────────────────────────────
class Parser {
i = 0;
constructor(readonly s: string) {}
fail(why: string): never {
throw new DocError(`${why} — 「${this.s}」 ${this.i}자`);
}
ws(): void {
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
}
op(...words: string[]): string | null {
this.ws();
for (const w of words) {
if (!this.s.startsWith(w, this.i)) continue;
if (w === "그리고" || w === "또는") {
ID_AT.lastIndex = this.i;
const m = ID_AT.exec(this.s);
if (m && m[0].length !== w.length) continue;
}
this.i += w.length;
return ALIAS[w] ?? w;
}
return null;
}
need(word: string): void {
if (!this.op(word)) this.fail(`「${word}」 없음`);
}
parse(): Node {
this.ws();
if (this.i >= this.s.length) this.fail("빈 식");
const node = this.expr();
this.ws();
if (this.i !== this.s.length) this.fail("식 끝에 남은 글자");
return node;
}
expr(): Node {
let node = this.both();
while (this.op("또는")) node = ["bin", "또는", node, this.both()];
return node;
}
both(): Node {
let node = this.compare();
while (this.op("그리고")) node = ["bin", "그리고", node, this.compare()];
return node;
}
compare(): Node {
const node = this.add();
const o = this.op("<=", ">=", "==", "!=", "≤", "≥", "≠", "<", ">");
return o ? ["bin", o, node, this.add()] : node;
}
add(): Node {
let node = this.mul();
for (let o = this.op("+", "-", "−"); o; o = this.op("+", "-", "−"))
node = ["bin", o, node, this.mul()];
return node;
}
mul(): Node {
let node = this.unary();
for (let o = this.op("*", "/", "×", "÷"); o; o = this.op("*", "/", "×", "÷"))
node = ["bin", o, node, this.unary()];
return node;
}
unary(): Node {
if (this.op("-", "−")) return ["neg", this.unary()];
let node = this.primary();
for (;;) {
this.ws();
if (this.s.startsWith(".", this.i)) {
this.i++;
node = ["field", node, this.field()];
} else if (this.s.startsWith("%", this.i)) {
this.i++;
node = ["pct", node];
} else return node;
}
}
field(): string {
if (this.s.startsWith("'", this.i)) return this.quoted();
FIELD_AT.lastIndex = this.i;
const m = FIELD_AT.exec(this.s);
if (!m) this.fail("칸 이름 없음");
this.i += m[0].length;
return m[0];
}
quoted(): string {
const end = this.s.indexOf("'", this.i + 1);
if (end < 0) this.fail("작은따옴표 안 닫힘");
const text = this.s.slice(this.i + 1, end);
this.i = end + 1;
return text;
}
primary(): Node {
this.ws();
if (this.op("(")) {
const node = this.expr();
this.need(")");
return node;
}
if (this.s.startsWith("'", this.i)) return ["str", this.quoted()];
NUM_AT.lastIndex = this.i;
const n = NUM_AT.exec(this.s);
if (n) {
this.i += n[0].length;
return ["num", Number(n[0]), n[0]];
}
ID_AT.lastIndex = this.i;
const m = ID_AT.exec(this.s);
if (!m) this.fail("기호 · 수 없음");
this.i += m[0].length;
const word = m[0];
this.ws();
if (this.s.startsWith("(", this.i)) {
if (!(FUNCS as readonly string[]).includes(word)) this.fail(`모르는 함수 「${word}」`);
this.i++;
const items = [this.expr()];
while (this.op(",")) items.push(this.expr());
this.need(")");
return ["call", word, items];
}
if (word === "그리고" || word === "또는" || (FUNCS as readonly string[]).includes(word))
this.fail(`「${word}」 자리 아님`);
return ["var", word];
}
}
/** 식 글 → 나무 · 틀리면 DocError */
export function parse(text: unknown): Node {
if (typeof text === "number") return ["num", text, String(text)];
return new Parser(String(text ?? "")).parse();
}
export function* walk(node: Node): Generator<Node> {
yield node;
switch (node[0]) {
case "bin":
yield* walk(node[2]);
yield* walk(node[3]);
break;
case "neg":
case "pct":
case "field":
yield* walk(node[1]);
break;
case "call":
for (const x of node[2]) yield* walk(x);
break;
}
}
/** 식이 부르는 이름(차례 · 겹침 없이 · 칸 이름 빼고) */
export function names(node: Node): string[] {
const out: string[] = [];
for (const n of walk(node)) if (n[0] === "var" && !out.includes(n[1])) out.push(n[1]);
return out;
}
// ── 글로 되돌림 ───────────────────────────────────────────────────────
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
/** 수 → 글(파이썬 Decimal 꼴 · 지수 없음) */
export function numText(n: Node & { 0: "num" }): string {
return n[2];
}
/** 나무 → 식 글(× ÷ · 나무 묶음 그대로 · 괄호는 꼭 필요한 만큼) */
export function textOf(node: Node): string {
switch (node[0]) {
case "num":
return node[2];
case "str":
return `'${node[1]}'`;
case "var":
return node[1];
case "field": {
const base = prec(node[1]) < 9 ? `(${textOf(node[1])})` : textOf(node[1]);
const name = RUN_FULL.test(node[2]) ? node[2] : `'${node[2]}'`;
return `${base}.${name}`;
}
case "pct":
return prec(node[1]) < 9 ? `(${textOf(node[1])})%` : `${textOf(node[1])}%`;
case "neg":
return node[1][0] === "bin" ? `-(${textOf(node[1])})` : `-${textOf(node[1])}`;
case "call":
return `${node[1]}(${node[2].map(textOf).join(", ")})`;
case "bin": {
const [, op, a, b] = node;
const mine = PREC[op];
let left = textOf(a);
let right = textOf(b);
if (prec(a) < mine || (mine === 3 && prec(a) === 3)) left = `(${left})`;
if (prec(b) <= mine) right = `(${right})`;
return `${left} ${SHOW[op] ?? op} ${right}`;
}
}
}
/** 식 글 다듬기 — 못 풀면 그대로 */
export function tidy(text: string): string {
try {
return textOf(parse(text));
} catch (e) {
if (e instanceof DocError) return String(text);
throw e;
}
}
function swap(node: Node, old: string, now: string): Node {
switch (node[0]) {
case "var":
return node[1] === old ? ["var", now] : node;
case "bin":
return ["bin", node[1], swap(node[2], old, now), swap(node[3], old, now)];
case "neg":
case "pct":
return [node[0], swap(node[1], old, now)];
case "field":
return ["field", swap(node[1], old, now), node[2]];
case "call":
return ["call", node[1], node[2].map((x) => swap(x, old, now))];
default:
return node;
}
}
/** 식 글 안 이름 바꿈 — 못 푸는 글은 그대로 */
export function renameText(text: string | null | undefined, old: string, now: string) {
if (text === null || text === undefined || text === "") return text;
let node: Node;
try {
node = parse(text);
} catch (e) {
if (e instanceof DocError) return text;
throw e;
}
return names(node).includes(old) ? textOf(swap(node, old, now)) : text;
}
export const holes = (text: unknown): string[] =>
[...String(text ?? "").matchAll(HOLE)].map((m) => m[1]);
/** 연결이 부르는 이름 — 키 · 재료 조건 `{이름}` · 지역 · 계약종별 · 조건 · 넘길 입력 식 */
export function linkNames(link: Link): string[] {
const out: string[] = [];
const add = (n: string): void => {
if (!out.includes(n)) out.push(n);
};
for (const t of [link.키, link.표, ...Object.values(link.재료 ?? {})]) holes(t).forEach(add);
for (const slot of ["지역", "계약종별"] as const) if (link[slot]) add(link[slot] as string);
for (const slot of ["조건", "입력"] as const)
for (const t of Object.values(link[slot] ?? {})) {
try {
names(parse(t)).forEach(add);
} catch (e) {
if (!(e instanceof DocError)) throw e;
}
}
return out;
}
/** 이름 → 정의 자리 목록(식 · 연결 · 금액) — 둘 이상 = 겹침 */
export function defined(doc: FormulaDoc): Map<string, string[]> {
const out = new Map<string, string[]>();
const put = (k: string, w: string): void => {
out.set(k, [...(out.get(k) ?? []), w]);
};
for (const v of doc.변수 ?? []) if ((v.연결?.갈래 ?? "식") !== "식") put(varId(v), "연결");
for (const l of doc.식 ?? []) put(String(l.기호 ?? ""), "식");
for (const l of doc.금액 ?? []) if (l.기호) put(String(l.기호), "금액");
return out;
}
const fixHoles = (text: string, old: string, now: string): string =>
text.replace(HOLE, (_, h: string) => `{${h === old ? now : h}}`);
/** 기호 · 첨자 바꿈 — 변수표 · 식 줄 머리 · 모든 식 · 연결 안 이름까지(제자리) */
export function rename(doc: FormulaDoc, old: string, sign: string, low = "", up = ""): FormulaDoc {
const now = symId(sign, low, up);
if (!goodId(now)) throw new DocError(`기호 꼴 아님 「${now}」`);
if (now !== old && defined(doc).has(now)) throw new DocError(`같은 기호 있음 「${now}」`);
for (const v of doc.변수 ?? []) {
if (varId(v) === old) Object.assign(v, { 기호: sign, 아래: low, 위: up });
const link = v.연결;
if (!link) continue;
if (link.키) link.키 = fixHoles(link.키, old, now);
if (link.표) link.표 = fixHoles(link.표, old, now);
if (link.재료)
for (const [k, x] of Object.entries(link.재료))
if (typeof x === "string") link.재료[k] = fixHoles(x, old, now);
if (link.지역 === old) link.지역 = now;
if (link.계약종별 === old) link.계약종별 = now;
for (const slot of ["조건", "입력"] as const) {
const box = link[slot];
if (box) for (const k of Object.keys(box)) box[k] = renameText(box[k], old, now) as string;
}
}
for (const l of doc.식 ?? []) {
if (l.기호 === old) l.기호 = now;
l.식 = renameText(l.식, old, now) as string;
}
for (const l of doc.금액 ?? []) {
if (l.기호 === old) l.기호 = now;
l.수량 = renameText(l.수량, old, now) as string | null;
l.단가 = renameText(l.단가, old, now) as string;
}
if (doc.결과 === old) doc.결과 = now;
return doc;
}
/** 빈 문서 — 빈 화면에서 시작(기본 끝수만 적어 둠) */
export function blankDoc(key = "", name = "", unit = "원"): FormulaDoc {
return {
꼴: "수식문서",
판: 1,
키: key,
이름: name,
결과단위: unit,
변수: [],
식: [],
금액: [],
끝수: { 줄: { ...CUT.줄 }, 비목: { ...CUT.비목 }, 끝전: CUT.끝전 },
};
}
// ── 검사 ──────────────────────────────────────────────────────────────
const mark = (갈래: string, 글: string, 자리 = "", 기호 = ""): Mark => ({
갈래,
글,
...(자리 ? { 자리 } : {}),
...(기호 ? { 기호 } : {}),
});
function goodWay(rule: unknown): boolean {
const r = rule as Rule | null;
const at = r?.자리 ?? 0;
return (
!!r &&
typeof r === "object" &&
WAYS.includes(r.방법) &&
typeof at === "number" &&
at >= 0 &&
at <= 6
);
}
/** 돌고 도는 정의(이름 → 부르는 이름들) — 고리마다 한 번 */
export function loops(uses: Map<string, string[]>): string[][] {
const out: string[][] = [];
const seen = new Set<string>();
const done = new Set<string>();
const go = (name: string, path: string[]): void => {
const at = path.indexOf(name);
if (at >= 0) {
const loop = path.slice(at);
const key = [...loop].sort().join("");
if (!seen.has(key)) {
seen.add(key);
out.push(loop);
}
return;
}
if (done.has(name) || !uses.has(name)) return;
for (const n of uses.get(name) ?? []) go(n, [...path, name]);
done.add(name);
};
for (const name of [...uses.keys()]) go(name, []);
return out;
}
/** 정적 검사 — 식 틀림 · 정의 필요 · 겹침 · 순환 · 기호 꼴 · 연결 꼴 · 금액 줄 · 끝수(셈 없음) */
export function check(doc: FormulaDoc): Mark[] {
const out: Mark[] = [];
const defs = defined(doc);
const moneySyms = new Set((doc.금액 ?? []).filter((x) => x.기호).map((x) => String(x.기호)));
for (const [name, where] of defs) {
if (!goodId(name)) out.push(mark("기호 꼴", `「${name}」 은 기호로 못 씀`, "", name));
if (where.length > 1)
out.push(mark("겹침", `「${name}」 정의가 ${where.join(" · ")} 에 둘 이상`, "", name));
}
const uses = new Map<string, string[]>();
const need = (text: unknown, where: string, money: boolean, head = ""): string[] => {
let got: string[];
try {
got = names(parse(text));
} catch (e) {
if (!(e instanceof DocError)) throw e;
out.push(mark("식 틀림", e.message, where, head));
return [];
}
for (const n of got) {
if (MONEY_NAMES.includes(n) && !money)
out.push(mark("금액 이름", `「${n}」 은 금액 줄에서만`, where, head));
else if (
!defs.has(n) &&
!(BUNDLE as readonly string[]).includes(n) &&
!MONEY_NAMES.includes(n)
)
out.push(mark("정의 필요", `「${n}」 정의 필요`, where, n));
else if (moneySyms.has(n) && !money)
out.push(mark("금액 이름", `「${n}」(금액 줄 기호)는 금액 줄에서만`, where, head));
}
return got;
};
for (const v of doc.변수 ?? []) {
const link = v.연결 ?? {};
const me = varId(v);
const kind = link.갈래 ?? "식";
if (!LINKS.includes(kind)) {
out.push(mark("연결 꼴", `「${me}」 연결 갈래 「${kind}」`, "", me));
continue;
}
if (kind === "식" && !defs.has(me)) out.push(mark("정의 필요", `「${me}」 식 줄 없음`, "", me));
const got: string[] = [];
for (const slot of ["조건", "입력"] as const)
for (const [k, t] of Object.entries(link[slot] ?? {}))
got.push(...need(t, `${me} ${slot} ${k}`, false, me));
const all = linkNames(link);
for (const h of all)
if (!got.includes(h) && !defs.has(h))
out.push(mark("정의 필요", `「${h}」 정의 필요`, me, h));
uses.set(me, all);
if (kind === "선택" && !Array.isArray(link.목록))
out.push(mark("연결 꼴", `「${me}」 선택 목록 없음`, "", me));
if (kind === "표" && !(link.표 && link.칸))
out.push(mark("연결 꼴", `「${me}」 표 · 칸 없음`, "", me));
if (kind === "요소" && !(link.키 || link.재료))
out.push(mark("연결 꼴", `「${me}」 요소 없음`, "", me));
if (kind === "단가산출" && !link.키)
out.push(mark("연결 꼴", `「${me}」 단가산출 없음`, "", me));
}
for (const l of doc.식 ?? []) {
const head = String(l.기호 ?? "");
if (!head) out.push(mark("식 틀림", "식 줄 기호 없음"));
uses.set(head, [...(uses.get(head) ?? []), ...need(l.식, head, false, head)]);
}
(doc.금액 ?? []).forEach((l, at) => {
const where = `금액 ${l.이름 || at + 1}`;
if (![...COSTS, "묶음"].includes(l.비목 as string))
out.push(mark("금액 줄", `${where} 비목 「${l.비목}」`));
if (l.수량 !== null && l.수량 !== undefined && l.수량 !== "")
need(l.수량, `${where} 수량`, true);
need(l.단가, `${where} 단가`, true);
if (l.자리 !== undefined && l.자리 !== null && !goodWay(l.자리))
out.push(mark("끝수", `${where} 자리 「${JSON.stringify(l.자리)}」`));
});
const cut = doc.끝수 ?? {};
for (const k of ["줄", "비목", "계"] as const)
if (cut[k] !== undefined && cut[k] !== null && !goodWay(cut[k]))
out.push(mark("끝수", `끝수 ${k} 「${JSON.stringify(cut[k])}」`));
if (cut.끝전 !== undefined && cut.끝전 !== null && !ENDS.includes(cut.끝전))
out.push(mark("끝수", `끝전 「${cut.끝전}」 — ${ENDS.join(" · ")}`));
if (doc.결과 && !defs.has(doc.결과))
out.push(mark("정의 필요", `결과 「${doc.결과}」 정의 필요`, "", doc.결과));
for (const loop of loops(uses))
out.push(mark("순환", [...loop, loop[0]].join(" → "), "", loop[0]));
return out;
}
+603
View File
@@ -0,0 +1,603 @@
/* =============================================================================
* common_util_formula_doc_calc.ts
* 단가산출 수식 문서 셈(PLAN 802-1 · 요구서_664 · 꼴 resources/master_data/_수식문서_틀.md)
* calcDoc(doc, links, given?) — 기호는 필요할 때 셈(차례 무관) · 예외 없음:
* 정의 필요 · 순환 · 0 나눔 · 식 틀림 = 표지 + 금액 「—」(null) · 값 없음 = 그 기호를 쓰는 줄만 비움
* 금액 = 줄 비목마다 끝수 · 합밖 · 제외 단계 · 앞 줄 누계 · 번지 · 단계 → 비목 끝수 → 끝전 → 계 끝수 → 조정
* 표 찾기(findRow · tableValue) = common_util_formula_doc_table.ts
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_calc.py · master_doc_link.py
* · 거울 시험 test_802_1_formula_doc.py(고칠 때 둘을 같이 고침) · 화면 · DOM · fetch 없음
* ========================================================================== */
import {
BUNDLE,
COSTS,
CUT,
DocError,
HOLE,
MONEY_NAMES,
check,
parse,
varId,
type DocVar,
type FormulaDoc,
type Mark,
type Node,
type Rule,
} from "./common_util_formula_doc";
const EPS = 1e-7; // B09_Estimation_Calc_Base EPS 와 같음
const DEEP = 20;
export class Gap extends Error {}
export class Broken extends Error {}
export class Blank {
constructor(readonly why: string) {}
}
export class Stop {
constructor(
readonly why: string,
readonly kind = "멈춤",
readonly sign = "",
) {}
}
type Bundle = Record<string, number>;
type Val = number | string | boolean | Bundle | Record<string, Bundle> | Blank | Stop;
type Payload = Record<string, unknown>;
export interface DocLinks {
element(p: Payload): unknown;
table(p: Payload): unknown;
doc(key: string): FormulaDoc | null | undefined;
}
export interface DocAnswer {
끝수: Record<string, unknown>;
값: Record<string, unknown>;
빈값: Record<string, string>;
표지: Mark[];
셈됨: boolean;
결과?: number | null;
까닭?: string;
줄?: Record<string, unknown>[];
노무비?: number | null;
재료비?: number | null;
경비?: number | null;
계?: number | null;
묶음?: Record<string, unknown>[];
머리?: string;
단계?: Record<string, unknown>[];
}
const bad = (v: unknown): v is Blank | Stop => v instanceof Blank || v instanceof Stop;
export const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
const isObj = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === "object" && !(v instanceof Blank) && !(v instanceof Stop);
// ── 수 ────────────────────────────────────────────────────────────────
function scaled(value: number, places: number, fn: (x: number) => number): number {
const a = Math.abs(value);
const out = fn(a * 10 ** places) / 10 ** places;
return value < 0 ? -out : out;
}
const WAY: Record<string, (x: number) => number> = {
버림: (x) => Math.floor(x + EPS),
올림: (x) => Math.ceil(x - EPS),
반올림: (x) => Math.floor(x + 0.5 + EPS),
};
/** 끝수 한 번 — 엑셀 ROUNDDOWN · ROUNDUP · ROUND 꼴 */
export function cut(value: number | null | undefined, rule?: Rule | null): number | null {
if (value === null || value === undefined) return null;
if (!rule) return value;
return scaled(value, Math.trunc(rule.자리 ?? 0), WAY[rule.방법]);
}
/** 구멍(`{기호}`)에 넣는 글 */
export const plainText = (v: unknown): string =>
isNum(v) ? String(Number(v.toPrecision(15))) : String(v);
export const same = (a: unknown, b: unknown): boolean =>
isNum(a) && isNum(b)
? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b))
: typeof a === typeof b && a === b;
export function binary(op: string, a: unknown, b: unknown): unknown {
if (op === "==") return same(a, b);
if (op === "!=") return !same(a, b);
const nums = (isNum(a) || typeof a === "boolean") && (isNum(b) || typeof b === "boolean");
const x = Number(a);
const y = Number(b);
if (["<", "<=", ">", ">="].includes(op)) {
if (!(nums || (typeof a === "string" && typeof b === "string"))) throw new TypeError(op);
const lt = nums ? x < y && !same(x, y) : (a as string) < (b as string);
const eq = nums ? same(x, y) : a === b;
return op === "<" ? lt : op === "<=" ? lt || eq : op === ">" ? !lt && !eq : !lt;
}
if (op === "+" && typeof a === "string" && typeof b === "string") return a + b;
if (!nums) throw new TypeError(op);
return op === "+" ? x + y : op === "-" ? x - y : op === "*" ? x * y : x / y;
}
// ── 셈 한 벌 ──────────────────────────────────────────────────────────
type Memo = Map<string, DocAnswer>;
class Run {
vars = new Map<string, DocVar>();
lines = new Map<string, string | undefined>();
values = new Map<string, Val>();
busy: string[] = [];
marks: Mark[] = [];
moneySyms: Set<string>;
constructor(
readonly doc: FormulaDoc,
readonly links: DocLinks,
readonly given: Record<string, unknown>,
readonly stack: string[],
readonly memo: Memo,
) {
for (const v of doc.변수 ?? []) this.vars.set(varId(v), v);
for (const l of doc.식 ?? []) this.lines.set(String(l.기호 ?? ""), l.식);
this.moneySyms = new Set((doc.금액 ?? []).filter((x) => x.기호).map((x) => String(x.기호)));
}
mark(stop: Stop): Stop {
const one: Mark = { 갈래: stop.kind, 글: stop.why, ...(stop.sign ? { 기호: stop.sign } : {}) };
if (!this.marks.some((m) => JSON.stringify(m) === JSON.stringify(one))) this.marks.push(one);
return stop;
}
get(name: string, scope?: Record<string, Val>): Val {
if (scope && name in scope) return scope[name];
if (MONEY_NAMES.includes(name) || this.moneySyms.has(name))
return this.mark(new Stop(`「${name}」 은 금액 줄에서만`, "금액 이름", name));
if (this.values.has(name)) return this.values.get(name) as Val;
const at = this.busy.indexOf(name);
if (at >= 0)
return this.mark(new Stop([...this.busy.slice(at), name].join(" → "), "순환", name));
this.busy.push(name);
let value: Val;
try {
value = this.define(name);
} finally {
this.busy.pop();
}
if (value instanceof Stop) this.mark(value);
this.values.set(name, value);
return value;
}
define(name: string): Val {
const v = this.vars.get(name);
const kind = v?.연결?.갈래 ?? "식";
if (this.lines.has(name) && kind !== "식")
return new Stop(`「${name}」 정의가 식 줄 · 연결 둘`, "겹침", name);
if (this.lines.has(name)) return this.expr(this.lines.get(name), name);
if (!v || kind === "식") return new Stop(`「${name}」 정의 필요`, "정의 필요", name);
return this.link(v, name);
}
expr(text: unknown, where = "", scope?: Record<string, Val>): Val {
let node: Node;
try {
node = parse(text);
} catch (e) {
if (!(e instanceof DocError)) throw e;
return this.mark(new Stop(e.message, "식 틀림", where));
}
return this.ev(node, scope, where);
}
arith(fn: () => unknown, where: string): Val {
try {
const got = fn();
if (typeof got === "number" && !Number.isFinite(got))
return this.mark(new Stop(`${where} — 수 아닌 값을 셈`, "셈 틀림", where));
return got as Val;
} catch (e) {
if (!(e instanceof TypeError)) throw e;
return this.mark(new Stop(`${where} — 수 아닌 값을 셈`, "셈 틀림", where));
}
}
ev(node: Node, scope: Record<string, Val> | undefined, where: string): Val {
switch (node[0]) {
case "num":
case "str":
return node[1];
case "var":
return this.get(node[1], scope);
case "field": {
const base = this.ev(node[1], scope, where);
if (bad(base)) return base;
if (!isObj(base) || !(node[2] in base))
return this.mark(new Stop(`칸 「${node[2]}」 없음`, "칸 없음", where));
const got = base[node[2]];
return got === null || got === undefined
? new Blank(`칸 「${node[2]}」 비어 있음`)
: (got as Val);
}
case "neg": {
const a = this.ev(node[1], scope, where);
return bad(a) ? a : this.arith(() => binary("-", 0, a), where);
}
case "pct": {
const a = this.ev(node[1], scope, where);
return bad(a) ? a : this.arith(() => binary("/", a, 100), where);
}
case "call":
return this.call(node[1], node[2], scope, where);
case "bin": {
const op = node[1];
const a = this.ev(node[2], scope, where);
if (bad(a)) return a;
if ((op === "그리고" && !a) || (op === "또는" && a)) return a;
const b = this.ev(node[3], scope, where);
if (bad(b) || op === "그리고" || op === "또는") return b;
if (op === "/" && isNum(b) && b === 0)
return this.mark(new Stop(`${where} — 0 나눔`, "0 나눔", where));
return this.arith(() => binary(op, a, b), where);
}
}
}
call(name: string, items: Node[], scope: Record<string, Val> | undefined, where: string): Val {
if (name === "만약") {
if (items.length !== 3) return this.mark(new Stop("만약(조건, 참, 거짓)", "식 틀림", where));
const test = this.ev(items[0], scope, where);
if (bad(test)) return test;
return this.ev(test ? items[1] : items[2], scope, where);
}
const values = items.map((x) => this.ev(x, scope, where));
const b = values.find(bad);
if (b) return b;
if (name === "최소" || name === "최대")
return this.arith(() => {
if (!values.every(isNum)) throw new TypeError(name);
return (name === "최소" ? Math.min : Math.max)(...(values as number[]));
}, where);
if (values.length > 2) return this.mark(new Stop(`${name}(값, 자리)`, "식 틀림", where));
return this.arith(() => {
const places = values.length > 1 ? values[1] : 0;
if (!isNum(values[0]) || !isNum(places)) throw new TypeError(name);
return cut(values[0], { 방법: name, 자리: Math.trunc(places) });
}, where);
}
fill(text: string): string | Blank | Stop {
let hit: Blank | Stop | null = null;
const out = text.replace(HOLE, (_, h: string) => {
const got = this.get(h);
if (bad(got)) {
hit = hit ?? got;
return "";
}
return plainText(got);
});
return hit ?? out;
}
link(v: DocVar, name: string): Val {
const link = v.연결 ?? {};
const kind = link.갈래;
const own = v.열쇠;
if (kind === "숫자" || kind === "입력" || kind === "선택") {
const given = !!own && own in this.given;
let value = (given ? this.given[own as string] : link.값) as Val | null | undefined;
if (kind === "숫자" && isObj(value)) {
// 손 묶음(노 · 재 · 경 — 엑셀 값 그대로)
const box = value as Record<string, unknown>;
if (!COSTS.every((k) => isNum(box[k])))
return new Stop(`「${name}」 묶음 값은 노무비 · 재료비 · 경비 수`, "연결 꼴", name);
return bundleOf(Object.fromEntries(COSTS.map((k) => [k, box[k] as number])));
}
if (value === null || value === undefined || value === "")
return new Blank(`「${v.이름 || name}」 값 비어 있음`);
if (kind === "입력" && typeof value === "string") {
const n = Number(value.trim().replace(/,/g, ""));
if (value.trim() !== "" && Number.isFinite(n)) value = n;
}
if (kind === "선택" && !given) {
const hit = (link.목록 ?? []).find((x) => plainText(x) === plainText(value));
if (hit === undefined)
return new Stop(`「${name}」 값 「${plainText(value)}」 목록 밖`, "목록 밖", name);
value = hit as Val;
}
if (kind === "입력" && link.범위 && isNum(value)) {
const [low, high] = link.범위;
if ((low !== null && value < low) || (high !== null && value > high))
return new Stop(
`「${name}」 ${plainText(value)} 범위 [${low}, ${high}] 밖`,
"범위 밖",
name,
);
}
return value;
}
const p: Payload = {};
const hits: (Blank | Stop)[] = [];
const keep = (x: Val | string): Val | string => {
if (bad(x)) hits.push(x);
return x;
};
for (const key of ["키", "표"] as const) if (link[key]) p[key] = keep(this.fill(link[key]!));
if (link.재료 && typeof link.재료 === "object") {
const box: Record<string, unknown> = {};
for (const [k, x] of Object.entries(link.재료))
box[k] = typeof x === "string" ? keep(this.fill(x)) : x;
p.재료 = box;
}
for (const slot of ["지역", "계약종별"] as const)
if (link[slot]) p[slot] = keep(this.get(link[slot]!));
for (const slot of ["조건", "입력"] as const)
if (link[slot] && typeof link[slot] === "object") {
const box: Record<string, unknown> = {};
for (const [k, t] of Object.entries(link[slot]!)) box[k] = keep(this.expr(t, name));
p[slot] = box;
}
if (hits.length) return hits[0];
if (link.칸) p.칸 = link.칸;
if (link.단위) p.단위 = link.단위;
try {
if (kind === "요소") return this.links.element(p) as Val;
if (kind === "표") return this.links.table(p) as Val;
if (kind === "단가산출") return this.sub(p, name);
} catch (e) {
if (e instanceof Gap) return new Blank(e.message);
if (e instanceof Broken) return new Stop(`「${name}」 ${e.message}`, "연결 끊김", name);
throw e;
}
return new Stop(`「${name}」 연결 갈래 「${kind}」`, "연결 꼴", name);
}
sub(p: Payload, name: string): Val {
const key = String(p.키);
if (this.stack.includes(key) || this.stack.length > DEEP)
return new Stop(`단가산출 부름 돌고 돎 — ${[...this.stack, key].join(" → ")}`, "순환", name);
const doc = this.links.doc(key);
if (!doc) throw new Broken(`없는 단가산출 「${key}」`);
const given = (p.입력 ?? {}) as Record<string, unknown>;
const memoKey = `${key}|${Object.keys(given)
.sort()
.map((k) => `${k}=${plainText(given[k])}`)
.join("|")}`;
if (!this.memo.has(memoKey))
this.memo.set(memoKey, calcDoc(doc, this.links, given, [...this.stack, key], this.memo));
const got = this.memo.get(memoKey) as DocAnswer;
if (!got.셈됨) {
// 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤
const why = got.까닭 || got.표지.map((m) => m.글).join(" · ");
if (got.까닭) return new Blank(`단가산출 「${key}」 못 셈 — ${why}`);
return new Stop(`단가산출 「${key}」 못 셈 — ${why}`, "부름 멈춤", name);
}
let value: Val;
if ("결과" in got) value = got.결과 as number;
else {
value = Object.fromEntries(BUNDLE.map((k) => [k, got[k] as number]));
}
const col = p.칸 as string | undefined;
if (col) {
if (!isObj(value) || !(col in value)) throw new Broken(`칸 「${col}」 없음`);
return (value as Bundle)[col];
}
return value;
}
}
// ── 금액 ──────────────────────────────────────────────────────────────
const zero = (): Bundle => ({ 노무비: 0, 재료비: 0, 경비: 0 });
const addTo = (acc: Bundle, parts: Bundle): void => {
for (const [k, v] of Object.entries(parts))
if (k in acc && v !== null && v !== undefined) acc[k] += v;
};
const bundleOf = (acc: Bundle): Bundle => ({ ...acc, 계: COSTS.reduce((s, k) => s + acc[k], 0) });
type Step = NonNullable<FormulaDoc["단계"]>[number];
function coverOf(doc: FormulaDoc): [Step, Set<number>][] {
const where = new Map<string, number>();
(doc.금액 ?? []).forEach((x, i) => {
if (x.이름) where.set(x.이름, i);
});
const out: [Step, Set<number>][] = [];
let open: [number, Set<number>][] = [];
for (const st of doc.단계 ?? []) {
const level = Number(st.단 ?? 1) || 1;
const mine = new Set((st.줄 ?? []).filter((n) => where.has(n)).map((n) => where.get(n)!));
const keep: [number, Set<number>][] = [];
for (const [low, got] of open)
if (low < level) got.forEach((i) => mine.add(i));
else keep.push([low, got]);
open = st.제외 ? keep : [...keep, [level, mine]];
out.push([st, mine]);
}
return out;
}
type StepRow = Bundle & { 이름?: string; 단?: number; 제외?: boolean };
function stepsOf(cover: [Step, Set<number>][], outs: { 비목: Bundle }[]): StepRow[] {
return cover.map(([st, mine]) => {
const acc = zero();
[...mine].sort((a, b) => a - b).forEach((i) => i < outs.length && addTo(acc, outs[i].비목));
return {
이름: st.이름,
단: st.단,
...bundleOf(acc),
...(st.제외 ? { 제외: true } : {}),
} as StepRow;
});
}
function money(run: Run, doc: FormulaDoc, rules: Record<string, Rule>) {
const lines = doc.금액 ?? [];
const cover = coverOf(doc);
const skip = new Set<number>();
lines.forEach((x, i) => x.합밖 && skip.add(i));
for (const [st, mine] of cover) if (st.제외) mine.forEach((i) => skip.add(i));
const running = zero();
const syms: Record<string, Val> = {};
const outs: (Record<string, unknown> & { 비목: Bundle })[] = [];
let stopped = false;
lines.forEach((line, i) => {
const places: Record<string, Bundle> = {};
lines.slice(0, outs.length).forEach((l, j) => {
if (l.번지) addTo((places[String(l.번지)] ??= zero()), outs[j].비목);
});
const stepMap: Record<string, Bundle> = {};
for (const s of stepsOf(cover, outs))
stepMap[String(s.이름)] = Object.fromEntries(BUNDLE.map((k) => [k, s[k]])) as Bundle;
const scope: Record<string, Val> = {
...running,
번지: Object.fromEntries(Object.entries(places).map(([k, v]) => [k, bundleOf(v)])),
단계: stepMap,
...syms,
};
const where = String(line.이름 || `금액 ${i + 1}`);
const qty =
line.수량 !== null && line.수량 !== undefined && line.수량 !== ""
? run.expr(line.수량, where, scope)
: 1;
const price = run.expr(line.단가, where, scope);
const kind = line.비목 as string;
let parts: Bundle = {};
let why: string | null = null;
const stop = (text: string): void => {
stopped = true;
why = run.mark(new Stop(`${where} ${text}`, "셈 틀림", where)).why;
};
if (qty instanceof Stop || price instanceof Stop) {
stopped = true;
why = (qty instanceof Stop ? qty : (price as Stop)).why;
} else if (qty instanceof Blank) why = qty.why;
else if (!isNum(qty)) stop("수량이 수 아님");
else if (qty === 0) for (const k of kind === "묶음" ? COSTS : [kind]) parts[k] = 0;
else if (price instanceof Blank) why = price.why;
else if (kind === "묶음") {
if (isObj(price) && COSTS.every((k) => isNum((price as Bundle)[k])))
for (const k of COSTS) parts[k] = qty * (price as Bundle)[k];
else stop("묶음 줄 단가가 단가산출 묶음 아님");
} else if ((COSTS as readonly string[]).includes(kind) && isNum(price))
parts[kind] = qty * price;
else stop(isObj(price) ? "묶음 값 — 칸(.계 · .노무비 …) 필요" : "단가가 수 아님");
const rule = line.자리 ?? rules.줄;
parts = Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, cut(v, rule) as number]));
const keys = Object.keys(parts);
const amount = keys.length ? keys.reduce((s, k) => s + parts[k], 0) : null;
if (!skip.has(i)) addTo(running, parts);
if (line.기호) syms[String(line.기호)] = amount ?? new Blank(why ?? "빈 줄");
outs.push({
이름: line.이름 ?? "",
비목: parts,
수량: isNum(qty) ? qty : null,
단가: isNum(price) || isObj(price) ? price : null,
금액: amount,
...(why ? { 까닭: why } : {}),
...(skip.has(i) ? { 합밖: true } : {}),
...(line.묶음 ? { 묶음: line.묶음 } : {}),
});
});
return { outs, running, stopped, cover };
}
function settle(doc: FormulaDoc, rules: Record<string, Rule | string>, total: Bundle): Bundle {
const 비목 = rules.비목 as Rule;
let costs: Bundle = Object.fromEntries(COSTS.map((k) => [k, cut(total[k], 비목) as number]));
if (rules.끝전 === "큰나머지") {
const whole = COSTS.reduce((s, k) => s + total[k], 0);
const rest = Math.round((cut(whole, 비목) as number) - COSTS.reduce((s, k) => s + costs[k], 0));
const order = [...COSTS].sort(
(a, b) =>
costs[a] - total[a] - (costs[b] - total[b]) ||
[...COSTS].reverse().indexOf(a) - [...COSTS].reverse().indexOf(b),
);
for (const k of order.slice(0, rest)) costs[k] += 1; // 파이썬 order[:rest] 와 같음
}
if (rules.계) {
const r = rules.계 as Rule;
costs = Object.fromEntries(COSTS.map((k) => [k, cut(costs[k], r) as number]));
}
const adjust = doc.조정 ?? {};
for (const [k, rate] of Object.entries(adjust.배율 ?? {})) costs[k] = costs[k] * Number(rate);
if (adjust.옮김) {
const whole = COSTS.reduce((s, k) => s + costs[k], 0);
costs = Object.fromEntries(COSTS.map((k) => [k, k === adjust.옮김 ? whole : 0]));
}
return Object.fromEntries(COSTS.map((k) => [k, cut(costs[k], 비목) as number]));
}
function groupsOf(outs: { 비목: Bundle; 묶음?: unknown; 합밖?: unknown }[]) {
const order: string[] = [];
const acc = new Map<string, Bundle>();
for (const one of outs) {
const name = String(one.묶음 ?? "");
if (!acc.has(name)) {
order.push(name);
acc.set(name, zero());
}
if (!one.합밖) addTo(acc.get(name)!, one.비목);
}
return order.map((n) => ({ 이름: n, ...bundleOf(acc.get(n)!) }));
}
/** 끝수 — 문서 칸 › 기본(CUT) · 셈 답에 그대로 실음 */
export function rulesOf(doc: FormulaDoc): Record<string, Rule | string> {
const got = (doc.끝수 ?? {}) as Record<string, Rule | string | undefined>;
const base = CUT as Record<string, Rule | string>;
const out: Record<string, Rule | string> = {};
for (const k of ["줄", "비목", "끝전", "계"]) {
const v = got[k] || base[k];
if (v) out[k] = v;
}
return out;
}
const STOP_KINDS = ["기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"];
/** 수식 문서 셈 — given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음 */
export function calcDoc(
doc: FormulaDoc,
links: DocLinks,
given: Record<string, unknown> = {},
stack: string[] = [],
memo: Memo = new Map(),
): DocAnswer {
if (!stack.length && doc.키) stack = [String(doc.키)]; // 제 키 — 저를 부르면 순환
const run = new Run(doc, links, given, stack, memo);
for (const one of check(doc)) if (STOP_KINDS.includes(one.갈래)) run.marks.push(one);
const rules = rulesOf(doc);
for (const name of [...run.lines.keys(), ...run.vars.keys()]) if (name) run.get(name);
const out = { 끝수: rules } as unknown as DocAnswer;
if (doc.결과) {
const value = run.get(String(doc.결과));
out.결과 = isNum(value) ? value : null;
if (value instanceof Blank) out.까닭 = value.why;
out.셈됨 = isNum(value);
} else if ((doc.금액 ?? []).length) {
const got = money(run, doc, rules as Record<string, Rule>);
const stopped =
got.stopped || run.marks.some((m) => ["겹침", "금액 줄", "끝수"].includes(m.갈래));
const costs = stopped ? null : settle(doc, rules, got.running);
out.줄 = got.outs;
for (const k of COSTS) out[k] = costs ? costs[k] : null;
out.계 = costs ? COSTS.reduce((s, k) => s + costs[k], 0) : null;
out.묶음 = groupsOf(got.outs);
out.머리 = out.묶음.length > 1 ? "합계" : "계";
if (doc.단계?.length) out.단계 = stepsOf(got.cover, got.outs);
out.셈됨 = costs !== null;
} else {
run.marks.push({ 갈래: "빈 문서", 글: "금액 줄 · 결과 없음" });
Object.assign(out, { 줄: [], 노무비: null, 재료비: null, 경비: null, 계: null, 묶음: [] });
Object.assign(out, { 머리: "계", 셈됨: false });
}
out.값 = {};
out.빈값 = {};
for (const [k, v] of run.values) {
out.값[k] = bad(v) ? null : v;
if (v instanceof Blank) out.빈값[k] = v.why;
}
out.표지 = run.marks;
return out;
}
@@ -0,0 +1,114 @@
/* =============================================================================
* common_util_formula_doc_table.ts
* 수식 문서 표 찾기(PLAN 802-1) — 조건에 맞는 한 줄(범위 규칙) · 칸 값 · 분수 글 칸
* 파이썬 짝 = resources/master_data/scripts/master_doc_link.py(find · _cell · _arith)
* · 거울 시험 test_802_1_formula_doc.py · 화면 · DOM · fetch 없음
* ========================================================================== */
import { DocError, parse, walk, type Node } from "./common_util_formula_doc";
import { Broken, Gap, binary, isNum, plainText, same } from "./common_util_formula_doc_calc";
type Payload = Record<string, unknown>;
export interface DocTable {
키?: string;
원문번호?: string;
조건?: Record<string, string>;
범위규칙?: string;
값칸?: Record<string, unknown>;
줄: Record<string, unknown>[];
}
function matchRow(table: DocTable, row: Record<string, unknown>, cond: string, want: unknown) {
if (!(cond in row)) return true;
const have = row[cond];
if (table.조건?.[cond] !== "범위") return same(have, want);
const [low, high] = have as [number | null, number | null];
if (!isNum(want) && (low !== null || high !== null))
throw new Broken(`표 ${table.키} 범위 조건에 수 아닌 값`);
const w = want as number;
const [first, ...more] = String(table.범위규칙 || "초과·이하").split(" · ");
const rule = first.split(" (")[0];
const ge = low === null || w >= low;
const gt = low === null || w > low;
const le = high === null || w <= high;
if (rule === "이상·이하") return ge && le;
if (rule === "초과·이하" || rule === "이하") return gt && le;
if (rule === "이상·미만") {
const tops = table.줄
.map((r) => r[cond] as [number | null, number | null] | undefined)
.filter((x) => x && x[1] !== null)
.map((x) => x![1] as number);
const closed = more.some((x) => x.includes("이하 포함")) && high === Math.max(...tops);
return ge && (high === null || w < high || (closed && w === high));
}
throw new Broken(`모르는 범위규칙 「${table.범위규칙}」`);
}
/** 조건에 맞는 한 줄 — 「이하」 · 「이상·이하」 는 위 끝이 가장 작은 줄 · 여럿이면 찾는 칸 있는 줄 */
export function findRow(
table: DocTable,
conds: Record<string, unknown>,
want?: string,
): Record<string, unknown> {
for (const c of Object.keys(conds))
if (!(c in (table.조건 ?? {}))) throw new Broken(`표 ${table.키} 에 없는 조건 「${c}」`);
let hits = table.줄.filter((r) =>
Object.entries(conds).every(([c, v]) => matchRow(table, r, c, v)),
);
const rule = String(table.범위규칙 ?? "").split(" · ")[0];
if (hits.length > 1 && (rule === "이하" || rule === "이상·이하"))
for (const c of Object.keys(conds)) {
const ups = hits
.filter((r) => table.조건?.[c] === "범위" && r[c])
.map((r) => (r[c] as [number | null, number | null])[1]);
if (ups.length && !ups.includes(null)) {
const least = Math.min(...(ups as number[]));
hits = hits.filter((r) => !r[c] || (r[c] as [unknown, number])[1] === least);
}
}
if (hits.length > 1 && want) hits = hits.filter((r) => want in r);
if (hits.length !== 1) {
const what = Object.entries(conds)
.map(([c, v]) => `${c} ${plainText(v)}`)
.join(" · ");
const where = `표 ${table.키}(${table.원문번호})`;
throw new Gap(`${where}에 ${what} 인 줄이 ${hits.length ? `${hits.length}개` : "없음"}`);
}
return hits[0];
}
/** 표 칸의 분수 · 식 글(「1/1.30」) → 수 · 아니면 글 그대로 */
export function cellArith(text: string): number | string {
let node: Node;
try {
node = parse(text);
} catch (e) {
if (e instanceof DocError) return text;
throw e;
}
for (const n of walk(node)) if (!["num", "bin", "neg"].includes(n[0])) return text;
const ev = (n: Node): number => {
if (n[0] === "num") return n[1];
if (n[0] === "neg") return -ev(n[1]);
if (n[0] === "bin") return binary(n[1], ev(n[2]), ev(n[3])) as number;
return NaN;
};
const got = ev(node);
return Number.isFinite(got) ? got : text;
}
/** 표 연결 값 — 줄 찾기 · 칸 · 빈 칸 = Gap · 범위 = Broken */
export function tableValue(table: DocTable, p: Payload): unknown {
const col = String(p.칸);
const row = findRow(table, (p.조건 ?? {}) as Record<string, unknown>, col);
if (!(col in row)) {
if (!(col in (table.값칸 ?? {})) && !table.줄.some((r) => col in r))
throw new Broken(`표 ${table.키} 에 칸 「${col}」 없음`);
throw new Gap(`표 ${table.키} 고른 줄에 「${col}」 값 없음`);
}
const v = row[col];
if (v === null || v === undefined) throw new Gap(`칸 「${col}」 값 없음 — 관리자가 채울 값`);
if (Array.isArray(v)) throw new Broken(`칸 「${col}」 값이 범위 — 입력으로 받아야 함`);
return typeof v === "string" ? cellArith(v) : v;
}
+120
View File
@@ -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`.
+545
View File
@@ -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()},
}
@@ -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
@@ -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
@@ -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"
@@ -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));
+588
View File
@@ -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<b) == (c<d)", "(a < b) == (c < d)"),
("B_굴착기.경비 × Q", "B_굴착기.경비 × Q"),
("번지.A01.재료비 × 11 ÷ 100", "번지.A01.재료비 × 11 ÷ 100"),
("단계.'설치 소계'.계", "단계.'설치 소계'.계"),
("Q_시공^표 − 0.50", "Q_시공^표 - 0.50"),
("C_{m 3} + x_1", "C_{m 3} + x_1"),
("반올림(3600 × q ÷ C, 3)", "반올림(3600 × q ÷ C, 3)"),
("a 또는 b 그리고 c", "a 또는 b 그리고 c"),
("(a 또는 b) 그리고 c", "(a 또는 b) 그리고 c"),
],
)
def test_식_풀이_되돌림(text, want):
got = md.text_of(md.parse(text))
assert got == want
assert md.text_of(md.parse(got)) == got
@pytest.mark.parametrize(
"text", ["", "a +", "(a", "모름(1)", "a b", "'열림", "1 ++", "만약", "x._"]
)
def test_틀린_식(text):
with pytest.raises(md.DocError):
md.parse(text)
def test_기호_글():
assert md.sym_id("Q", "시공", "표") == "Q_시공^표"
assert md.sym_id("C", "m 3") == "C_{m 3}"
assert md.split_id("Q_시공^표") == ("Q", "시공", "표")
assert md.split_id("C_{m 3}") == ("C", "m 3", "")
assert md.good_id("q′_s") and not md.good_id("노무비") and not md.good_id("1a")
def test_이름_바꿈_전파():
doc = SAMPLE()
doc["변수"].append(_var("P_x", {"갈래": "요소", "키": "EQ:{S_유형}", "칸": "가격"}))
md.rename(doc, "S_유형", "U", "형")
assert doc["변수"][0]["기호"] == "U" and doc["변수"][0]["열쇠"] == "유형"
assert doc["변수"][2]["연결"]["조건"]["유형"] == "U_형"
assert doc["식"][0]["식"] == "Q^표 × (1 + 만약(U_형 == '복잡', r_할증, 0)%)"
assert doc["변수"][-1]["연결"]["키"] == "EQ:{U_형}"
md.rename(doc, "C_형틀", "M")
assert doc["금액"][0]["기호"] == "M" and doc["금액"][3]["단가"] == "M × 1 ÷ 3"
with pytest.raises(md.DocError):
md.rename(doc, "M", "t") # 같은 기호
with pytest.raises(md.DocError):
md.rename(doc, "t", "노무비") # 못 쓰는 기호
# ── ② 셈 ──────────────────────────────────────────────────────────────
def test_셈_기본_금액_식():
got = _calc(SAMPLE(), Fake(LABOR))
assert got["셈됨"] and got["표지"] == []
# 형틀 0.2 × 274,978 = 54,995.6 · 보통 0.05 × 165,545 = 8,277.25 → 8,277.2
# 공구손료 = (54,995.6 + 8,277.2) × 3% = 1,898.184 → 1,898.1 · 소모 = 54,995.6 ÷ 3 = 18,331.8
assert [x["금액"] for x in got["줄"]] == [D("54995.6"), D("8277.2"), D("1898.1"), D("18331.8")]
assert _money(got) == {"노무비": 63272, "재료비": 18331, "경비": 1898, "계": 83501}
assert got["끝수"]["줄"] == {"방법": "버림", "자리": 1} and got["머리"] == "계"
got = _calc(SAMPLE(), Fake(LABOR), {"유형": "복잡", "두께": D(5)}) # 넘긴 입력 = 열쇠
assert got["값"]["Q_형틀"] == D("0.33")
def test_표_조건_범위_분수():
links = Fake(LABOR)
doc = SAMPLE()
assert _calc(doc, links, {"두께": D(10)})["값"]["Q^표"] == D("0.1") # 초과·이하 — 10 은 아래 줄
assert _calc(doc, links, {"두께": D("10.5")})["값"]["Q^표"] == D("0.2")
got = _calc(doc, links, {"두께": D(25)}) # 맞는 줄 없음 = 값 없음 → 그 줄만 비움
assert got["셈됨"] and "Q^표" in got["빈값"] and got["줄"][0]["금액"] is None
assert got["노무비"] == 8277 # 형틀 줄 빠짐
doc["변수"].append(
_var("k", {"갈래": "표", "표": "QX990001", "조건": {"유형": "'분수'"}, "칸": "품"})
)
assert _calc(doc, links)["값"]["k"] == D(1) / D("1.30")
doc["변수"].append(
_var("z", {"갈래": "표", "표": "QX990001", "조건": {"유형": "'복잡'"}, "칸": "율"})
)
assert "z" in _calc(doc, links)["빈값"] # 그 줄에 칸 없음 = 값 없음
doc["변수"].append(
_var("y", {"갈래": "표", "표": "QX990001", "조건": {"없는": "1"}, "칸": "품"})
)
assert any(m["갈래"] == "연결 끊김" for m in _calc(doc, links)["표지"]) # 없는 조건 = 끊김
def test_요소_값_없음_줄만_비움():
got = _calc(
SAMPLE(), Fake({"LB990001": D(274978), "LB990002": "빈:값 없음 — 관리자가 채울 값"})
)
assert got["셈됨"] and got["줄"][1]["까닭"].startswith("값 없음")
assert got["노무비"] == 54995
def test_단가산출_부름_묶음_칸_결과():
hour = _doc(
[("w", {"갈래": "숫자", "값": D(100000)}), ("m", {"갈래": "숫자", "값": D("3000.7")})],
[],
[_man("조종원", "1", "w"), {"이름": "연료", "비목": "재료비", "단가": "m"}],
)
work = _doc(
[("q", {"갈래": "입력", "값": D("0.6")}, "버킷"), ("Q", {"갈래": "식"})],
[("Q", "반올림(3600 × q ÷ 18, 3)")],
결과="Q",
)
main = _doc(
[
("B", {"갈래": "단가산출", "키": "GX990002", "입력": {}}),
("B_경", {"갈래": "단가산출", "키": "GX990002", "입력": {}, "칸": "경비"}),
("Q_작업", {"갈래": "단가산출", "키": "GX990003", "입력": {"버킷": "0.7"}}),
],
[],
[
{"이름": "굴착기", "비목": "묶음", "수량": "1 ÷ Q_작업", "단가": "B", "묶음": "굴착기"},
_man("보통인부", "0.01", "B.계", 묶음="인력"),
],
)
links = Fake(docs={"GX990002": hour, "GX990003": work})
assert _calc(work, links)["결과"] == D(120)
got = _calc(main, links)
assert got["값"]["Q_작업"] == D(140) and got["값"]["B"]["계"] == D(103000)
assert got["값"]["B_경"] == 0
assert [x["이름"] for x in got["묶음"]] == ["굴착기", "인력"] and got["머리"] == "합계"
# 굴착기 = 100,000 ÷ 140 = 714.28 → 714.2 · 3,000 ÷ 140 = 21.42 → 21.4 · 인력 0.01 × 103,000 = 1,030
assert got["줄"][0]["비목"] == {"노무비": D("714.2"), "재료비": D("21.4"), "경비": D(0)}
assert got["묶음"][0]["계"] == D("735.6") and _money(got)["계"] == 1765
def test_반올림_자리_끝전_계_조정():
doc = _doc(
[("a", {"갈래": "숫자", "값": D("0.35")})],
[],
[
_man("갑", "1", "반올림(a × 10, 0)", 자리={"방법": "반올림", "자리": 0}),
_man("을", "1", "올림(a, 1) + 버림(a, 1)", "재료비"),
_man("병", "1", "1.6", "경비"),
],
)
got = _calc(doc)
assert [x["금액"] for x in got["줄"]] == [D(4), D("0.7"), D("1.6")]
assert _money(got) == {"노무비": 4, "재료비": 0, "경비": 1, "계": 5}
doc["끝수"]["끝전"] = "큰나머지" # 4 + 0.7 + 1.6 = 6.3 → 6 · 모자란 1 원 = 원 미만 큰 재료비
assert _money(_calc(doc)) == {"노무비": 4, "재료비": 1, "경비": 1, "계": 6}
doc["조정"] = {"배율": {"노무비": D("0.5")}, "옮김": "경비"}
assert _money(_calc(doc)) == {"노무비": 0, "재료비": 0, "경비": 4, "계": 4}
doc.pop("조정")
doc["끝수"] = {
"줄": {"방법": "버림", "자리": 0},
"비목": {"방법": "버림", "자리": 0},
"계": {"방법": "반올림", "자리": -1},
}
assert any(m["갈래"] == "끝수" for m in _calc(doc)["표지"]) # 자리 꼴 틀림 = 표지 · 멈춤
def test_번지_단계_합밖():
doc = _doc(
[],
[],
[
{"이름": "갑", "비목": "재료비", "단가": "1000", "번지": "A01"},
{"이름": "을", "비목": "재료비", "단가": "500", "번지": "A01"},
{"이름": "형틀", "비목": "노무비", "단가": "2000", "번지": "A02"},
{"이름": "소모", "비목": "재료비", "단가": "번지.A01.재료비 × 10 ÷ 100"},
{"이름": "참고", "비목": "경비", "단가": "999", "합밖": True},
{"이름": "손료", "비목": "경비", "단가": "단계.'설치'.계 × 1 ÷ 100"},
],
단계=[
{"이름": "자재", "단": 1, "줄": ["갑", "을", "소모"]},
{"이름": "설치", "단": 1, "줄": ["형틀"]},
{"이름": "계", "단": 2},
],
)
got = _calc(doc)
assert [x["금액"] for x in got["줄"]] == [1000, 500, 2000, 150, 999, 20]
assert _money(got) == {"노무비": 2000, "재료비": 1650, "경비": 20, "계": 3670}
assert [x["계"] for x in got["단계"]] == [1650, 2000, 3650] and got["줄"][4]["합밖"]
def test_결과_문서_입력_빔():
doc = _doc(
[("q", {"갈래": "입력", "값": None}), ("Q", {"갈래": "식"})], [("Q", "q × 2")], 결과="Q"
)
got = _calc(doc)
assert got["결과"] is None and not got["셈됨"] and "비어 있음" in got["까닭"]
assert _calc(doc, given={"q": "3"})["결과"] == 6 # 글 수 = 수
# ── ③ 표지 ────────────────────────────────────────────────────────────
def _kinds(got):
return {m["갈래"] for m in got["표지"]}
@pytest.mark.parametrize(
"lines, kind",
[
([("a", "b + 1")], "정의 필요"),
([("a", "b + 1"), ("b", "a × 2")], "순환"),
([("a", "1 ÷ (2 - 2)")], "0 나눔"),
([("a", "1 +")], "식 틀림"),
([("a", "'글' × 2")], "셈 틀림"),
([("a", "노무비 × 2")], "금액 이름"),
],
)
def test_표지_금액_줄표(lines, kind):
doc = _doc([], lines, [_man("갑", "1", "a")])
got = _calc(doc)
assert kind in _kinds(got)
assert not got["셈됨"] and got["계"] is None and got["노무비"] is None
def test_빈_문서_겹침_순환_부름():
got = _calc(md.blank())
assert not got["셈됨"] and got["계"] is None and _kinds(got) == {"빈 문서"}
doc = _doc([("a", {"갈래": "숫자", "값": D(1)})], [("a", "2")], [_man("갑", "1", "a")])
assert "겹침" in _kinds(_calc(doc)) and _calc(doc)["계"] is None
loop = _doc(
[("B", {"갈래": "단가산출", "키": "GX990001", "입력": {}})], [], [_man("갑", "1", "B.계")]
)
got = _calc(loop, Fake(docs={"GX990001": loop}))
assert not got["셈됨"] and "순환" in _kinds(got)
def test_정적_검사():
doc = SAMPLE()
assert md.check(doc) == []
doc["식"].append({"기호": "x", "식": "y + Q_형틀"})
doc["식"].append({"기호": "y", "식": "x"})
kinds = [m["갈래"] for m in md.check(doc)]
assert "순환" in kinds and "정의 필요" not in kinds
# ── ④ 지움 견딤 ───────────────────────────────────────────────────────
_ROWS: dict = {}
class Lazy(ml.Links):
"""M01 연결 + 부르는 문서는 그때 변환(옛 로직 → 수식 문서)."""
def doc(self, key):
try:
row = self.master.get(key)
except Exception:
return None
key = str(row["키"])
if key not in self.docs and key in _ROWS:
self.docs[key] = cv.convert(self.master, _ROWS[key])[0]
return self.docs.get(key)
def _real_docs(keys):
files, whole = m01.loaded()
_ROWS.update({r["키"]: r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"]})
links = Lazy(whole, {})
return links, {key: links.doc(key) for key in keys}
REAL = (
"GC000211",
"GF000294",
"GC001147",
"GC000100",
"GF000185",
"GC000087",
"GF000221",
"GC000278",
)
def _cuts(doc):
"""지운 문서들 — 변수 · 식 줄 · 금액 줄 하나씩 · 식 글 하나씩 빈 글 · 반 토막."""
for kind in ("변수", "식", "금액"):
for i in range(len(doc.get(kind) or [])):
one = copy.deepcopy(doc)
del one[kind][i]
yield one
for i, line in enumerate(doc.get("식") or []):
for text in ("", line["식"][: len(line["식"]) // 2]):
one = copy.deepcopy(doc)
one["식"][i]["식"] = text
yield one
for i, line in enumerate(doc.get("금액") or []):
for k in ("수량", "단가"):
one = copy.deepcopy(doc)
one["금액"][i][k] = ""
yield one
one = copy.deepcopy(doc)
one.pop("결과", None)
yield one
def test_지움_견딤_예외_0():
links, docs = _real_docs(REAL)
count = 0
for doc in [SAMPLE(), *docs.values()]:
base = _calc(doc, links if doc.get("키") in docs else Fake(LABOR))
assert base["셈됨"]
for one in _cuts(doc):
got = _calc(one, links if doc.get("키") in docs else Fake(LABOR)) # 예외 없음
count += 1
assert set(got) >= {"값", "표지", "셈됨", "끝수"}
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