Files
Aislo/tools/line-stack-test/calc.js
T
eomsangdonandClaude Opus 5.5 3e64aa2a79 feat(804-1): 시험 페이지 「줄 쌓기 단가산출」 tools/line-stack-test + M01 왼쪽 패널 단추
- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = localStorage
- 견본 셋 금액 = 거창 파일 원 단위(5,323 · 52,796 · 94,215 비목 넷) · 확인 = node tools/line-stack-test/check.mjs
- 접근 = main.py 정적 마운트 /tools/line-stack-test · vite 프록시 한 줄(개발 포트에서 열림) · M01 왼쪽 패널 맨 아래 「시험: 줄 쌓기」 새 탭
- 라이브러리 0(분수 = CSS) · 지우기 = 폴더 + 표시 주석 셋 덩이

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:19:38 +09:00

183 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 줄 쌓기 단가산출 시험 — 계산(화면 없음 · node 로도 돎)
// 끝수 = 거창 출력과 같은 자리: 줄 값 0.1 자리 버림 · 비목 합계 원 단위 버림 · 공식 변수(식인 줄) 소수 둘째 반올림
export const COLS = ["l", "m", "e"];
export const COL_NAME = { l: "노무비", m: "재료비", e: "경 비" };
const TOKEN = /\s*(?:(\d[\d,]*\.?\d*|\.\d+)|([A-Za-z가-힣_][A-Za-z0-9가-힣_]*)|([+\-*/×÷^()]))/y;
export function tokenize(s) {
const out = [];
TOKEN.lastIndex = 0;
const src = String(s ?? "").trim();
while (TOKEN.lastIndex < src.length) {
const at = TOKEN.lastIndex;
const m = TOKEN.exec(src);
if (!m || m.index !== at) throw new Error(`읽을 수 없는 글자: ${src.slice(at).trim()[0]}`);
if (m[1] !== undefined) out.push({ t: "num", v: Number(m[1].replace(/,/g, "")) });
else if (m[2] !== undefined) out.push({ t: "var", n: m[2] });
else out.push({ t: "op", v: m[3] });
if (TOKEN.lastIndex >= src.length || !src.slice(TOKEN.lastIndex).trim()) break;
}
return out;
}
/** 식 → 나무(괄호는 그대로 남겨 화면에 쓴 꼴대로 보임) */
export function parse(s) {
const tk = tokenize(s);
if (!tk.length) throw new Error("빈 식");
let i = 0;
const peek = () => tk[i];
const isOp = (...ops) => peek()?.t === "op" && ops.includes(peek().v);
const add = () => {
let a = mul();
while (isOp("+", "-")) a = { t: "bin", op: tk[i++].v, a, b: mul() };
return a;
};
const mul = () => {
let a = unary();
while (isOp("*", "/", "×", "÷")) a = { t: "bin", op: tk[i++].v, a, b: unary() };
return a;
};
const unary = () => (isOp("-") ? (i++, { t: "neg", a: unary() }) : pow());
const pow = () => {
const a = atom();
return isOp("^") ? (i++, { t: "bin", op: "^", a, b: unary() }) : a;
};
const atom = () => {
const k = tk[i++];
if (!k) throw new Error("식이 덜 끝남");
if (k.t === "num" || k.t === "var") return k;
if (k.v === "(") {
const a = add();
if (!isOp(")")) throw new Error("괄호가 안 닫힘");
i++;
return { t: "par", a };
}
throw new Error(`「${k.v}」 자리가 틀림`);
};
const ast = add();
if (i < tk.length) throw new Error(`「${tk[i].v ?? tk[i].n}」 앞에서 식이 끊김`);
return ast;
}
export function varsIn(ast, out = []) {
if (!ast) return out;
if (ast.t === "var") out.includes(ast.n) || out.push(ast.n);
for (const k of ["a", "b"]) if (ast[k]) varsIn(ast[k], out);
return out;
}
export function evalAst(ast, vars) {
switch (ast.t) {
case "num":
return ast.v;
case "var":
if (!(ast.n in vars)) throw new Error(`없는 이름: ${ast.n}`);
return vars[ast.n];
case "par":
return evalAst(ast.a, vars);
case "neg":
return -evalAst(ast.a, vars);
}
const a = evalAst(ast.a, vars);
const b = evalAst(ast.b, vars);
if (ast.op === "+") return a + b;
if (ast.op === "-") return a - b;
if (ast.op === "*" || ast.op === "×") return a * b;
if (ast.op === "^") return a ** b;
return a / b;
}
export const floor1 = (x) => Math.floor(x * 10 + 1e-6) / 10;
export const floor0 = (x) => Math.floor(x + 1e-6);
export const round1 = (x) => Math.round(x * 10) / 10;
export const roundTo = (x, d) => Math.round(x * 10 ** d + 1e-9) / 10 ** d;
/** 곱나눔 꼬리 하나 값(% 면 100 으로 나눔) */
export function opValue(op, vars) {
const v = evalAst(parse(op.v), vars);
return op.u?.trim() === "%" ? v / 100 : v;
}
export function applyOps(x, ops, vars) {
for (const op of ops ?? []) {
const v = opValue(op, vars);
x = op.op === "×" || op.op === "*" ? x * v : x / v;
}
return x;
}
const isPlain = (ast) => ast.t === "num" || (ast.t === "neg" && ast.a.t === "num");
/** 단가산출 하나를 셈 — 줄마다 결과 · 항목 소계 · 합계 항(경쟁사 꼴) · 비목 합계 */
export function compute(doc) {
const vars = {};
const terms = { l: [], m: [], e: [] };
const all = { l: 0, m: 0, e: 0 };
const items = [];
const errors = [];
for (const item of doc.items ?? []) {
const it = { lines: [], sum: { l: 0, m: 0, e: 0 }, terms: { l: [], m: [], e: [] } };
it.hasSub = (item.lines ?? []).some((ln) => ln.k === "mach" || ln.k === "ref");
const put = (col, val, r) => {
it.sum[col] = round1(it.sum[col] + val);
it.terms[col].push(val);
r.cols[col] = val;
};
for (const ln of item.lines ?? []) {
const r = { cols: {}, err: null, vars: { ...vars } };
try {
if (ln.k === "formula") {
r.rows = (ln.rows ?? []).map((row) => calcRow(row, vars));
} else if (ln.k === "mach" || ln.k === "ref") {
for (const c of COLS) {
const price = Number(ln.res?.[c]) || 0;
if (price) put(c, floor1(applyOps(price, ln.cops?.[c] ?? ln.ops, vars)), r);
}
} else if (ln.k === "labor" || ln.k === "mat") {
const col = ln.k === "labor" ? "l" : ln.col || "m";
put(col, floor1(applyOps(Number(ln.res?.price) || 0, ln.ops, vars)), r);
} else if (ln.k === "rate") {
const bc = ln.baseCol || "l";
const base = round1(ln.scope === "all" ? all[bc] + it.sum[bc] : it.sum[bc]);
r.base = base;
if (ln.baseName && ln.scope !== "all") it.terms[bc] = base ? [base] : [];
put(ln.col || "m", floor1((base * Number(ln.rate || 0)) / 100), r);
}
} catch (e) {
r.err = e.message;
errors.push(e.message);
}
it.lines.push(r);
}
for (const c of COLS) {
all[c] = round1(all[c] + it.sum[c]);
terms[c].push(...(it.hasSub ? (it.sum[c] ? [it.sum[c]] : []) : it.terms[c]));
}
items.push(it);
}
const tot = { l: floor0(all.l), m: floor0(all.m), e: floor0(all.e) };
tot.sum = tot.l + tot.m + tot.e;
return { items, terms, all, tot, errors, vars };
}
/** 공식 줄 한 칸 — 「이름 = 식」 · 없는 이름은 unknown 으로(빨강) */
export function calcRow(row, vars) {
const r = { val: null, err: null, unknown: [], env: { ...vars } };
if (!row.x?.toString().trim()) return r;
try {
r.ast = parse(row.x);
r.unknown = varsIn(r.ast).filter((n) => !(n in vars));
if (r.unknown.length) {
r.err = `없는 이름: ${r.unknown.join(", ")}`;
return r;
}
const v = evalAst(r.ast, vars);
r.val = isPlain(r.ast) ? v : roundTo(v, row.d ?? 2);
if (row.n?.trim()) vars[row.n.trim()] = r.val;
} catch (e) {
r.err = e.message;
}
return r;
}