- 공용 화면 A00_Common/formula_doc_*(M01 · 뒤에 M02 = 길 앞머리만 바꿈) — 옛 수식 나무 화면은 M01 에서 걷음(M02 는 그대로) - 보이는 수식 그 자리 고침(공용 editEq · 전체 고르기 = 한 줄 글) · 분수 · 첨자 · 만약 = 탄 갈래만 · 반올림 자리 숨김 · 값 식 단위 - 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 정의 영역 = 기호 : 이름 — 연결(키 · 값 · 단위 없음 · 탄 갈래로 닿는 기호만) - 새 기호 = 정의 필요 → 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식 연결 · 기호 · 첨자 · 이름 고침 · 정의 지움 - 금액 = 경쟁사 꼴 이름 : 단가 × 수량 = 금액 · 묶음 하나 = 계 · 둘 이상 = 가. 이름 소계 + 합계 · 단가산출 연결 펼침 = 그 문서 식 · 값 식 - 셈 = 브라우저 계산기 즉시 · 연결 값 = 서버 기록 캐시(common_util_formula_doc_cache.ts ↔ master_doc_record.py) · 없는 연결만 뒤에서 미리 셈 - 서버 길 GET /api/m01/logic/doc · POST preview · save(서버가 정본으로 다시 셈 · 수식 문서 파일에 씀) · links · GET table (M01_MasterData_Doc.py) - 연결 대상 안 고른 연결(표 · 요소 · 단가산출) = 연결 끊김 표지(파이썬 · TS 짝 가드 — 서버 500 걷음) - 시험 test_802_3_doc_screen 11(읽기 · 빈 문서 · 저장 = 서버 다시 셈 · 관리자 아니면 막힘 · 지움 견딤 · 표지 갈래 · 후보) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
605 lines
24 KiB
TypeScript
605 lines
24 KiB
TypeScript
/* =============================================================================
|
|
* 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 (!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<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;
|
|
}
|