/* ============================================================================= * common_util_formula_doc_calc.ts * 단가산출 수식 문서 셈(PLAN 802-1 · 요구서_664 · 꼴 resources/master_data/_수식문서_틀.md) * calcDoc(doc, links, given?) — 기호는 필요할 때 셈(차례 무관) · 예외 없음: * 정의 필요 · 순환 · 0 나눔 · 식 틀림 = 표지 + 금액 「—」(null) · 값 없음 = 그 기호를 쓰는 줄만 비움 * 금액 = 줄 비목마다 끝수 · 합밖 · 제외 단계 · 앞 줄 누계 · 번지 · 단계 → 비목 끝수 → 끝전 → 계 끝수 → 조정 * 표 찾기(findRow · tableValue) = common_util_formula_doc_table.ts * 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_calc.py · master_doc_link.py * · 거울 시험 test_802_1_formula_doc.py(고칠 때 둘을 같이 고침) · 화면 · DOM · fetch 없음 * ========================================================================== */ import { BUNDLE, COSTS, CUT, DocError, HOLE, MONEY_NAMES, check, parse, varId, type DocVar, type FormulaDoc, type Mark, type Node, type Rule, } from "./common_util_formula_doc"; const EPS = 1e-7; // B09_Estimation_Calc_Base EPS 와 같음 const DEEP = 20; export class Gap extends Error {} export class Broken extends Error {} export class Blank { constructor(readonly why: string) {} } export class Stop { constructor( readonly why: string, readonly kind = "멈춤", readonly sign = "", ) {} } type Bundle = Record; type Val = number | string | boolean | Bundle | Record | Blank | Stop; type Payload = Record; export interface DocLinks { element(p: Payload): unknown; table(p: Payload): unknown; doc(key: string): FormulaDoc | null | undefined; } export interface DocAnswer { 끝수: Record; 값: Record; 빈값: Record; 표지: Mark[]; 셈됨: boolean; 결과?: number | null; 까닭?: string; 줄?: Record[]; 노무비?: number | null; 재료비?: number | null; 경비?: number | null; 계?: number | null; 묶음?: Record[]; 머리?: string; 단계?: Record[]; } const bad = (v: unknown): v is Blank | Stop => v instanceof Blank || v instanceof Stop; export const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !(v instanceof Blank) && !(v instanceof Stop); // ── 수 ──────────────────────────────────────────────────────────────── function scaled(value: number, places: number, fn: (x: number) => number): number { const a = Math.abs(value); const out = fn(a * 10 ** places) / 10 ** places; return value < 0 ? -out : out; } const WAY: Record number> = { 버림: (x) => Math.floor(x + EPS), 올림: (x) => Math.ceil(x - EPS), 반올림: (x) => Math.floor(x + 0.5 + EPS), }; /** 끝수 한 번 — 엑셀 ROUNDDOWN · ROUNDUP · ROUND 꼴 */ export function cut(value: number | null | undefined, rule?: Rule | null): number | null { if (value === null || value === undefined) return null; if (!rule) return value; return scaled(value, Math.trunc(rule.자리 ?? 0), WAY[rule.방법]); } /** 구멍(`{기호}`)에 넣는 글 */ export const plainText = (v: unknown): string => isNum(v) ? String(Number(v.toPrecision(15))) : String(v); export const same = (a: unknown, b: unknown): boolean => isNum(a) && isNum(b) ? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)) : typeof a === typeof b && a === b; export function binary(op: string, a: unknown, b: unknown): unknown { if (op === "==") return same(a, b); if (op === "!=") return !same(a, b); const nums = (isNum(a) || typeof a === "boolean") && (isNum(b) || typeof b === "boolean"); const x = Number(a); const y = Number(b); if (["<", "<=", ">", ">="].includes(op)) { if (!(nums || (typeof a === "string" && typeof b === "string"))) throw new TypeError(op); const lt = nums ? x < y && !same(x, y) : (a as string) < (b as string); const eq = nums ? same(x, y) : a === b; return op === "<" ? lt : op === "<=" ? lt || eq : op === ">" ? !lt && !eq : !lt; } if (op === "+" && typeof a === "string" && typeof b === "string") return a + b; if (!nums) throw new TypeError(op); return op === "+" ? x + y : op === "-" ? x - y : op === "*" ? x * y : x / y; } // ── 셈 한 벌 ────────────────────────────────────────────────────────── type Memo = Map; class Run { vars = new Map(); lines = new Map(); values = new Map(); busy: string[] = []; marks: Mark[] = []; moneySyms: Set; constructor( readonly doc: FormulaDoc, readonly links: DocLinks, readonly given: Record, readonly stack: string[], readonly memo: Memo, ) { for (const v of doc.변수 ?? []) this.vars.set(varId(v), v); for (const l of doc.식 ?? []) this.lines.set(String(l.기호 ?? ""), l.식); this.moneySyms = new Set((doc.금액 ?? []).filter((x) => x.기호).map((x) => String(x.기호))); } mark(stop: Stop): Stop { const one: Mark = { 갈래: stop.kind, 글: stop.why, ...(stop.sign ? { 기호: stop.sign } : {}) }; if (!this.marks.some((m) => JSON.stringify(m) === JSON.stringify(one))) this.marks.push(one); return stop; } get(name: string, scope?: Record): Val { if (scope && name in scope) return scope[name]; if (MONEY_NAMES.includes(name) || this.moneySyms.has(name)) return this.mark(new Stop(`「${name}」 은 금액 줄에서만`, "금액 이름", name)); if (this.values.has(name)) return this.values.get(name) as Val; const at = this.busy.indexOf(name); if (at >= 0) return this.mark(new Stop([...this.busy.slice(at), name].join(" → "), "순환", name)); this.busy.push(name); let value: Val; try { value = this.define(name); } finally { this.busy.pop(); } if (value instanceof Stop) this.mark(value); this.values.set(name, value); return value; } define(name: string): Val { const v = this.vars.get(name); const kind = v?.연결?.갈래 ?? "식"; if (this.lines.has(name) && kind !== "식") return new Stop(`「${name}」 정의가 식 줄 · 연결 둘`, "겹침", name); if (this.lines.has(name)) return this.expr(this.lines.get(name), name); if (!v || kind === "식") return new Stop(`「${name}」 정의 필요`, "정의 필요", name); return this.link(v, name); } expr(text: unknown, where = "", scope?: Record): Val { let node: Node; try { node = parse(text); } catch (e) { if (!(e instanceof DocError)) throw e; return this.mark(new Stop(e.message, "식 틀림", where)); } return this.ev(node, scope, where); } arith(fn: () => unknown, where: string): Val { try { const got = fn(); if (typeof got === "number" && !Number.isFinite(got)) return this.mark(new Stop(`${where} — 수 아닌 값을 셈`, "셈 틀림", where)); return got as Val; } catch (e) { if (!(e instanceof TypeError)) throw e; return this.mark(new Stop(`${where} — 수 아닌 값을 셈`, "셈 틀림", where)); } } ev(node: Node, scope: Record | undefined, where: string): Val { switch (node[0]) { case "num": case "str": return node[1]; case "var": return this.get(node[1], scope); case "field": { const base = this.ev(node[1], scope, where); if (bad(base)) return base; if (!isObj(base) || !(node[2] in base)) return this.mark(new Stop(`칸 「${node[2]}」 없음`, "칸 없음", where)); const got = base[node[2]]; return got === null || got === undefined ? new Blank(`칸 「${node[2]}」 비어 있음`) : (got as Val); } case "neg": { const a = this.ev(node[1], scope, where); return bad(a) ? a : this.arith(() => binary("-", 0, a), where); } case "pct": { const a = this.ev(node[1], scope, where); return bad(a) ? a : this.arith(() => binary("/", a, 100), where); } case "call": return this.call(node[1], node[2], scope, where); case "bin": { const op = node[1]; const a = this.ev(node[2], scope, where); if (bad(a)) return a; if ((op === "그리고" && !a) || (op === "또는" && a)) return a; const b = this.ev(node[3], scope, where); if (bad(b) || op === "그리고" || op === "또는") return b; if (op === "/" && isNum(b) && b === 0) return this.mark(new Stop(`${where} — 0 나눔`, "0 나눔", where)); return this.arith(() => binary(op, a, b), where); } } } call(name: string, items: Node[], scope: Record | undefined, where: string): Val { if (name === "만약") { if (items.length !== 3) return this.mark(new Stop("만약(조건, 참, 거짓)", "식 틀림", where)); const test = this.ev(items[0], scope, where); if (bad(test)) return test; return this.ev(test ? items[1] : items[2], scope, where); } const values = items.map((x) => this.ev(x, scope, where)); const b = values.find(bad); if (b) return b; if (name === "최소" || name === "최대") return this.arith(() => { if (!values.every(isNum)) throw new TypeError(name); return (name === "최소" ? Math.min : Math.max)(...(values as number[])); }, where); if (values.length > 2) return this.mark(new Stop(`${name}(값, 자리)`, "식 틀림", where)); return this.arith(() => { const places = values.length > 1 ? values[1] : 0; if (!isNum(values[0]) || !isNum(places)) throw new TypeError(name); return cut(values[0], { 방법: name, 자리: Math.trunc(places) }); }, where); } fill(text: string): string | Blank | Stop { let hit: Blank | Stop | null = null; const out = text.replace(HOLE, (_, h: string) => { const got = this.get(h); if (bad(got)) { hit = hit ?? got; return ""; } return plainText(got); }); return hit ?? out; } link(v: DocVar, name: string): Val { const link = v.연결 ?? {}; const kind = link.갈래; const own = v.열쇠; if (kind === "숫자" || kind === "입력" || kind === "선택") { const given = !!own && own in this.given; let value = (given ? this.given[own as string] : link.값) as Val | null | undefined; if (kind === "숫자" && isObj(value)) { // 손 묶음(노 · 재 · 경 — 엑셀 값 그대로) const box = value as Record; if (!COSTS.every((k) => isNum(box[k]))) return new Stop(`「${name}」 묶음 값은 노무비 · 재료비 · 경비 수`, "연결 꼴", name); return bundleOf(Object.fromEntries(COSTS.map((k) => [k, box[k] as number]))); } if (value === null || value === undefined || value === "") return new Blank(`「${v.이름 || name}」 값 비어 있음`); if (kind === "입력" && typeof value === "string") { const n = Number(value.trim().replace(/,/g, "")); if (value.trim() !== "" && Number.isFinite(n)) value = n; } if (kind === "선택" && !given) { const hit = (link.목록 ?? []).find((x) => plainText(x) === plainText(value)); if (hit === undefined) return new Stop(`「${name}」 값 「${plainText(value)}」 목록 밖`, "목록 밖", name); value = hit as Val; } if (kind === "입력" && link.범위 && isNum(value)) { const [low, high] = link.범위; if ((low !== null && value < low) || (high !== null && value > high)) return new Stop( `「${name}」 ${plainText(value)} 범위 [${low}, ${high}] 밖`, "범위 밖", name, ); } return value; } const p: Payload = {}; const hits: (Blank | Stop)[] = []; const keep = (x: Val | string): Val | string => { if (bad(x)) hits.push(x); return x; }; for (const key of ["키", "표"] as const) if (link[key]) p[key] = keep(this.fill(link[key]!)); if (link.재료 && typeof link.재료 === "object") { const box: Record = {}; for (const [k, x] of Object.entries(link.재료)) box[k] = typeof x === "string" ? keep(this.fill(x)) : x; p.재료 = box; } for (const slot of ["지역", "계약종별"] as const) if (link[slot]) p[slot] = keep(this.get(link[slot]!)); for (const slot of ["조건", "입력"] as const) if (link[slot] && typeof link[slot] === "object") { const box: Record = {}; for (const [k, t] of Object.entries(link[slot]!)) box[k] = keep(this.expr(t, name)); p[slot] = box; } if (hits.length) return hits[0]; if (link.칸) p.칸 = link.칸; if (link.단위) p.단위 = link.단위; try { if (kind === "요소") return this.links.element(p) as Val; if (kind === "표") return this.links.table(p) as Val; if (kind === "단가산출") return this.sub(p, name); } catch (e) { if (e instanceof Gap) return new Blank(e.message); if (e instanceof Broken) return new Stop(`「${name}」 ${e.message}`, "연결 끊김", name); throw e; } return new Stop(`「${name}」 연결 갈래 「${kind}」`, "연결 꼴", name); } sub(p: Payload, name: string): Val { const key = String(p.키 ?? ""); if (!key) throw new Broken("단가산출 고르지 않음"); // 고르는 중 — 표지 · 짝 파이썬 sub if (this.stack.includes(key) || this.stack.length > DEEP) return new Stop(`단가산출 부름 돌고 돎 — ${[...this.stack, key].join(" → ")}`, "순환", name); const doc = this.links.doc(key); if (!doc) throw new Broken(`없는 단가산출 「${key}」`); const given = (p.입력 ?? {}) as Record; const memoKey = `${key}|${Object.keys(given) .sort() .map((k) => `${k}=${plainText(given[k])}`) .join("|")}`; if (!this.memo.has(memoKey)) this.memo.set(memoKey, calcDoc(doc, this.links, given, [...this.stack, key], this.memo)); const got = this.memo.get(memoKey) as DocAnswer; if (!got.셈됨) { // 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤 const why = got.까닭 || got.표지.map((m) => m.글).join(" · "); if (got.까닭) return new Blank(`단가산출 「${key}」 못 셈 — ${why}`); return new Stop(`단가산출 「${key}」 못 셈 — ${why}`, "부름 멈춤", name); } let value: Val; if ("결과" in got) value = got.결과 as number; else { value = Object.fromEntries(BUNDLE.map((k) => [k, got[k] as number])); } const col = p.칸 as string | undefined; if (col) { if (!isObj(value) || !(col in value)) throw new Broken(`칸 「${col}」 없음`); return (value as Bundle)[col]; } return value; } } // ── 금액 ────────────────────────────────────────────────────────────── const zero = (): Bundle => ({ 노무비: 0, 재료비: 0, 경비: 0 }); const addTo = (acc: Bundle, parts: Bundle): void => { for (const [k, v] of Object.entries(parts)) if (k in acc && v !== null && v !== undefined) acc[k] += v; }; const bundleOf = (acc: Bundle): Bundle => ({ ...acc, 계: COSTS.reduce((s, k) => s + acc[k], 0) }); type Step = NonNullable[number]; function coverOf(doc: FormulaDoc): [Step, Set][] { const where = new Map(); (doc.금액 ?? []).forEach((x, i) => { if (x.이름) where.set(x.이름, i); }); const out: [Step, Set][] = []; let open: [number, Set][] = []; for (const st of doc.단계 ?? []) { const level = Number(st.단 ?? 1) || 1; const mine = new Set((st.줄 ?? []).filter((n) => where.has(n)).map((n) => where.get(n)!)); const keep: [number, Set][] = []; for (const [low, got] of open) if (low < level) got.forEach((i) => mine.add(i)); else keep.push([low, got]); open = st.제외 ? keep : [...keep, [level, mine]]; out.push([st, mine]); } return out; } type StepRow = Bundle & { 이름?: string; 단?: number; 제외?: boolean }; function stepsOf(cover: [Step, Set][], outs: { 비목: Bundle }[]): StepRow[] { return cover.map(([st, mine]) => { const acc = zero(); [...mine].sort((a, b) => a - b).forEach((i) => i < outs.length && addTo(acc, outs[i].비목)); return { 이름: st.이름, 단: st.단, ...bundleOf(acc), ...(st.제외 ? { 제외: true } : {}), } as StepRow; }); } function money(run: Run, doc: FormulaDoc, rules: Record) { const lines = doc.금액 ?? []; const cover = coverOf(doc); const skip = new Set(); lines.forEach((x, i) => x.합밖 && skip.add(i)); for (const [st, mine] of cover) if (st.제외) mine.forEach((i) => skip.add(i)); const running = zero(); const syms: Record = {}; const outs: (Record & { 비목: Bundle })[] = []; let stopped = false; lines.forEach((line, i) => { const places: Record = {}; lines.slice(0, outs.length).forEach((l, j) => { if (l.번지) addTo((places[String(l.번지)] ??= zero()), outs[j].비목); }); const stepMap: Record = {}; for (const s of stepsOf(cover, outs)) stepMap[String(s.이름)] = Object.fromEntries(BUNDLE.map((k) => [k, s[k]])) as Bundle; const scope: Record = { ...running, 번지: Object.fromEntries(Object.entries(places).map(([k, v]) => [k, bundleOf(v)])), 단계: stepMap, ...syms, }; const where = String(line.이름 || `금액 ${i + 1}`); const qty = line.수량 !== null && line.수량 !== undefined && line.수량 !== "" ? run.expr(line.수량, where, scope) : 1; const price = run.expr(line.단가, where, scope); const kind = line.비목 as string; let parts: Bundle = {}; let bare: number | null = null; // 비목 없는 합 밖 줄(소계 보임 · 810-2) — 금액만 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 && !kind && line.합밖) bare = 0; 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 if (!kind && line.합밖 && isNum(price)) bare = 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 = bare !== null ? (cut(bare, rule) as number) : keys.length ? keys.reduce((s, k) => s + parts[k], 0) : null; if (!skip.has(i)) addTo(running, parts); if (line.기호) syms[String(line.기호)] = amount ?? new Blank(why ?? "빈 줄"); outs.push({ 이름: line.이름 ?? "", 비목: parts, 수량: isNum(qty) ? qty : null, 단가: isNum(price) || isObj(price) ? price : null, 금액: amount, ...(why ? { 까닭: why } : {}), ...(skip.has(i) ? { 합밖: true } : {}), ...(line.묶음 ? { 묶음: line.묶음 } : {}), }); }); return { outs, running, stopped, cover }; } function settle(doc: FormulaDoc, rules: Record, total: Bundle): Bundle { const 비목 = rules.비목 as Rule; let costs: Bundle = Object.fromEntries(COSTS.map((k) => [k, cut(total[k], 비목) as number])); if (rules.끝전 === "큰나머지") { const whole = COSTS.reduce((s, k) => s + total[k], 0); const rest = Math.round((cut(whole, 비목) as number) - COSTS.reduce((s, k) => s + costs[k], 0)); const gap = (k: string): number => Math.round((costs[k] - total[k]) * 1e9) / 1e9; // 소수 흔들림 = 같은 나머지(810-3) const order = [...COSTS].sort( (a, b) => gap(a) - gap(b) || [...COSTS].reverse().indexOf(a) - [...COSTS].reverse().indexOf(b), ); for (const k of order.slice(0, rest)) costs[k] += 1; // 파이썬 order[:rest] 와 같음 } if (rules.계) { const r = rules.계 as Rule; costs = Object.fromEntries(COSTS.map((k) => [k, cut(costs[k], r) as number])); } const adjust = doc.조정 ?? {}; for (const [k, rate] of Object.entries(adjust.배율 ?? {})) costs[k] = costs[k] * Number(rate); if (adjust.옮김) { const whole = COSTS.reduce((s, k) => s + costs[k], 0); costs = Object.fromEntries(COSTS.map((k) => [k, k === adjust.옮김 ? whole : 0])); } return Object.fromEntries(COSTS.map((k) => [k, cut(costs[k], 비목) as number])); } function groupsOf(outs: { 비목: Bundle; 묶음?: unknown; 합밖?: unknown }[]) { const order: string[] = []; const acc = new Map(); for (const one of outs) { const name = String(one.묶음 ?? ""); if (!acc.has(name)) { order.push(name); acc.set(name, zero()); } if (!one.합밖) addTo(acc.get(name)!, one.비목); } return order.map((n) => ({ 이름: n, ...bundleOf(acc.get(n)!) })); } /** 끝수 — 문서 칸 › 기본(CUT) · 셈 답에 그대로 실음 */ export function rulesOf(doc: FormulaDoc): Record { const got = (doc.끝수 ?? {}) as Record; const base = CUT as Record; const out: Record = {}; for (const k of ["줄", "비목", "끝전", "계"]) { const v = got[k] || base[k]; if (v) out[k] = v; } return out; } const STOP_KINDS = ["기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"]; /** 수식 문서 셈 — given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음 */ export function calcDoc( doc: FormulaDoc, links: DocLinks, given: Record = {}, stack: string[] = [], memo: Memo = new Map(), ): DocAnswer { if (!stack.length && doc.키) stack = [String(doc.키)]; // 제 키 — 저를 부르면 순환 const run = new Run(doc, links, given, stack, memo); for (const one of check(doc)) if (STOP_KINDS.includes(one.갈래)) run.marks.push(one); const rules = rulesOf(doc); for (const name of [...run.lines.keys(), ...run.vars.keys()]) if (name) run.get(name); const out = { 끝수: rules } as unknown as DocAnswer; if (doc.결과) { const value = run.get(String(doc.결과)); out.결과 = isNum(value) ? value : null; if (value instanceof Blank) out.까닭 = value.why; out.셈됨 = isNum(value); } else if ((doc.금액 ?? []).length) { const got = money(run, doc, rules as Record); const stopped = got.stopped || run.marks.some((m) => ["겹침", "금액 줄", "끝수"].includes(m.갈래)); const costs = stopped ? null : settle(doc, rules, got.running); out.줄 = got.outs; for (const k of COSTS) out[k] = costs ? costs[k] : null; out.계 = costs ? COSTS.reduce((s, k) => s + costs[k], 0) : null; out.묶음 = groupsOf(got.outs); out.머리 = out.묶음.length > 1 ? "합계" : "계"; if (doc.단계?.length) out.단계 = stepsOf(got.cover, got.outs); out.셈됨 = costs !== null; } else { run.marks.push({ 갈래: "빈 문서", 글: "금액 줄 · 결과 없음" }); Object.assign(out, { 줄: [], 노무비: null, 재료비: null, 경비: null, 계: null, 묶음: [] }); Object.assign(out, { 머리: "계", 셈됨: false }); } out.값 = {}; out.빈값 = {}; for (const [k, v] of run.values) { out.값[k] = bad(v) ? null : v; if (v instanceof Blank) out.빈값[k] = v.why; } out.표지 = run.marks; return out; }