From 3e64aa2a79bc0b613e967e0fb684f4aa1a5c04ef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 23:19:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(804-1):=20=EC=8B=9C=ED=97=98=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=E3=80=8C=EC=A4=84=20=EC=8C=93=EA=B8=B0=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=E3=80=8D=20tools/line-stac?= =?UTF-8?q?k-test=20+=20M01=20=EC=99=BC=EC=AA=BD=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = 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 --- M01_MasterData/M01_MasterData_UI_Side.ts | 8 +- main.py | 11 + tools/line-stack-test/app.js | 247 ++++++++++++++++++ tools/line-stack-test/calc.js | 182 ++++++++++++++ tools/line-stack-test/check.mjs | 11 + tools/line-stack-test/edit.js | 165 ++++++++++++ tools/line-stack-test/index.html | 43 ++++ tools/line-stack-test/paste.js | 257 +++++++++++++++++++ tools/line-stack-test/render.js | 181 ++++++++++++++ tools/line-stack-test/samples.js | 221 ++++++++++++++++ tools/line-stack-test/style.css | 306 +++++++++++++++++++++++ vite.config.ts | 2 + 12 files changed, 1633 insertions(+), 1 deletion(-) create mode 100644 tools/line-stack-test/app.js create mode 100644 tools/line-stack-test/calc.js create mode 100644 tools/line-stack-test/check.mjs create mode 100644 tools/line-stack-test/edit.js create mode 100644 tools/line-stack-test/index.html create mode 100644 tools/line-stack-test/paste.js create mode 100644 tools/line-stack-test/render.js create mode 100644 tools/line-stack-test/samples.js create mode 100644 tools/line-stack-test/style.css diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 7a83aa253..c46bd484d 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -517,9 +517,15 @@ ${first.file}` aliasSec .querySelector(".ui-collapsible__title") ?.addEventListener("click", () => void openAlias()); + // 시험 페이지 「줄 쌓기 단가산출」(PLAN 804-1) — 새 탭 · 지울 때 이 덩이 + main.py 마운트 + vite.config.ts 줄 + tools/line-stack-test 폴더 + const lineStackSec = section("시험: 줄 쌓기", null); + lineStackSec.classList.add("m01-side__leaf"); + lineStackSec + .querySelector(".ui-collapsible__title") + ?.addEventListener("click", () => window.open("/tools/line-stack-test/", "_blank")); const root = el("div", { className: "m01-side ui-sidebar-panel", - children: [...groups, logic, createDivider(), optionsSec, aliasSec], + children: [...groups, logic, createDivider(), optionsSec, aliasSec, lineStackSec], }); attachCollapsible(root); void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) => diff --git a/main.py b/main.py index 51e4b6363..58ff26c8b 100644 --- a/main.py +++ b/main.py @@ -411,6 +411,17 @@ app.mount( ) logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}") +# 시험 페이지 「줄 쌓기 단가산출」(PLAN 804-1) — 지울 때 이 덩이 + tools/line-stack-test 폴더 + vite.config.ts 「/tools」 줄 + M01 왼쪽 패널 「시험: 줄 쌓기」 덩이 +app.mount( + "/tools/line-stack-test", + _NoCacheHtmlStatic( + directory=str(Path(__file__).parent / "tools" / "line-stack-test"), + html=True, + check_dir=False, + ), + name="line-stack-test", +) + # ───────────────────────────────────────────────────────────────────────── # 기본 엔드포인트 # ───────────────────────────────────────────────────────────────────────── diff --git a/tools/line-stack-test/app.js b/tools/line-stack-test/app.js new file mode 100644 index 000000000..8453ae604 --- /dev/null +++ b/tools/line-stack-test/app.js @@ -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) => + `
${{ l: "노무비", m: "재료비", e: "경비" }[c]}${fmtP(t[c])}
`, + ) + .join("") + `
계${fmtP(t.sum)}
`; + 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)}) → ${same ? "같음" : "다름"}` + : ""; + 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) => ``, +).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 = + `${TABLE_E.head.map((h) => `${esc(h)}`).join("")}` + + TABLE_E.rows + .map( + (r) => + `${esc(r[0])}${r + .slice(1) + .map((v, c) => + v == null + ? "" + : ``, + ) + .join("")}`, + ) + .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(); diff --git a/tools/line-stack-test/calc.js b/tools/line-stack-test/calc.js new file mode 100644 index 000000000..47acde38e --- /dev/null +++ b/tools/line-stack-test/calc.js @@ -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; +} diff --git a/tools/line-stack-test/check.mjs b/tools/line-stack-test/check.mjs new file mode 100644 index 000000000..7fe0fd767 --- /dev/null +++ b/tools/line-stack-test/check.mjs @@ -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); diff --git a/tools/line-stack-test/edit.js b/tools/line-stack-test/edit.js new file mode 100644 index 000000000..d8f9cce74 --- /dev/null +++ b/tools/line-stack-test/edit.js @@ -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 = "") => + ``; +const sel = (p, v, opts) => + ``; +const pick = (list, act, label) => + ``; + +export function allTemplates(mine) { + return [...TEMPLATES, ...mine]; +} + +/** 「+ 줄」 고르개 — 공식은 틀마다 하나씩 */ +export function addLineSelect(i, mine) { + const tpls = allTemplates(mine) + .map((t) => ``) + .join(""); + return ``; +} + +function opsEdit(p, ops) { + const one = (o, k) => + `${sel(`${p}.ops.${k}.op`, o.op, { "×": "×", "÷": "÷", "/": "/" })}${inp( + `${p}.ops.${k}.v`, + o.v, + "v", + "값 · 이름", + )}${inp(`${p}.ops.${k}.u`, o.u, "u", "단위")}`; + return `
${(ops ?? []).map(one).join("")}
`; +} + +const copsNote = (ln) => + ln.cops + ? `
비목 따로 꼬리(붙여넣기): ${Object.entries(ln.cops) + .map( + ([c, ops]) => + `${COLSEL[c]} ${ops.map((o) => `${o.op} ${esc(o.v)} ${esc(o.u)}`).join(" ")}`, + ) + .join(" · ")}
` + : ""; + +/** 공식 줄 — 접힘 = 값 칸만 · 풀림 = 「이름 = 식」 글 줄 전부 */ +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 = `${rr.err ? esc(rr.err) : (rr.val ?? "")}`; + const src = row.src ? `표` : ""; + const table = ``; + if (!ln.open) { + if (plain || !row.x) + return `
${esc(row.n || "?")} = ${inp(`${rp}.x`, row.x, "num")} ${esc(row.u)} ${table}${src}
`; + return `
${esc(row.n)} = ${esc(row.x)} = ${val} ${esc(row.u)}
`; + } + return `
${inp(`${rp}.n`, row.n, "name", "이름")} = ${inp(`${rp}.x`, row.x, `expr ${rr.err ? "bad" : ""}`, "값 또는 식")}${inp( + `${rp}.u`, + row.u, + "u", + "단위", + )}${val}${table}${src}
`; + }) + .join(""); + return `
${label} + +
${rows} + ${ln.open ? `` : ""}`; +} + +/** 틀과 견줄 꼴 — 값 줄은 이름만 · 식 줄은 이름과 식 */ +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 = () => + `
노무 ${inp(`${p}.res.l`, ln.res?.l, "num")} 재료 ${inp(`${p}.res.m`, ln.res?.m, "num")} 경비 ${inp( + `${p}.res.e`, + ln.res?.e, + "num", + )}
`; + switch (ln.k) { + case "mach": + return `
${pick(MACHINE, `res:mach:${p}`, "견본 기계 ▾")}${inp(`${p}.res.name`, ln.res?.name, "wide", "기계 이름")}
${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`; + case "ref": + return `
${pick(SANGEUN, `res:ref:${p}`, "견본 단산 ▾")} 단산 ${inp(`${p}.res.no`, ln.res?.no, "num s")}${inp( + `${p}.res.name`, + ln.res?.name, + "wide", + "단가산출 이름", + )}
${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`; + case "labor": + case "mat": + return `
${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)}` : ""}
${opsEdit(p, ln.ops)}`; + case "rate": + return `
${inp(`${p}.name`, ln.name, "wide", "이름(예: 공구손료 및 경장비)")}
+
기준 ${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)}
+
글 「( ${inp(`${p}.of`, ln.of, "s2", "노무비")}의 % )」 · 소계 이름 ${inp(`${p}.baseName`, ln.baseName, "s2", "비우면 안 찍음")}
`; + 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 `
⠿${KINDS[ln.k]}${ + r?.err ? `${esc(r.err)}` : "" + }
${lineBody(p, ln, r, mine)}
`; + }) + .join(""); + return `
${inp(`items.${i}.title`, item.title, "title", "항목 이름(비우면 번호 없음)")} + ${addLineSelect(i, mine)} +
${lines || '
줄 없음 — [+ 줄 ▾]
'}
`; + }) + .join(""); + return `
이름 ${inp("name", doc.name, "wide")} 단위 ${inp("unit", doc.unit, "s")}
${items}`; +} diff --git a/tools/line-stack-test/index.html b/tools/line-stack-test/index.html new file mode 100644 index 000000000..9399c3535 --- /dev/null +++ b/tools/line-stack-test/index.html @@ -0,0 +1,43 @@ + + + + + + 시험: 줄 쌓기 단가산출 + + + + +
+

시험: 줄 쌓기 단가산출

+ 서버 없음 · 저장은 이 브라우저에만 · 마음에 안 들면 폴더째 지움 + + +
+
+
+
+
+
+
+
+
+ + 엑셀 글 붙여넣기 — 경쟁사 「단가산출」 시트 한 블록(머리 줄부터 「계 :」 줄까지) + + + +
+
+ +

+
+ +
+ + + diff --git a/tools/line-stack-test/paste.js b/tools/line-stack-test/paste.js new file mode 100644 index 000000000..8118e1b6f --- /dev/null +++ b/tools/line-stack-test/paste.js @@ -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 }; +} diff --git a/tools/line-stack-test/render.js b/tools/line-stack-test/render.js new file mode 100644 index 000000000..9335d4667 --- /dev/null +++ b/tools/line-stack-test/render.js @@ -0,0 +1,181 @@ +// 줄 쌓기 단가산출 시험 — 산출근거 출력(경쟁사 「단가산출」 시트 꼴 · 분수는 CSS 로 조판) +import { COLS, COL_NAME, parse, varsIn } from "./calc.js"; + +export const esc = (s) => + String(s ?? "").replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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]) + : `${esc(ast.n)}`; + case "par": + return `( ${go(ast.a)} )`; + case "neg": + return `-${go(ast.a)}`; + } + if (ast.op === "/" && frac) + return `${bare(ast.a)}${bare(ast.b)}`; + 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 = `${esc(o.v)}`; + } + 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('') }), (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)} ${esc(rr?.err ?? "")}` }); + } + 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: `${esc(r.err)}`, 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 ""; + return `${r.won ? fmtP(v) : fmt1(v)}`; + }; + const body = outRows(doc, res) + .map( + (r) => + `${r.h}${["sum", ...COLS].map((k) => cell(r, k)).join("")}`, + ) + .join(""); + return `${body}
산 출 근 거합 계노무비재료비경 비
`; +} diff --git a/tools/line-stack-test/samples.js b/tools/line-stack-test/samples.js new file mode 100644 index 000000000..4e5d9eb69 --- /dev/null +++ b/tools/line-stack-test/samples.js @@ -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("", "")] }] }], + }, + }, +]; diff --git a/tools/line-stack-test/style.css b/tools/line-stack-test/style.css new file mode 100644 index 000000000..16d1737c9 --- /dev/null +++ b/tools/line-stack-test/style.css @@ -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; +} diff --git a/vite.config.ts b/vite.config.ts index c185bb425..13b980d05 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -60,6 +60,8 @@ export default { target: apiTarget, changeOrigin: true, }, + // 시험 페이지 「줄 쌓기 단가산출」(PLAN 804-1) — 폴더 지울 때 이 줄도 + "/tools/line-stack-test": { target: apiTarget, changeOrigin: true }, }, }, // 빌드판(555-2 · `tools/preview_up.py`) — 폰 · 외부 접속용 · 포트 = 개발 포트 − 1000(5173 → 4173)