Files
Aislo/tools/line-stack-test/paste.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

258 lines
9.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.
import { compute } from "./calc.js";
// 줄 쌓기 단가산출 시험 — 엑셀 「단가산출」 블록 글 → 줄(일곱 종류로 읽히는 만큼 · 못 읽는 줄은 글 줄)
const NUM = /^-?[\d,]*\.?\d+\.?$|^-?\d[\d,]*\.$/;
const isNum = (t) => NUM.test(t);
const num = (t) => Number(String(t).replace(/,/g, "").replace(/\.$/, ""));
const clean = (t) =>
String(t)
.replace(/(\d)\.(?!\d)/g, "$1")
.replace(/,(?=\d{3})/g, "");
const OPS = ["×", "÷", "/", "*"];
const KEY = { 노무비: "l", 재료비: "m", 경비: "e" };
/** 「× 4 인 ÷ 170 ㎡」 → 꼬리 [{op, v, u}] · 괄호 등 못 읽으면 null */
export function parseOps(s, known = {}) {
const tk = s.trim().split(/\s+/).filter(Boolean);
const ops = [];
let i = 0;
while (i < tk.length) {
const op = tk[i] === "*" ? "×" : tk[i];
let v = tk[i + 1];
if (!OPS.includes(tk[i]) || v === undefined) return null;
if (isNum(v)) {
v = clean(v);
// 값이 위 공식의 Q · QH 와 같으면 이름으로 이음(틀을 고치면 따라 바뀜)
const hit = Object.entries(known).find(([n, val]) => /^Q/.test(n) && String(val) === v);
if (hit) v = hit[0];
} else if (!/^[A-Za-z]\w*$/.test(v)) return null;
i += 2;
const u = [];
while (i < tk.length && !OPS.includes(tk[i])) u.push(tk[i++]);
if (u.some((x) => /[()]/.test(x))) return null;
ops.push({ op, v, u: u.join(" ") });
}
return ops;
}
/** 「q = 0.7 ㎥ K = 0.55 f = 1. / 1.325 = 0.75」 → 줄 [{n, x, u}] */
export function parseDefs(line) {
const re = /(^|\s)([A-Za-z][A-Za-z0-9]*)\s*=\s/g;
const starts = [];
let m;
while ((m = re.exec(line))) {
const before = line.slice(starts.length ? starts.at(-1).body : 0, m.index).trim();
if (starts.length && /[+\-×÷*/(]$/.test(before)) continue;
starts.push({ n: m[2], at: m.index, body: m.index + m[0].length });
}
return starts.map((s, i) => {
const seg = line.slice(s.body, starts[i + 1]?.at ?? line.length).trim();
const parts = seg.split(/\s=\s/);
const last = parts.at(-1).trim();
const lm = last.match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/);
if (parts.length === 1)
return { n: s.n, x: lm ? clean(lm[1]) : clean(last), u: lm ? lm[2] : "" };
return { n: s.n, x: clean(parts[0]), u: lm ? lm[2] : "", got: lm ? num(lm[1]) : null };
});
}
/** 율 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」 → 율 줄 */
function rateHead(s, next) {
const m = s.match(/^(.*?)\s*\(\s*(\S+?)의\s*([\d.]+)\s*%\s*\)\s*$/);
if (!m || !/×\s*[\d.]+\s*%\s*=/.test(next ?? "")) return null;
const bc = /노무/.test(m[2]) ? "l" : /경비/.test(m[2]) ? "e" : "m";
return { k: "rate", name: m[1], of: m[2], baseCol: bc, rate: String(num(m[3])), col: "m" };
}
/** 분수 세 줄(분자 · 「Q = ─ … = 23.39 ㎥ / hr」 · 분모) → 줄 하나 · 기호 반 · 값 반으로 나눔 */
function parseFrac(top, mid, bot) {
const mm = mid.match(/^([A-Za-z][A-Za-z0-9]*)\s*=\s*(.*)$/);
if (!mm) return null;
const parts = mm[2].split(/\s=\s/);
const sym = parts[0];
const groups = sym.match(/─(\s*─)*/g) ?? [];
const half = (s) => {
const t = s.trim().split(/\s+/);
return t.length % 2 ? null : t.slice(0, t.length / 2);
};
const a = half(top);
const b = half(bot);
if (!a || !b || !groups.length) return null;
if (groups.length > 1 && (a.length !== groups.length || b.length !== groups.length)) return null;
let k = 0;
const x = sym.replace(/─(\s*─)*/g, () => {
const [na, nb] = groups.length > 1 ? [a[k], b[k]] : [a.join(" "), b.join(" ")];
k++;
return `(${na}) / (${nb})`;
});
const tail = parts
.at(-1)
.trim()
.match(/^(-?[\d,]*\.?\d+\.?)\s*(.*)$/);
return {
n: mm[1],
x: clean(x),
u: tail && parts.length > 1 ? tail[2] : "",
got: tail ? num(tail[1]) : null,
};
}
export function parsePaste(text) {
const lines = String(text)
.replace(/\t/g, " ")
.split(/\r?\n/)
.map((s) => s.replace(/\s+/g, " ").trim());
const doc = { name: "붙여넣은 단가산출", unit: "", items: [] };
let expect = null;
let item = null;
let summary = false;
let pendingBase = "";
let skipRate = false;
const known = {};
const sums = { l: [], m: [], e: [] };
const stat = { read: 0, text: 0 };
const cur = () => item ?? ((item = { title: "", lines: [] }), doc.items.push(item), item);
const add = (ln) => (cur().lines.push(ln), ln.k === "text" ? stat.text++ : stat.read++);
const formula = () => {
const last = cur().lines.at(-1);
if (last?.k === "formula") return last;
const f = { k: "formula", tpl: null, rows: [] };
add(f);
return f;
};
const addRow = (r) => {
formula().rows.push({ n: r.n, x: r.x, u: r.u });
if (r.got != null) known[r.n] = String(r.got);
else if (isNum(r.x)) known[r.n] = r.x;
};
for (let i = 0; i < lines.length; i++) {
const s = lines[i];
if (!s || /^단가산출$|^산 ?출 ?근 ?거/.test(s)) continue;
let m = s.match(/^(\d+)\.(\S.*?)\[(.+?)\]\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*([\d,]+)?\s*$/);
if (m && !doc.items.length) {
doc.name = m[2].trim();
doc.unit = m[3];
if (m[4])
expect = { sum: num(m[4]), l: num(m[5] ?? 0), m: num(m[6] ?? 0), e: num(m[7] ?? 0) };
continue;
}
// 분수 세 줄
if (lines[i + 1]?.includes("─")) {
const r = parseFrac(s, lines[i + 1], lines[i + 2] ?? "");
if (r) {
addRow(r);
i += 2;
continue;
}
for (const t of [s, lines[i + 1], lines[i + 2] ?? ""]) add({ k: "text", text: t });
i += 2;
continue;
}
if ((m = s.match(/^(\d+)\s*\.\s+(.*)$/)) || (m = s.match(/^([가-하])\s*\.\s+(.*)$/))) {
summary = /^(합계|소계)$/.test(m[2].replace(/\s/g, ""));
if (summary) continue;
item = { title: m[2], lines: [], ...(/[가-하]/.test(m[1]) ? { sub: true } : {}) };
doc.items.push(item);
const rm = rateHead(m[2], lines[i + 1]);
if (rm) {
add({ ...rm, name: "", scope: "all", baseName: "" });
pendingBase = "";
i++;
}
continue;
}
const key = s.split(":")[0].replace(/\s/g, "");
if (summary && /^(노무비|재료비|경비|계)$/.test(key)) {
if (KEY[key]) {
const nums =
s
.slice(s.indexOf(":") + 1)
.split("=")[0]
.match(/[\d,]+\.?\d*/g) ?? [];
sums[KEY[key]].push(...nums.map(num));
}
continue;
}
if (/^소계$/.test(key)) continue;
if (s.includes(":") && /소계$/.test(key)) {
pendingBase = key.replace(/소계$/, "");
continue;
}
// 율 줄 머리 「이름 ( 노무비의 1 % )」 + 다음 줄 「38,290.7 × 1. % = 382.9」
const rm = rateHead(s, lines[i + 1]);
if (rm) {
add({ ...rm, baseName: pendingBase, scope: "item" });
pendingBase = "";
i++;
continue;
}
// 「q = 0.7 K = 0.55」 값 · 식 줄
if (/^[A-Za-z][A-Za-z0-9]*\s*=\s/.test(s) && !s.includes(":")) {
parseDefs(s).forEach(addRow);
continue;
}
// 「노무비 : 59,020 ÷ 23.39 = 2,523.3」 셋 → 기계 · 참조 줄
if (KEY[key] && s.includes(":")) {
const rhs = s
.slice(s.indexOf(":") + 1)
.split("=")[0]
.trim();
const pm = rhs.match(/^([\d,]+\.?)\s+(.*)$/);
const ops = pm && parseOps(pm[2], known);
if (!pm || !ops) {
if (rhs) add({ k: "text", text: s });
continue;
}
const c = KEY[key];
let last = cur().lines.at(-1);
if (!(
last &&
(last.k === "mach" || last.k === "ref") &&
!(c in last.res) &&
last.res._open
)) {
const ref = (item?.title ?? "").match(/단산\s*(\d+)\s*참조/);
last = ref
? { k: "ref", res: { no: Number(ref[1]), name: item.title, _open: true }, ops }
: { k: "mach", res: { name: item?.title ?? "", _open: true }, ops };
add(last);
}
last.res[c] = num(pm[1]);
if (JSON.stringify(ops) !== JSON.stringify(last.ops)) (last.cops ??= {})[c] = ops;
continue;
}
// 「치즐 ( 223,000. × 0.006 ) ÷ 5.」 → 괄호 걷음(곱나눔 차례 그대로라 값 같음)
const s2 = s.replace(/\(\s*([\d,]+\.?\s+[×÷][^()]*?)\s*\)/, "$1");
// 「보통인부 : 172,068 ÷ 8. ÷ 23.39 = 919.5」 → 인력
if ((m = s2.match(/^([^:]+?)\s*:\s*([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/))) {
const ops = parseOps(m[3], known);
if (ops) {
add({ k: "labor", res: { name: m[1], price: num(m[2]) }, ops });
continue;
}
}
// 「합판(내수),대전 (…) 11,455. × 1.03 ㎡ × 38. % = 4,483.4」 → 재료
if ((m = s2.match(/^(.*\S)\s+([\d,]+\.?)\s+([×÷/*].*?)\s*=\s*[\d,.]+/)) && !s.includes(":")) {
const ops = parseOps(m[3], known);
if (ops) {
add({ k: "mat", col: "m", res: { name: m[1], price: num(m[2]) }, ops });
continue;
}
}
add({ k: "text", text: s });
}
for (const it of doc.items) for (const ln of it.lines) if (ln.res) delete ln.res._open;
// 율 줄이 든 비목 = 합계 줄에 그 값이 놓인 칸
const res = compute(doc);
doc.items.forEach((it, ii) =>
it.lines.forEach((ln, li) => {
if (ln.k !== "rate") return;
const v = Object.values(res.items[ii].lines[li].cols)[0];
const col =
["m", "e", "l"].find((c) => sums[c].includes(v)) ??
["m", "e", "l"].find((c) => sums[c].includes(Math.floor(v)));
if (col) ln.col = col;
}),
);
return { doc, expect, stat };
}