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