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>
This commit is contained in:
2026-11-07 23:19:38 +09:00
co-authored by Claude Opus 5.5
parent d59874d4b4
commit 3e64aa2a79
12 changed files with 1633 additions and 1 deletions
+182
View File
@@ -0,0 +1,182 @@
// 줄 쌓기 단가산출 시험 — 계산(화면 없음 · 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;
}