chore(tools): line-stack-test 시험 페이지 — 시놀로지가 복사한 랩탑 판(메인 3e64aa2a7 과 같은 내용 · 합칠 때 같은 추가로 풀림)

This commit is contained in:
2026-11-08 12:13:58 +09:00
parent 2885a51854
commit 47f4a729b4
9 changed files with 1613 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
// 줄 쌓기 단가산출 시험 페이지(요구 669~676 · PLAN 804-1) — 서버 없음 · 저장 = 이 브라우저 localStorage
// 지우기 = 이 폴더 삭제 + main.py 「/tools/line-stack-test」 마운트 덩이 + vite.config.ts 「/tools」 프록시 줄 + M01 왼쪽 패널 「시험: 줄 쌓기」 덩이
import { compute } from "./calc.js";
import { renderOut, fmtP, esc } from "./render.js";
import { parsePaste } from "./paste.js";
import { LABOR, MACHINE, MATERIAL, SANGEUN, SAMPLES, TABLE_E } from "./samples.js";
import { renderEdit, allTemplates } from "./edit.js";
const KEY_DOC = "lineStackTest.doc";
const KEY_TPL = "lineStackTest.myTemplates";
const $ = (s) => document.querySelector(s);
const clone = (x) => structuredClone(x);
let doc = clone(SAMPLES[0].doc);
let expect = SAMPLES[0].expect;
let expectFrom = SAMPLES[0].label;
let mine = JSON.parse(localStorage.getItem(KEY_TPL) || "[]");
let res = compute(doc);
const get = (path) => path.split(".").reduce((o, k) => o?.[k], doc);
const setPath = (path, v) => {
const ks = path.split(".");
const last = ks.pop();
ks.reduce((o, k) => o[k], doc)[last] = v;
};
/** 칸 값만 바뀜 — 오른쪽 출력 · 합계 · 공식 값 칸만 다시(왼쪽은 그대로 → 입력 자리 안 잃음) */
function update() {
res = compute(doc);
$("#out").innerHTML = renderOut(doc, res);
const t = res.tot;
$("#tot").innerHTML =
["l", "m", "e"]
.map(
(c) =>
`<div class="box"><span>${{ l: "노무비", m: "재료비", e: "경비" }[c]}</span><b>${fmtP(t[c])}</b></div>`,
)
.join("") + `<div class="box sum"><span>계</span><b>${fmtP(t.sum)}</b></div>`;
const same = expect && ["l", "m", "e", "sum"].every((c) => t[c] === expect[c]);
$("#cmp").innerHTML = expect
? `${esc(expectFrom)} 거창 파일 ${fmtP(expect.sum)} (노 ${fmtP(expect.l)} · 재 ${fmtP(expect.m)} · 경 ${fmtP(expect.e)}) → <b class="${same ? "ok" : "bad"}">${same ? "같음" : "다름"}</b>`
: "";
document.querySelectorAll("[data-val]").forEach((el) => {
const m = el.dataset.val.match(/^items\.(\d+)\.lines\.(\d+)\.rows\.(\d+)$/);
const rr = res.items[m[1]]?.lines[m[2]]?.rows?.[m[3]];
el.textContent = rr?.err ?? rr?.val ?? "";
el.classList.toggle("bad", !!rr?.err);
el.parentElement.querySelector(".expr")?.classList.toggle("bad", !!rr?.err);
});
}
/** 줄 더함 · 지움 · 옮김 등 꼴이 바뀜 — 왼쪽까지 다시 */
function redraw() {
res = compute(doc);
$("#edit").innerHTML = renderEdit(doc, res, mine);
update();
}
function newLine(kind) {
const [k, tplId] = kind.split(":");
if (k === "formula") {
const tpl = allTemplates(mine).find((t) => t.id === tplId);
return {
k,
tpl: tpl ? tpl.id : null,
rows: tpl ? clone(tpl.rows) : [{ n: "", x: "", u: "" }],
open: !tpl,
};
}
if (k === "mach") return { k, res: { ...MACHINE[0] }, ops: [{ op: "÷", v: "Q", u: "" }] };
if (k === "ref") return { k, res: { ...SANGEUN[0] }, ops: [{ op: "×", v: "Q", u: "" }] };
if (k === "labor") return { k, res: { ...LABOR[0] }, ops: [{ op: "×", v: "1", u: "인" }] };
if (k === "mat")
return { k, col: "m", res: { ...MATERIAL[0] }, ops: [{ op: "×", v: "1", u: "" }] };
if (k === "rate")
return {
k,
name: "공구손료 및 경장비",
of: "노무비",
baseName: "",
baseCol: "l",
scope: "item",
rate: "3",
col: "m",
};
return { k: "text", text: "" };
}
function loadSample(s) {
doc = clone(s.doc);
expect = s.expect;
expectFrom = s.label;
redraw();
}
// ── 머리 단추
$("#samples").innerHTML = SAMPLES.map(
(s, i) => `<button data-sample="${i}">${esc(s.label)}</button>`,
).join("");
$("#samples").addEventListener("click", (e) => {
const i = e.target.dataset.sample;
if (i !== undefined) loadSample(SAMPLES[i]);
});
$("#save").onclick = () => {
localStorage.setItem(KEY_DOC, JSON.stringify({ doc, expect, expectFrom }));
$("#msg").textContent = "이 브라우저에 저장함";
};
$("#load").onclick = () => {
const s = JSON.parse(localStorage.getItem(KEY_DOC) || "null");
if (!s) return void ($("#msg").textContent = "저장한 것 없음");
({ doc, expect, expectFrom } = s);
redraw();
$("#msg").textContent = "불러옴";
};
// ── 왼쪽 칸 입력
$("#edit").addEventListener("input", (e) => {
const p = e.target.dataset.p;
if (!p) return;
setPath(p, e.target.type === "checkbox" ? e.target.checked : e.target.value);
if (e.target.type === "checkbox") return redraw();
update();
if (/\.rows\.\d+\.[nx]$/.test(p)) {
// 틀 이름표(「내 틀 … 에서 변경」)만 다시 — 입력 자리는 그대로
const lp = p.replace(/\.rows\.\d+\.[nx]$/, "");
const card = e.target.closest(".line");
const fresh = document.createElement("div");
fresh.innerHTML = renderEdit(doc, res, mine);
const [, i, j] = lp.match(/items\.(\d+)\.lines\.(\d+)/);
const tag = fresh.querySelector(`.line[data-loc="${i}.${j}"] .tpl`);
if (tag) card.querySelector(".tpl").innerHTML = tag.innerHTML;
}
});
$("#edit").addEventListener("change", (e) => {
const act = e.target.dataset.act;
if (!act || !e.target.value) return;
if (act === "addline") {
doc.items[e.target.dataset.i].lines.push(newLine(e.target.value));
return redraw();
}
if (act.startsWith("res:")) {
const [, kind, p] = act.split(":");
const list = { mach: MACHINE, ref: SANGEUN, labor: LABOR, mat: MATERIAL }[kind];
get(p).res = { ...list[e.target.value] };
return redraw();
}
});
$("#edit").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
const { act, p, k, loc, i } = b.dataset;
const at = loc?.split(".").map(Number);
if (act === "additem") doc.items.push({ title: "", lines: [] });
else if (act === "delitem") doc.items.splice(Number(i), 1);
else if (act === "delline") doc.items[at[0]].lines.splice(at[1], 1);
else if (act === "addop") (get(p).ops ??= []).push({ op: "×", v: "", u: "" });
else if (act === "delop") get(p).ops.splice(Number(k), 1);
else if (act === "addrow") get(p).rows.push({ n: "", x: "", u: "" });
else if (act === "delrow") get(p).rows.splice(Number(k), 1);
else if (act === "fold") get(p).open = !get(p).open;
else if (act === "savetpl") {
const ln = get(p);
const name = prompt("틀 이름", "내 틀");
if (!name?.trim()) return;
const id = `my${Date.now()}`;
mine.push({ id, name: name.trim(), rows: clone(ln.rows).map(({ src, ...r }) => r) });
localStorage.setItem(KEY_TPL, JSON.stringify(mine));
ln.tpl = id;
} else if (act === "table") return openTable(p);
else return;
redraw();
});
// ── 끌어 순서 바꿈(줄 위 = 그 앞 · 항목 머리 = 그 항목 끝)
let dragFrom = null;
$("#edit").addEventListener("dragstart", (e) => {
dragFrom = e.target.dataset?.loc?.split(".").map(Number) ?? null;
e.dataTransfer?.setData("text/plain", "line");
});
$("#edit").addEventListener("dragover", (e) => {
if (dragFrom && e.target.closest(".line, [data-drop]")) e.preventDefault();
});
$("#edit").addEventListener("drop", (e) => {
if (!dragFrom) return;
e.preventDefault();
const [fi, fj] = dragFrom;
dragFrom = null;
const ln = doc.items[fi].lines.splice(fj, 1)[0];
const card = e.target.closest(".line");
if (card) {
let [ti, tj] = card.dataset.loc.split(".").map(Number);
if (ti === fi && tj > fj) tj--;
doc.items[ti].lines.splice(tj, 0, ln);
} else {
doc.items[Number(e.target.closest("[data-drop]").dataset.drop)].lines.push(ln);
}
redraw();
});
// ── 표에서 고르기(견본 표 하나) — 값 + 출처 꼬리표
function openTable(p) {
const dlg = $("#tbl");
dlg.querySelector("h3").textContent = TABLE_E.title;
dlg.querySelector("table").innerHTML =
`<tr>${TABLE_E.head.map((h) => `<th>${esc(h)}</th>`).join("")}</tr>` +
TABLE_E.rows
.map(
(r) =>
`<tr><th>${esc(r[0])}</th>${r
.slice(1)
.map((v, c) =>
v == null
? "<td></td>"
: `<td><button data-v="${v}" data-src="${esc(`${TABLE_E.title} · ${r[0]} · ${TABLE_E.head[c + 1]}`)}">${v}</button></td>`,
)
.join("")}</tr>`,
)
.join("");
dlg.onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) {
const row = get(p);
row.x = b.dataset.v;
row.src = b.dataset.src;
dlg.close();
redraw();
} else if (e.target.closest("[data-close]")) dlg.close();
};
dlg.showModal();
}
// ── 엑셀 글 붙여넣기
$("#pasteGo").onclick = () => {
const text = $("#paste").value;
if (!text.trim()) return void ($("#pasteMsg").textContent = "붙일 글이 없음");
const { doc: d, expect: ex, stat } = parsePaste(text);
doc = d;
expect = ex;
expectFrom = "붙여넣은 블록";
redraw();
$("#pasteMsg").textContent =
`읽은 줄 ${stat.read} · 글 줄 ${stat.text}${ex ? "" : " · 머리 합계 없음(비교 안 함)"}`;
};
redraw();
+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;
}
+11
View File
@@ -0,0 +1,11 @@
// node tools/line-stack-test/check.mjs — 견본 셋 금액 = 거창 출력 숫자(원 단위)
import { compute } from "./calc.js";
import { SAMPLES } from "./samples.js";
let bad = 0;
for (const s of SAMPLES.filter((x) => x.expect)) {
const { tot, errors } = compute(s.doc);
const ok = ["l", "m", "e", "sum"].every((c) => tot[c] === s.expect[c]) && !errors.length;
if (!ok) bad++;
console.log(ok ? "같음" : "다름", s.label, JSON.stringify(tot), errors.join(" "));
}
process.exit(bad ? 1 : 0);
+165
View File
@@ -0,0 +1,165 @@
// 줄 쌓기 단가산출 시험 — 왼쪽 줄 쌓기 편집(종류마다 필요한 칸만 · 칸은 data-p 경로로 doc 에 바로 씀)
import { LABOR, MACHINE, MATERIAL, SANGEUN, TEMPLATES } from "./samples.js";
import { esc } from "./render.js";
export const KINDS = {
mach: "① 기계",
ref: "② 다른 단산 참조",
labor: "③ 인력",
rate: "④ 율",
mat: "⑤ 재료",
formula: "⑥ 공식",
text: "⑦ 글 · 근거",
};
const COLSEL = { l: "노무비", m: "재료비", e: "경비" };
const inp = (p, v, cls = "", ph = "") =>
`<input data-p="${p}" value="${esc(v ?? "")}" class="${cls}" placeholder="${esc(ph)}" />`;
const sel = (p, v, opts) =>
`<select data-p="${p}">${Object.entries(opts)
.map(([k, t]) => `<option value="${k}"${k === v ? " selected" : ""}>${esc(t)}</option>`)
.join("")}</select>`;
const pick = (list, act, label) =>
`<select data-act="${act}"><option value="">${label}</option>${list
.map((r, i) => `<option value="${i}">${esc(r.no ? `단산 ${r.no} ${r.name}` : r.name)}</option>`)
.join("")}</select>`;
export function allTemplates(mine) {
return [...TEMPLATES, ...mine];
}
/** 「+ 줄」 고르개 — 공식은 틀마다 하나씩 */
export function addLineSelect(i, mine) {
const tpls = allTemplates(mine)
.map((t) => `<option value="formula:${t.id}">⑥ 공식 — ${esc(t.name)}</option>`)
.join("");
return `<select data-act="addline" data-i="${i}"><option value="">+ 줄 ▾</option>
<option value="mach">${KINDS.mach}</option><option value="ref">${KINDS.ref}</option>
<option value="labor">${KINDS.labor}</option><option value="rate">${KINDS.rate}</option>
<option value="mat">${KINDS.mat}</option>${tpls}
<option value="formula:">⑥ 공식 — 새 공식(빈 묶음)</option><option value="text">${KINDS.text}</option></select>`;
}
function opsEdit(p, ops) {
const one = (o, k) =>
`<span class="op">${sel(`${p}.ops.${k}.op`, o.op, { "×": "×", "÷": "÷", "/": "/" })}${inp(
`${p}.ops.${k}.v`,
o.v,
"v",
"값 · 이름",
)}${inp(`${p}.ops.${k}.u`, o.u, "u", "단위")}<button data-act="delop" data-p="${p}" data-k="${k}" title="지움">×</button></span>`;
return `<div class="ops">${(ops ?? []).map(one).join("")}<button data-act="addop" data-p="${p}">+ 곱나눔</button></div>`;
}
const copsNote = (ln) =>
ln.cops
? `<div class="note">비목 따로 꼬리(붙여넣기): ${Object.entries(ln.cops)
.map(
([c, ops]) =>
`${COLSEL[c]} ${ops.map((o) => `${o.op} ${esc(o.v)} ${esc(o.u)}`).join(" ")}`,
)
.join(" · ")}</div>`
: "";
/** 공식 줄 — 접힘 = 값 칸만 · 풀림 = 「이름 = 식」 글 줄 전부 */
function formulaEdit(p, ln, r, mine) {
const tpl = allTemplates(mine).find((t) => t.id === ln.tpl);
const changed = tpl && JSON.stringify(shape(tpl.rows)) !== JSON.stringify(shape(ln.rows));
const label = !tpl
? ln.tpl === null && !ln.rows?.some((x) => x.x)
? "새 공식"
: "내 공식"
: changed
? `내 틀 (${esc(tpl.name)} 에서 변경)`
: esc(tpl.name);
const rows = (ln.rows ?? [])
.map((row, k) => {
const rr = r?.rows?.[k] ?? {};
const rp = `${p}.rows.${k}`;
const plain = /^-?[\d.]+$/.test(String(row.x ?? "").trim());
const val = `<span class="val ${rr.err ? "bad" : ""}" data-val="${rp}">${rr.err ? esc(rr.err) : (rr.val ?? "")}</span>`;
const src = row.src ? `<span class="src" title="${esc(row.src)}">표</span>` : "";
const table = `<button data-act="table" data-p="${rp}" title="표에서 고르기">표</button>`;
if (!ln.open) {
if (plain || !row.x)
return `<div class="frow"><b>${esc(row.n || "?")}</b> = ${inp(`${rp}.x`, row.x, "num")} <i>${esc(row.u)}</i> ${table}${src}</div>`;
return `<div class="frow calc"><b>${esc(row.n)}</b> = <code>${esc(row.x)}</code> = ${val} <i>${esc(row.u)}</i></div>`;
}
return `<div class="frow open">${inp(`${rp}.n`, row.n, "name", "이름")} = ${inp(`${rp}.x`, row.x, `expr ${rr.err ? "bad" : ""}`, "값 또는 식")}${inp(
`${rp}.u`,
row.u,
"u",
"단위",
)}${val}${table}${src}<button data-act="delrow" data-p="${p}" data-k="${k}" title="줄 지움">×</button></div>`;
})
.join("");
return `<div class="fhead"><span class="tpl">${label}</span>
<button data-act="fold" data-p="${p}">${ln.open ? "접기" : "식으로 풀기"}</button>
<button data-act="savetpl" data-p="${p}">틀로 저장</button></div>${rows}
${ln.open ? `<button data-act="addrow" data-p="${p}">+ 「이름 = 식」 줄</button>` : ""}`;
}
/** 틀과 견줄 꼴 — 값 줄은 이름만 · 식 줄은 이름과 식 */
export function shape(rows) {
return (rows ?? []).map((x) =>
/^-?[\d.]+$/.test(String(x.x).trim()) ? [x.n] : [x.n, String(x.x).replace(/\s/g, "")],
);
}
function lineBody(p, ln, r, mine) {
const price3 = () =>
`<div class="row">노무 ${inp(`${p}.res.l`, ln.res?.l, "num")} 재료 ${inp(`${p}.res.m`, ln.res?.m, "num")} 경비 ${inp(
`${p}.res.e`,
ln.res?.e,
"num",
)}</div>`;
switch (ln.k) {
case "mach":
return `<div class="row">${pick(MACHINE, `res:mach:${p}`, "견본 기계 ▾")}${inp(`${p}.res.name`, ln.res?.name, "wide", "기계 이름")}</div>${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
case "ref":
return `<div class="row">${pick(SANGEUN, `res:ref:${p}`, "견본 단산 ▾")} 단산 ${inp(`${p}.res.no`, ln.res?.no, "num s")}${inp(
`${p}.res.name`,
ln.res?.name,
"wide",
"단가산출 이름",
)}</div>${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
case "labor":
case "mat":
return `<div class="row">${pick(ln.k === "labor" ? LABOR : MATERIAL, `res:${ln.k}:${p}`, ln.k === "labor" ? "견본 노임 ▾" : "견본 자재 ▾")}${inp(
`${p}.res.name`,
ln.res?.name,
"wide",
"이름",
)} 단가 ${inp(`${p}.res.price`, ln.res?.price, "num")}${ln.k === "mat" ? ` → ${sel(`${p}.col`, ln.col || "m", COLSEL)}` : ""}</div>${opsEdit(p, ln.ops)}`;
case "rate":
return `<div class="row">${inp(`${p}.name`, ln.name, "wide", "이름(예: 공구손료 및 경장비)")}</div>
<div class="row">기준 ${sel(`${p}.scope`, ln.scope || "item", { item: "이 항목 위 줄", all: "위 전부" })} 의 ${sel(`${p}.baseCol`, ln.baseCol || "l", COLSEL)}
× ${inp(`${p}.rate`, ln.rate, "num s")} % → ${sel(`${p}.col`, ln.col || "m", COLSEL)}</div>
<div class="row">글 「( ${inp(`${p}.of`, ln.of, "s2", "노무비")}의 % )」 · 소계 이름 ${inp(`${p}.baseName`, ln.baseName, "s2", "비우면 안 찍음")}</div>`;
case "formula":
return formulaEdit(p, ln, r, mine);
case "text":
return inp(`${p}.text`, ln.text, "full", "< 품셈 근거 > · ※ 메모 · 별산");
}
return "";
}
export function renderEdit(doc, res, mine) {
const items = (doc.items ?? [])
.map((item, i) => {
const lines = (item.lines ?? [])
.map((ln, j) => {
const p = `items.${i}.lines.${j}`;
const r = res.items[i]?.lines[j];
return `<div class="line k-${ln.k}" data-loc="${i}.${j}"><div class="lh"><span class="grip" draggable="true" data-loc="${i}.${j}" title="끌어 순서 바꿈">⠿</span><span class="kind">${KINDS[ln.k]}</span>${
r?.err ? `<span class="bad">${esc(r.err)}</span>` : ""
}<button data-act="delline" data-loc="${i}.${j}" title="줄 지움">지움</button></div>${lineBody(p, ln, r, mine)}</div>`;
})
.join("");
return `<div class="item" data-i="${i}"><div class="ih" data-drop="${i}">${inp(`items.${i}.title`, item.title, "title", "항목 이름(비우면 번호 없음)")}
<label><input type="checkbox" data-p="items.${i}.sub" ${item.sub ? "checked" : ""}/>가나다</label>${addLineSelect(i, mine)}
<button data-act="delitem" data-i="${i}">항목 지움</button></div>${lines || '<div class="empty" data-drop="' + i + '">줄 없음 — [+ 줄 ▾]</div>'}</div>`;
})
.join("");
return `<div class="dochead">이름 ${inp("name", doc.name, "wide")} 단위 ${inp("unit", doc.unit, "s")}</div>${items}<button data-act="additem">+ 항목</button>`;
}
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>시험: 줄 쌓기 단가산출</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<h1>시험: 줄 쌓기 단가산출</h1>
<span class="hint">서버 없음 · 저장은 이 브라우저에만 · 마음에 안 들면 폴더째 지움</span>
<span id="samples"></span>
<button id="save">저장</button><button id="load">불러오기</button><span id="msg"></span>
</header>
<main>
<section id="edit"></section>
<section id="outWrap"><div id="out"></div></section>
</main>
<footer>
<div id="tot"></div>
<div id="cmp"></div>
<details>
<summary>
엑셀 글 붙여넣기 — 경쟁사 「단가산출」 시트 한 블록(머리 줄부터 「계 :」 줄까지)
</summary>
<textarea
id="paste"
rows="8"
placeholder="1.측구터파기(토사)[㎥] 5323 3,442 832 1,049&#10;1. 기 계 ( 100 % ) : 굴삭기(무한궤도) (0.7㎥)&#10;…"
></textarea>
<button id="pasteGo">줄로 넣기</button><span id="pasteMsg"></span>
</details>
</footer>
<dialog id="tbl">
<h3></h3>
<table></table>
<button data-close>닫기</button>
</dialog>
<script type="module" src="app.js"></script>
</body>
</html>
+257
View File
@@ -0,0 +1,257 @@
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 };
}
+181
View File
@@ -0,0 +1,181 @@
// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 CSS 로 조판)
import { COLS, COL_NAME, parse, varsIn } from "./calc.js";
export const esc = (s) =>
String(s ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
export const fmtP = (n) => Math.round(n).toLocaleString("en-US");
export const fmt1 = (n) =>
n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
export const fmtV = (n) =>
n == null || Number.isNaN(n) ? "?" : n.toLocaleString("en-US", { maximumFractionDigits: 4 });
const OP = { "*": "×", "/": "/", "×": "×", "÷": "÷", "+": "+", "-": "-", "^": "^" };
/** 식 나무 → 글(sym = 기호 · val = 값) · 기호가 든 식의 「/」 는 분수로 */
export function showAst(ast, mode, env, frac = varsIn(ast).length > 0) {
const go = (a) => showAst(a, mode, env, frac);
const bare = (a) => (a.t === "par" ? go(a.a) : go(a));
switch (ast.t) {
case "num":
return fmtV(ast.v);
case "var":
return mode === "sym"
? esc(ast.n)
: ast.n in env
? fmtV(env[ast.n])
: `<b class="bad">${esc(ast.n)}</b>`;
case "par":
return `( ${go(ast.a)} )`;
case "neg":
return `-${go(ast.a)}`;
}
if (ast.op === "/" && frac)
return `<span class="fr"><span>${bare(ast.a)}</span><span>${bare(ast.b)}</span></span>`;
return `${go(ast.a)} ${OP[ast.op]} ${go(ast.b)}`;
}
const opsText = (ops, env) =>
(ops ?? [])
.map((o) => {
let v;
try {
v = showAst(parse(o.v), "val", env);
} catch {
v = `<b class="bad">${esc(o.v)}</b>`;
}
return `${esc(OP[o.op] ?? o.op)} ${v}${o.u ? " " + esc(o.u) : ""}`;
})
.join(" ");
/** 공식 줄 — 값 줄은 넷까지 한 줄에 · 식 줄은 「이름 = 기호식 = 값식 = 결과 단위」 */
function formulaRows(ln, r, push) {
let pack = [];
const flush = () =>
pack.length && (push({ h: pack.join('<span class="gap"></span>') }), (pack = []));
(ln.rows ?? []).forEach((row, i) => {
const rr = r.rows?.[i];
if (!row.n?.trim() && !row.x?.toString().trim()) return;
const u = row.u ? " " + esc(row.u) : "";
const name = esc(row.n || "?");
if (!rr?.ast || rr.err) {
flush();
return push({ h: `${name} = ${esc(row.x)} <b class="bad">${esc(rr?.err ?? "")}</b>` });
}
const plain = rr.ast.t === "num";
const hasVar = varsIn(rr.ast).length > 0;
if (!hasVar) {
pack.push(
`${name} = ${showAst(rr.ast, "sym", rr.env)}${plain ? "" : " = " + fmtV(rr.val)}${u}`,
);
if (pack.length >= 4) flush();
return;
}
flush();
push({
h: `${name} = ${showAst(rr.ast, "sym", rr.env)} = ${showAst(rr.ast, "val", rr.env)} = ${fmtV(rr.val)}${u}`,
tall: /class="fr"/.test(showAst(rr.ast, "sym", rr.env)),
});
});
flush();
}
/** 출력 줄 목록 — {h, cols?, cls?} */
export function outRows(doc, res) {
const rows = [];
const push = (r) => rows.push(r);
push({
h: `${esc(doc.name)}${doc.unit ? ` [${esc(doc.unit)}]` : ""}`,
cols: { ...res.tot },
cls: "head",
won: true,
});
let n = 0;
let sub = 0;
(doc.items ?? []).forEach((item, ii) => {
const it = res.items[ii];
if (item.title?.trim()) {
const label = item.sub ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
if (!item.sub) sub = 0;
push({ h: label + esc(item.title), cls: item.sub ? "item sub" : "item" });
}
// 율 줄이 소계 이름을 가지면 그 앞 같은 비목 줄은 칸 값을 안 찍음(소계 줄이 찍음)
const hideCol = (li, col) =>
item.lines.some(
(x, j) =>
j > li && x.k === "rate" && x.baseName && x.scope !== "all" && (x.baseCol || "l") === col,
);
item.lines.forEach((ln, li) => {
const r = it.lines[li];
if (r.err) return push({ h: `<b class="bad">${esc(r.err)}</b>`, err: true });
if (ln.k === "text") return push({ h: esc(ln.text) });
if (ln.k === "formula") return formulaRows(ln, r, push);
if (ln.k === "mach" || ln.k === "ref") {
for (const c of COLS) {
if (r.cols[c] === undefined) continue;
push({
h: `${COL_NAME[c]} : ${fmtP(ln.res[c])} ${opsText(ln.cops?.[c] ?? ln.ops, r.vars)} = ${fmt1(r.cols[c])}`,
});
}
return;
}
if (ln.k === "labor" || ln.k === "mat") {
const col = ln.k === "labor" ? "l" : ln.col || "m";
const v = r.cols[col];
const sep = ln.k === "labor" ? " :" : "";
return push({
h: `${esc(ln.res?.name)}${sep} ${fmtP(Number(ln.res?.price) || 0)} ${opsText(ln.ops, r.vars)} = ${fmt1(v)}`,
cols: hideCol(li, col) ? null : { sum: v, [col]: v },
});
}
if (ln.k === "rate") {
const col = ln.col || "m";
const bc = ln.baseCol || "l";
if (ln.baseName && ln.scope !== "all")
push({
h: `${esc(ln.baseName)}소계 : ${fmt1(r.base)}`,
cols: { sum: r.base, [bc]: r.base },
cls: "subsum",
});
push({ h: `${esc(ln.name)} ( ${esc(ln.of || COL_NAME[bc])}의 ${esc(ln.rate)} % )` });
push({
h: `${fmt1(r.base)} × ${esc(ln.rate)} % = ${fmt1(r.cols[col])}`,
cols: { sum: r.cols[col], [col]: r.cols[col] },
});
}
});
if (it.hasSub && COLS.some((c) => it.sum[c])) {
const s = Math.round((it.sum.l + it.sum.m + it.sum.e) * 10) / 10;
push({ h: `소 계 : ${fmt1(s)}`, cols: { sum: s, ...it.sum }, cls: "subsum" });
}
});
push({ h: `${++n}. 합 계`, cls: "item" });
for (const c of COLS) {
const t = res.terms[c];
const body =
t.length > 1
? `${t.map(fmt1).join(" + ")} = ${fmtP(res.tot[c])}`
: t.length
? fmtP(res.tot[c])
: "";
push({ h: `${COL_NAME[c]} : ${body}` });
}
push({ h: `계 : ${fmtP(res.tot.sum)}`, cols: { ...res.tot }, cls: "total", won: true });
return rows;
}
export function renderOut(doc, res) {
const cell = (r, k) => {
const v = r.cols?.[k];
if (v === undefined || v === null || (r.won && !v && k !== "sum")) return "<td></td>";
return `<td class="n">${r.won ? fmtP(v) : fmt1(v)}</td>`;
};
const body = outRows(doc, res)
.map(
(r) =>
`<tr class="${r.cls ?? ""}${r.tall ? " tall" : ""}"><td class="h">${r.h}</td>${["sum", ...COLS].map((k) => cell(r, k)).join("")}</tr>`,
)
.join("");
return `<table class="out"><thead><tr><th>산 출 근 거</th><th>합 계</th><th>노무비</th><th>재료비</th><th>경 비</th></tr></thead><tbody>${body}</tbody></table>`;
}
+221
View File
@@ -0,0 +1,221 @@
// 줄 쌓기 단가산출 시험 — 견본 자원 · 공식 틀 · 견본 표 · 견본 단가산출(거창 산불 출력 「단가산출」 시트 1 · 41 · 15 번)
export const LABOR = [
{ name: "보통인부", price: 172068 },
{ name: "형틀목공", price: 275790 },
{ name: "석공", price: 268908 },
];
export const MATERIAL = [
{ name: "합판(내수),대전 (12t×1220×2440mm)", price: 11455 },
{ name: "외송각재,대전 (30×30×3600mm)", price: 586824 },
{ name: "치즐(0.7㎥)", price: 223000 },
];
/** 기계 = 시간당 기계경비 노무 · 재료 · 경비 */
export const MACHINE = [
{ name: "굴삭기(무한궤도) (0.7㎥)", l: 59020, m: 19473, e: 24554 },
{ name: "굴삭기(무한궤도) (0.6㎥+부착용집게)", l: 59020, m: 16280, e: 29209 },
{ name: "덤프트럭 (15Ton)", l: 59020, m: 30192, e: 20659 },
];
/** 다른 단가산출 = 그 단산의 노무 · 재료 · 경비 */
export const SANGEUN = [
{ no: 13, name: "뒷채움돌 기초잡석 채집운반(Φ150m/m) [㎡]", l: 9889, m: 2262, e: 3858 },
{ no: 28, name: "기초다짐및뒷채움(소형장비사용) [㎥]", l: 11206, m: 1077, e: 1259 },
{ no: 1, name: "측구터파기(토사) [㎥]", l: 3442, m: 832, e: 1049 },
];
const row = (n, x, u = "", d) => (d === undefined ? { n, x, u } : { n, x, u, d });
/** 공식 틀 — 「이름 = 값」 · 「이름 = 식」 줄 묶음 */
export const TEMPLATES = [
{
id: "excavator",
name: "굴삭기 Q (품셈 8-2-3)",
rows: [
row("q", "0.7", "㎥"),
row("K", "0.55"),
row("f", "1 / 1.325"),
row("E", "0.45"),
row("Cm", "20", "sec ( 135 ˚ )"),
row("Q", "3600 × q × K × f × E / Cm", "㎥ / hr"),
],
},
{
id: "dump",
name: "덤프 Q (q · K 는 위 굴삭기 것)",
rows: [
row("L", "0.1", "km"),
row("V1", "7", "km / hr"),
row("V2", "8", "km / hr"),
row("T", "15", "ton"),
row("rt", "1.9", "ton / ㎥"),
row("E", "0.9"),
row("qt", "T ÷ rt", "㎥"),
row("f", "0.89"),
row("Cms", "22"),
row("Es", "0.35"),
row("n", "qt / (q × K)"),
row("t1", "Cms × n / (60 × Es)"),
row("t2", "(L / V1 + L / V2) × 60", "분 ( 왕복 )"),
row("t3", "1.1", "분 ( 적하 )"),
row("t4", "0.7", "분 ( 대기 )"),
row("Cm", "t1 + t2 + t3 + t4", "분"),
row("QH", "(Cm - t1) / Cm"),
row("Q", "60 × qt × f × E / Cm", "㎥ / hr"),
],
},
{ id: "directQ", name: "직접 Q", rows: [row("Q", "0.35")] },
];
/** 견본 표 하나 — [표에서 고르기] (표준품셈 8-2-3 굴착기 2. 작업효율 E) */
export const TABLE_E = {
title: "굴착기 작업효율 E (표준품셈 8-2-3)",
head: ["토질", "자연 양호", "자연 보통", "자연 불량", "흐트 양호", "흐트 보통", "흐트 불량"],
rows: [
["모래, 사질토", 0.85, 0.7, 0.55, 0.9, 0.75, 0.6],
["자갈섞인 흙, 점성토", 0.75, 0.6, 0.45, 0.8, 0.65, 0.5],
["파쇄암", null, null, null, null, 0.45, 0.35],
],
};
const op = (o, v, u = "") => ({ op: o, v, u });
const tplRows = (id) => structuredClone(TEMPLATES.find((t) => t.id === id).rows);
export const SAMPLES = [
{
key: "s1",
label: "① 측구터파기(토사)",
expect: { l: 3442, m: 832, e: 1049, sum: 5323 },
doc: {
name: "측구터파기(토사)",
unit: "㎥",
items: [
{
title: "기 계 ( 100 % ) : 굴삭기(무한궤도) (0.7㎥)",
lines: [
{ k: "formula", tpl: "excavator", rows: tplRows("excavator") },
{ k: "mach", res: { ...MACHINE[0] }, ops: [op("÷", "Q")] },
],
},
{
title: "보조인부 ( 1 일당 1 인 )",
lines: [{ k: "labor", res: { ...LABOR[0] }, ops: [op("÷", "8"), op("÷", "Q")] }],
},
],
},
},
{
key: "s41",
label: "② 합판거푸집(4회)",
expect: { l: 38290, m: 14506, e: 0, sum: 52796 },
doc: {
name: "합판거푸집(4회)",
unit: "㎡",
items: [
{
title: "",
lines: [
{ k: "text", text: "< 토목품셈 6-3-1 합판거푸집(2022개정) >" },
{ k: "text", text: "※ 사용횟수 4 회 : 보통" },
],
},
{
title: "재료비",
lines: [
{
k: "mat",
col: "m",
res: { ...MATERIAL[0] },
ops: [op("×", "1.03", "㎡"), op("×", "38", "%")],
},
{
k: "mat",
col: "m",
res: { ...MATERIAL[1] },
ops: [op("×", "0.038", "㎥"), op("×", "38", "%")],
},
{
k: "rate",
name: "소모자재 ( 박리재등 )",
of: "주자재비",
baseName: "주자재",
baseCol: "m",
scope: "item",
rate: "9",
col: "m",
},
],
},
{
title: "인건비 ( 24 년 품셈 개정 )",
lines: [
{ k: "labor", res: { ...LABOR[1] }, ops: [op("×", "5", "인"), op("/", "45", "㎡")] },
{ k: "labor", res: { ...LABOR[0] }, ops: [op("×", "2", "인"), op("/", "45", "㎡")] },
{
k: "rate",
name: "공구손료 및 경장비 기계경비",
of: "노무비",
baseName: "인부",
baseCol: "l",
scope: "item",
rate: "1",
col: "m",
},
],
},
],
},
},
{
key: "s15",
label: "③ 전석 메쌓기",
expect: { l: 74880, m: 7029, e: 12306, sum: 94215 },
doc: {
name: "전석 메쌓기",
unit: "㎡",
items: [
{ title: "전석 구입운반 : 별산", lines: [] },
{
title: "뒷채움돌 운반 및 채집 : 뒷채움돌기초잡석채집운반 (Φ150m/m) [㎡] * 단산 13 참조 *",
lines: [
{ k: "formula", tpl: "directQ", rows: [row("Q", "0.35")] },
{ k: "ref", res: { ...SANGEUN[0] }, ops: [op("×", "Q")] },
],
},
{
title: "뒤채움돌 다짐 기초다짐및뒷채움 (소형장비사용) [㎥] * 단산 28 참조 *",
lines: [
{ k: "formula", tpl: "directQ", rows: [row("Q", "0.35")] },
{ k: "ref", res: { ...SANGEUN[1] }, ops: [op("×", "Q")] },
],
},
{
title: "전석쌓기 기계: 굴삭기(무한궤도) (0.6㎥+부착용집게)",
lines: [
{ k: "formula", tpl: "directQ", rows: [row("Q", "0.36", "㎥ / hr")] },
{ k: "mach", res: { ...MACHINE[1] }, ops: [op("×", "Q")] },
],
},
{
title: "전석쌓기 인부",
lines: [
{ k: "labor", res: { ...LABOR[2] }, ops: [op("×", "0.14", "인")] },
{ k: "labor", res: { ...LABOR[0] }, ops: [op("×", "0.05", "인")] },
],
},
],
},
},
{
key: "new",
label: "④ 새 공식",
expect: null,
doc: {
name: "새 단가산출",
unit: "",
items: [{ title: "", lines: [{ k: "formula", tpl: null, rows: [row("", "")] }] }],
},
},
];
+306
View File
@@ -0,0 +1,306 @@
/* 시험 페이지 전용 — 본 프로그램 부품 상자 밖(폴더째 지움) */
:root {
--bg: #f6f7f9;
--panel: #fff;
--line: #d5d9e0;
--text: #1d232b;
--muted: #6b7380;
--accent: #2f6fde;
--bad: #d23b3b;
--ok: #1f8a4c;
--chip: #eef2f8;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #15181d;
--panel: #1e2228;
--line: #343a44;
--text: #e3e7ec;
--muted: #9aa3ae;
--accent: #6c9ef0;
--bad: #ff6b6b;
--ok: #4cc27f;
--chip: #2a3039;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font:
13px/1.45 "Malgun Gothic",
system-ui,
sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
height: 100vh;
}
header,
footer {
padding: 6px 10px;
background: var(--panel);
border-bottom: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
footer {
border-top: 1px solid var(--line);
border-bottom: 0;
}
h1 {
font-size: 15px;
margin: 0 8px 0 0;
}
.hint,
#msg,
#pasteMsg,
.note,
i {
color: var(--muted);
font-size: 12px;
}
main {
flex: 1;
display: grid;
grid-template-columns: minmax(420px, 46%) 1fr;
min-height: 0;
}
#edit,
#outWrap {
overflow: auto;
padding: 8px;
}
#edit {
border-right: 1px solid var(--line);
}
button,
select,
input,
textarea {
font: inherit;
color: inherit;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 4px;
padding: 2px 6px;
}
button {
cursor: pointer;
background: var(--chip);
}
button:hover {
border-color: var(--accent);
}
input.wide {
width: 260px;
}
input.num {
width: 80px;
text-align: right;
}
input.s {
width: 50px;
}
input.s2 {
width: 90px;
}
input.full,
input.title {
width: 100%;
}
input.title {
flex: 1;
font-weight: 600;
}
input.v {
width: 60px;
}
input.u {
width: 50px;
}
input.name {
width: 60px;
}
input.expr {
width: 220px;
font-family: Consolas, monospace;
}
.dochead,
.row,
.ih,
.lh,
.ops,
.frow,
.fhead {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
margin: 2px 0;
}
.item {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
padding: 6px;
margin: 6px 0;
}
.line {
border-left: 3px solid var(--accent);
background: var(--bg);
padding: 4px 6px;
margin: 4px 0 4px 8px;
border-radius: 3px;
}
.line.k-text {
border-left-color: var(--muted);
}
.line.k-formula {
border-left-color: #a46bd8;
}
.line.k-rate {
border-left-color: #d8973b;
}
.grip {
cursor: grab;
padding: 0 4px;
user-select: none;
}
.kind {
font-weight: 600;
}
.lh button {
margin-left: auto;
}
.op {
display: inline-flex;
gap: 2px;
padding: 1px 3px;
background: var(--chip);
border-radius: 4px;
}
.tpl {
font-weight: 600;
color: #a46bd8;
}
.val {
min-width: 50px;
font-family: Consolas, monospace;
}
.src {
background: var(--ok);
color: #fff;
border-radius: 3px;
padding: 0 4px;
font-size: 11px;
}
.empty {
color: var(--muted);
padding: 4px 8px;
}
.bad,
input.bad {
color: var(--bad);
border-color: var(--bad);
}
.ok {
color: var(--ok);
}
table.out {
border-collapse: collapse;
width: 100%;
background: var(--panel);
}
.out th,
.out td {
border: 1px solid var(--line);
padding: 2px 6px;
vertical-align: middle;
}
.out th {
background: var(--chip);
}
.out td.h {
min-width: 380px;
}
.out td.n {
text-align: right;
font-family: Consolas, monospace;
width: 90px;
}
.out tr.head td,
.out tr.item td.h {
font-weight: 600;
}
.out tr.item td.h {
padding-top: 6px;
}
.out tr.subsum td,
.out tr.total td {
background: var(--chip);
}
.gap {
display: inline-block;
width: 18px;
}
.fr {
white-space: nowrap;
display: inline-flex;
flex-direction: column;
vertical-align: middle;
text-align: center;
margin: 0 2px;
}
.fr > span:first-child {
border-bottom: 1px solid currentColor;
padding: 0 3px;
}
.fr > span:last-child {
padding: 0 3px;
}
#tot {
display: flex;
gap: 6px;
}
.box {
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 10px;
display: flex;
gap: 8px;
align-items: baseline;
}
.box b {
font-size: 15px;
}
.box.sum {
border-color: var(--accent);
}
details {
width: 100%;
}
textarea {
width: 100%;
font-family: Consolas, monospace;
}
dialog {
background: var(--panel);
color: var(--text);
border: 1px solid var(--line);
border-radius: 8px;
}
dialog table {
border-collapse: collapse;
margin: 6px 0;
}
dialog th,
dialog td {
border: 1px solid var(--line);
padding: 2px 6px;
}