- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = 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>
183 lines
6.4 KiB
JavaScript
183 lines
6.4 KiB
JavaScript
// 줄 쌓기 단가산출 시험 — 계산(화면 없음 · 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;
|
||
}
|