diff --git a/resources/tester/helper_stmate_calc.cjs b/resources/tester/helper_stmate_calc.cjs index 2efc2eec5..8f4d89eed 100644 --- a/resources/tester/helper_stmate_calc.cjs +++ b/resources/tester/helper_stmate_calc.cjs @@ -1,6 +1,7 @@ // STMATE 재현 앱 calc.js 를 상태 JSON(stdin)으로 돌려 차수별 원가계산서 금액을 냄 const C = require("../../tools/stmate-test/calc.js"); let buf = ""; +process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { const res = C.calculateAll(JSON.parse(buf)); diff --git a/resources/tester/helper_stmate_cascade.cjs b/resources/tester/helper_stmate_cascade.cjs index e89a425de..b45e14a14 100644 --- a/resources/tester/helper_stmate_cascade.cjs +++ b/resources/tester/helper_stmate_cascade.cjs @@ -5,6 +5,7 @@ require("../../tools/stmate-test/smt_eval.js"); require("../../tools/stmate-test/smt_engine.js"); require("../../tools/stmate-test/view_prj_boq.js"); let buf = ""; +process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { const d = JSON.parse(buf); diff --git a/resources/tester/helper_stmate_file_payload.cjs b/resources/tester/helper_stmate_file_payload.cjs index 321437232..eb379077c 100644 --- a/resources/tester/helper_stmate_file_payload.cjs +++ b/resources/tester/helper_stmate_file_payload.cjs @@ -4,6 +4,7 @@ globalThis.window = globalThis; const Calc = require("../../tools/stmate-test/calc.js"); require("../../tools/stmate-test/view_prj_boq.js"); let buf = ""; +process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { const { load, edit } = JSON.parse(buf); diff --git a/resources/tester/helper_stmate_ilwi.cjs b/resources/tester/helper_stmate_ilwi.cjs index 6952f239f..04f7c165b 100644 --- a/resources/tester/helper_stmate_ilwi.cjs +++ b/resources/tester/helper_stmate_ilwi.cjs @@ -1,6 +1,7 @@ // STMATE 재현 앱 일위대가(B)·중기(X) 조립 — stdin {costn_rows, bdqty_rows, start_ini} const E = require("../../tools/stmate-test/smt_eval.js"); let buf = ""; +process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { const d = JSON.parse(buf); diff --git a/resources/tester/helper_stmate_kanjub.cjs b/resources/tester/helper_stmate_kanjub.cjs new file mode 100644 index 000000000..d04b291fc --- /dev/null +++ b/resources/tester/helper_stmate_kanjub.cjs @@ -0,0 +1,26 @@ +// STMATE 재현 앱 원가계산서 단독 계산 — [{boq, cfg, prev(앞 사례 번호), pclass}] (stdin) → 사례별 결과 요약 +const C = require("../../tools/stmate-test/calc.js"); +const kj = require("../../tools/stmate-test/kanjub_calc.js")(C); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const raw = []; + const out = JSON.parse(buf).map((c) => { + const data = { projectMeta: { pclass: c.pclass ?? 0 } }; + const r = kj(data, c.boq, c.prev != null ? raw[c.prev] : null, c.cfg); + raw.push(r); + const items = Object.fromEntries(Object.values(r.itemMap).map((x) => [x.code, x.amount])); + const pick = + "dM dL dS dT kanno totalLabor totalExpense pureConstructionCost ilbanAmt iyunRaw iyunFinal pretaxProfitAdj supplyFinal vatFinal contractFinal govFinal govK grandTotal knSa kongsaJ anjunBaseRaw"; + const o = Object.fromEntries(pick.split(" ").map((k) => [k, r[k]])); + return Object.assign(o, { + items, + keys: r.keysOut, + order: r.items.map((x) => x.code), + anjun: r.anjun, + lines: r.lines.map((l) => [l.key, l.amount]), + }); + }); + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/helper_stmate_kanjub_keys.cjs b/resources/tester/helper_stmate_kanjub_keys.cjs new file mode 100644 index 000000000..df3fcf306 --- /dev/null +++ b/resources/tester/helper_stmate_kanjub_keys.cjs @@ -0,0 +1,20 @@ +// STMATE 재현 앱 원가계산서 — 상태 JSON(stdin) → 차수별 계산 결과 저장키(keysOut · C_/S_/I_/B_)와 식 템플릿 검산 +const C = require("../../tools/stmate-test/calc.js"); +const F = require("../../tools/stmate-test/kanjub_calc.js"); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const res = C.calculateAll(JSON.parse(buf)); + const kj = F(C); + const out = {}; + for (const [n, st] of Object.entries(res.stages)) { + const k = st.kanjub; + // 식 템플릿(25번 §2)으로 다시 셈 — 율 항목 후보와 같아야 함 + const tpl = {}; + for (const it of Object.values(k.itemMap)) + if (it.tpl && it.tplVars) tpl[it.code] = [kj.evalTpl(it.tpl, it.tplVars), it.cand]; + out[n] = { keys: k.keysOut, tpl, order: k.items.map((x) => x.code) }; + } + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/helper_stmate_kanjub_rate.cjs b/resources/tester/helper_stmate_kanjub_rate.cjs new file mode 100644 index 000000000..af10fe2d0 --- /dev/null +++ b/resources/tester/helper_stmate_kanjub_rate.cjs @@ -0,0 +1,9 @@ +// STMATE 재현 앱 요율 조회(scr_kanjub_rate.js) — [{matrix, opts}] (stdin) → 조회 결과 목록 +const R = require("../../tools/stmate-test/scr_kanjub_rate.js"); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const out = JSON.parse(buf).map(({ matrix, opts }) => R.lookup(R.matrixFromRows(matrix), opts)); + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/helper_stmate_qty_run.cjs b/resources/tester/helper_stmate_qty_run.cjs new file mode 100644 index 000000000..8618dfb04 --- /dev/null +++ b/resources/tester/helper_stmate_qty_run.cjs @@ -0,0 +1,99 @@ +// STMATE 재현 앱 식 언어·수량산출서 순수 계산 호출 — stdin {calls:[{fn, args}]} → stdout [결과] +// evaluate·check 의 lookup = 단가표 {코드: {M,L,S,unit}} · qty = 수량코드 행([{CODE, QTY0~9, XCODE}]) +globalThis.window = globalThis; +const T = "../../tools/stmate-test/"; +// smt_engine 이 쓰는 수치 도우미만(calc.js 원가 계산 묶음과 떨어뜨림) +globalThis.STMATE_CALC = globalThis.STMATE_CALC || { + num: (v, d = 0) => { + const n = parseFloat(String(v ?? "").replace(/,/g, "")); + return Number.isFinite(n) ? n : d; + }, + fmt: (v) => String(v), +}; +const E = require(T + "smt_eval.js"); +const X = require(T + "scr_edit_expr.js"); +const L = require(T + "scr_edit_lang.js"); +const Q = require(T + "scr_qty_sheet.js"); +const PR = require(T + "scr_edit_price.js"); +require(T + "smt_engine.js"); +const ENG = globalThis.STMATE_ENGINE; + +const lookupOf = (table) => (c) => (table && table[c] ? table[c] : null); +const qtyOf = (rows) => ENG.qtyResolver({ dbfData: { qcode_rows: rows || [] } }); + +const FN = { + expr: (src, vars) => X.evaluate(src, vars || {}), + evaluate: (text, table, opts = {}) => { + const o = { ...opts, qty: opts.qcode ? qtyOf(opts.qcode) : undefined }; + const ev = E.evaluate(text, lookupOf(table), o); + return { + M: ev.M, + L: ev.L, + S: ev.S, + T: ev.T, + vars: ev.vars, + labels: ev.labels, + errors: ev.errors, + lines: ev.lines, + }; + }, + composition: (rows, table, opts = {}) => { + const o = { ...opts }; + if (o.laborMap) o.labor = new Map(Object.entries(o.laborMap)); + return E.evaluateComposition(rows, lookupOf(table), o); + }, + devd: (q, unit) => E.devdToOrdinary(q, unit), + normQCode: (c) => E.normQCode(c), + codeLines: (code, row, opts) => L.codeLines(code, row, opts), + stripResults: (t) => L.stripResults(t), + marker: (lv, ex) => L.marker(lv, ex), + laborMap: (ini, sel, fb) => Object.fromEntries(ENG.laborMap(ini, sel, fb)), + cycles: (graph) => ENG.findCycles((c) => graph[c] || [], new Set(Object.keys(graph))), + smtAll: (load) => { + // 실무 STC 의 산근 전부 — 오류 목록과 금액(금액결과 삭제 뒤 금액 포함) + const ctx = { dbfData: load, uiState: {}, state: load.sim_state }; + const out = {}; + for (const [code, m] of Object.entries(load.costn_memos || {})) { + const a = ENG.evaluateSmtFormulaText(m.text, ctx, code); + const b = ENG.evaluateSmtFormulaText(L.stripResults(m.text), ctx, code); + out[code] = { + errors: a.errors, + sum: [a.sumM, a.sumL, a.sumS], + stripped: [b.sumM, b.sumL, b.sumS], + }; + } + return out; + }, + cascadeCycles: (load) => { + globalThis.STMATE_VIEW_PRJ_BOQ = globalThis.STMATE_VIEW_PRJ_BOQ || { + syncCostnUnitPrice: () => {}, + }; + const ctx = { dbfData: load, uiState: {}, state: load.sim_state }; + return ENG.cascadeRecalculateAll(ctx).cycles; + }, + adopt: (rows, opts) => PR.adoptCandidates(rows, opts), + evalSheet: (sheet) => Q.evalSheet(sheet), + assignFromSheet: (rows, sheet) => Q.assignFromSheet(rows, sheet), + sheetToBoq: (sheet, mode) => Q.sheetToBoq(sheet, mode), + checkQcodes: (rows, codes) => Q.checkQcodes(rows, codes), + nextQcode: (rows) => Q.nextQcode(rows), + qtyByCode: (rows, kind, field) => Q.qtyByCode(rows, kind, field), + rollup: (boq, bd, field) => Q.rollup(boq, bd, field), + roundTrip: (sheet, rows) => Q.deserialize(Q.serialize(sheet, rows)), + deserialize: (t) => Q.deserialize(t), +}; + +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const { calls } = JSON.parse(buf); + const out = calls.map(({ fn, args }) => { + try { + return FN[fn](...(args || [])); + } catch (e) { + return { __error: String(e && e.stack ? e.stack : e) }; + } + }); + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/helper_stmate_report.cjs b/resources/tester/helper_stmate_report.cjs index 39081e778..f0ba10d13 100644 --- a/resources/tester/helper_stmate_report.cjs +++ b/resources/tester/helper_stmate_report.cjs @@ -1,5 +1,6 @@ -// STMATE 재현 앱 보고서 묶음 — stdin = { load: /api/load 결과, stage } · 연쇄 재계산(계산완료) 뒤 시트 모형 +// STMATE 재현 앱 보고서 묶음 — stdin = { load: /api/load 결과, stage, mode(pps = 조달청 XML) } · 연쇄 재계산(계산완료) 뒤 시트 모형 globalThis.window = globalThis; +globalThis.document = globalThis.document || { readyState: "complete" }; // 노드 — 화면 전용 분기 막음 require("../../tools/stmate-test/calc.js"); require("../../tools/stmate-test/smt_eval.js"); require("../../tools/stmate-test/smt_engine.js"); @@ -8,13 +9,20 @@ require("../../tools/stmate-test/report_core.js"); require("../../tools/stmate-test/report_unit.js"); require("../../tools/stmate-test/report_boq.js"); const Book = require("../../tools/stmate-test/report_book.js"); +const Pps = require("../../tools/stmate-test/report_pps.js"); let buf = ""; +process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { - const { load, stage } = JSON.parse(buf); + const { load, stage, mode } = JSON.parse(buf); const ctx = { dbfData: load, state: load.sim_state, uiState: {} }; window.STMATE_ENGINE.cascadeRecalculateAll(ctx); const computed = window.STMATE_CALC.calculateAll(ctx.state); + if (mode === "pps") { + const xml = Pps.buildPpsXml(load, ctx.state, computed, stage || 1, { today: "2026-01-01" }); + process.stdout.write(JSON.stringify({ xml })); + return; + } const book = Book.buildBook(load, ctx.state, computed, stage || 1); const out = {}; for (const s of book.sheets) out[s.id || s.name] = { name: s.name, rows: s.rows }; diff --git a/resources/tester/helper_stmate_smt.cjs b/resources/tester/helper_stmate_smt.cjs index 60bb7e90a..6897623ef 100644 --- a/resources/tester/helper_stmate_smt.cjs +++ b/resources/tester/helper_stmate_smt.cjs @@ -4,6 +4,7 @@ require("../../tools/stmate-test/calc.js"); require("../../tools/stmate-test/smt_eval.js"); require("../../tools/stmate-test/smt_engine.js"); let buf = ""; +process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { const d = JSON.parse(buf); diff --git a/resources/tester/helper_stmate_stage.cjs b/resources/tester/helper_stmate_stage.cjs index 203eacf5e..e7a3b51e7 100644 --- a/resources/tester/helper_stmate_stage.cjs +++ b/resources/tester/helper_stmate_stage.cjs @@ -94,6 +94,7 @@ const MODES = { }; let buf = ""; +process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (buf += d)); process.stdin.on("end", () => { const inp = JSON.parse(buf); diff --git a/resources/tester/helper_stmate_ui.cjs b/resources/tester/helper_stmate_ui.cjs new file mode 100644 index 000000000..11eac218e --- /dev/null +++ b/resources/tester/helper_stmate_ui.cjs @@ -0,0 +1,292 @@ +// STMATE 재현 앱 화면 편집 순수 계산(ui_boq · ui_boq_tools · ui_sys · ui_dan · ui_menu) — stdin = /api/load 결과 +// 결과 = 장면별 수치(내역 직공비 증감 · 짝 구조 · 공종번호 · 초기값 왕복 · 사용처 · 단축키) +globalThis.window = globalThis; +const D = "../../tools/stmate-test/"; +[ + "kanjub_parts.js", + "kanjub_calc.js", + "calc.js", + "smt_eval.js", + "smt_engine.js", + "view_prj_boq.js", +].forEach((f) => require(D + f)); +const B = require(D + "ui_boq.js"); +const T = require(D + "ui_boq_tools.js"); +const S = require(D + "ui_sys.js"); +const U = require(D + "ui_dan.js"); +const M = require(D + "ui_menu.js"); +const Calc = window.STMATE_CALC; + +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (x) => (buf += x)); +process.stdin.on("end", () => { + const src = JSON.parse(buf); + const fresh = () => JSON.parse(JSON.stringify(src)); + const out = {}; + const ctxOf = (d) => ({ dbfData: d, state: d.sim_state, uiState: {} }); + const modelOf = (d) => ( + (d.boq_deleted = []), + { rows: d.boq_rows, items: d.sim_state.boqItems, deleted: d.boq_deleted } + ); + const direct = (d) => Calc.computeAll(d.sim_state).boqComputed.totals.directT; + const codes = (d) => d.boq_rows.map((r) => `${r.SORTCODE}|${r.ENDCODE}|${r.BDCODE1}`).join(","); + const cbc = (d) => Object.fromEntries(d.costn_rows.map((r) => [String(r.CODE).trim(), r])); + + // 1. 원본 짝 구조 — 다시 매김 무변화 · 겹침 정상 · 계산 항목 수 + { + const d = fresh(), + m = modelOf(d), + before = codes(d), + n0 = m.items.length; + B.renumber(m.rows, B.linkPairs(m.rows)); + B.syncItems(m); + out.structure = { + same: codes(d) === before, + nest: B.validNesting(m.rows, B.linkPairs(m.rows)), + items: [n0, m.items.length], + }; + } + + // 2. 행 넣기·지우기 — 직공비 증감 = 새 행 금액 · 지우면 원래대로 + { + const d = fresh(), + m = modelOf(d), + t0 = direct(d); + const leaf = d.boq_rows.find((r) => B.isLeaf(r) && Calc.num(r.T_AMT1) > 0); + const row = B.makeRow(m, { BDCODE1: B.codeOf(leaf), NAME1: leaf.NAME1, QTY1: "7" }); + const at = d.boq_rows.indexOf(leaf); + const ins = B.insertRows(m, at, [row]); + const comp = Calc.computeAll(d.sim_state); + const nr = comp.boqComputed.rows.find((r) => r._recno === row._recno); + const t1 = comp.boqComputed.totals.directT; + const del = B.deleteRows(m, [row._recno]); + out.insert = { + ok: ins.ok && del.ok, + delta: t1 - t0, + rowT: nr ? nr.tAmt : null, + back: direct(d) - t0, + deleted: d.boq_deleted.slice(), + newSort: row.SORTCODE, + }; + const del2 = B.deleteRows(m, [leaf._recno]); + out.deleteOrig = { + ok: del2.ok, + delta: direct(d) - t0, + leafT: Calc.num(leaf.T_AMT1), + deleted: d.boq_deleted.slice(), + recno: leaf._recno, + }; + } + + // 3. 이동 — 위·아래 왕복 뒤 행 차례 원래대로 · 짝 엇갈림 거부 + { + const d = fresh(), + m = modelOf(d); + const order0 = d.boq_rows.map((r) => r._recno).join(","); + const leaf = d.boq_rows.find((r, i) => i > 0 && B.isLeaf(r) && B.isLeaf(d.boq_rows[i - 1])); + const up = B.moveRows(m, [leaf._recno], -1), + dn = B.moveRows(m, [leaf._recno], 1); + const head = d.boq_rows.find((r) => B.rowKind(r).kind === "T" && B.rowKind(r).n <= 2); + const bad = B.moveRows(m, [head._recno], -1); + out.move = { + up: up.ok, + down: dn.ok, + back: d.boq_rows.map((r) => r._recno).join(",") === order0, + badRejected: !bad.ok, + nest: B.validNesting(m.rows, B.linkPairs(m.rows)), + }; + } + + // 4. 계표시 감싸기·레벨·주석 → 계시작 — 새 계 행 합계 = 감싼 단가코드 금액 합 + { + const d = fresh(), + m = modelOf(d), + ini = d.start_ini; + const ix = d.boq_rows.map((r, i) => (B.isLeaf(r) ? i : -1)).filter((i) => i >= 0); + let a = ix.find((i) => B.isLeaf(d.boq_rows[i + 1]) && B.isLeaf(d.boq_rows[i + 2])); + const w = B.wrapGroup(m, a + 1, a + 2, 1, "시험 묶음", ini); + const ctx = ctxOf(d); + ctx.computed = Calc.computeAll(d.sim_state); + const ev = window.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx); + const hi = ev.findIndex((r) => r.NAME1 === "시험 묶음"); + const sumLeaf = Calc.num(ev[hi + 1].T_AMT1) + Calc.num(ev[hi + 2].T_AMT1); + const head = d.boq_rows[hi]; + const sh = B.shiftLevel(m, head._recno, 2, ini); + const tail = B.linkPairs(d.boq_rows).get(head); + const note = B.makeRow(m, { NAME1: "주석 시험" }); + B.insertRows(m, d.boq_rows.length - 1, [note]); + const n2g = B.noteToGroup(m, note._recno, 1, ini); + out.group = { + wrap: w.ok, + eTotal: Calc.num(ev[hi + 3].T_AMT1), + sumLeaf, + eName: ev[hi + 3].ITNUM, + shift: sh.ok && B.codeOf(head) === "T3" && B.codeOf(tail) === "E3", + shiftName: tail.ITNUM, + note: n2g.ok && B.codeOf(note) === "T1", + nest: B.validNesting(m.rows, B.linkPairs(m.rows)), + }; + } + + // 5. 복사·붙여넣기 — 묶음 통째(짝 포함) · 새 번호 + { + const d = fresh(), + m = modelOf(d); + const head = d.boq_rows.find( + (r) => B.rowKind(r).kind === "T" && B.rowKind(r).n <= 2 && B.linkPairs(d.boq_rows).get(r), + ); + const clip = B.copyRows(m, [head._recno]); + const n0 = d.boq_rows.length, + t0 = direct(d); + const recs = new Set(clip.rows.filter(B.isLeaf).map((r) => r._recno)); + const srcT = Calc.computeAll(d.sim_state) + .boqComputed.rows.filter((r) => recs.has(r._recno) && r.rowType === "normal") + .reduce((a, r) => a + r.tAmt, 0); + const at = d.boq_rows.indexOf(B.linkPairs(d.boq_rows).get(head)) + 1; // 원래 묶음 바로 뒤(같은 구역) + const p = B.pasteRows(m, at, clip); + const leafSum = clip.rows.filter(B.isLeaf).length; + out.paste = { + ok: p.ok, + added: d.boq_rows.length - n0, + clipRows: clip.rows.length, + pairs: clip.pairs.length, + leaves: leafSum, + srcT, + delta: direct(d) - t0, + nest: B.validNesting(m.rows, B.linkPairs(m.rows)), + }; + } + + // 6. 일위대가 풀기 — 구성 코드·수량 = 구성표 × 내역 수량 + { + const d = fresh(), + m = modelOf(d); + const row = d.boq_rows.find( + (r) => + B.codeOf(r)[0] === "B" && d.bdqty_rows.some((q) => String(q.BDCODE).trim() === B.codeOf(r)), + ); + if (row) { + const Q = Calc.num(row.QTY1), + code = B.codeOf(row); + const res = T.expandIlwi( + m, + row._recno, + d.bdqty_rows, + cbc(d), + { level: 1, mode: "trunc", digits: 4 }, + d.start_ini, + ); + const at = d.boq_rows.findIndex((r) => r.NAME1 === row.NAME1 && B.codeOf(r) === "T1"); + const made = d.boq_rows + .slice(at + 1, at + 1 + res.count) + .map((r) => [B.codeOf(r), Calc.num(r.QTY1)]); + const want = d.bdqty_rows + .filter((q) => String(q.BDCODE).trim() === code) + .map((q) => [String(q.CODE).trim(), Math.trunc(Calc.num(q.QTY1) * Q * 1e4 + 1e-6) / 1e4]) + .filter(([c]) => cbc(d)[c] && !/%$/.test(String(cbc(d)[c].UNIT || "").trim())); + out.expand = { + ok: res.ok, + code, + made, + want, + deleted: d.boq_deleted.includes(row._recno), + closing: B.codeOf(d.boq_rows[at + 1 + res.count]), + }; + } + } + + // 7. 공종번호 자동입력 — 원본 공종번호를 지우고 다시 매김 + { + const d = fresh(); + const keep = d.boq_rows.map((r) => r.ITNUM); + const plan = src.itnum_plan || []; + plan.forEach((o) => + d.boq_rows.forEach( + (r) => B.rowKind(r).kind === "T" && B.rowKind(r).n === o.level && (r.ITNUM = ""), + ), + ); + plan.forEach((o) => T.autoItnum(d.boq_rows, o)); + out.itnum = plan.map((o) => { + const idx = d.boq_rows + .map((r, i) => (B.rowKind(r).kind === "T" && B.rowKind(r).n === o.level ? i : -1)) + .filter((i) => i >= 0); + return { + level: o.level, + got: idx.map((i) => d.boq_rows[i].ITNUM), + want: idx.map((i) => keep[i]), + }; + }); + } + + // 8. 수량 단수 보정 · 할증 기록 + { + const d = fresh(), + m = modelOf(d); + const leaf = d.boq_rows.find(B.isLeaf); + B.setCell(m, leaf._recno, "QTY1", 12.3456); + const r1 = T.roundQty(m, [leaf._recno], "trunc", 2); + const it = m.items.find((x) => x._recno === leaf._recno); + const q1 = [it.qty, it.qtys[0], Calc.num(leaf.QTY1)]; + const r2 = T.roundQty(m, [leaf._recno], "round", 0); + const hal = T.applyHalRate(m, [leaf._recno], { M: 3, L: 0, S: 0 }, "each"); + out.qty = { + first: [r1.changed, ...q1], + second: [r2.changed, Calc.num(leaf.QTY1)], + hal: [hal.changed, leaf.RM, it.halRate.M], + }; + } + + // 9. 초기값 — 원본 그대로 왕복 무변화 · 계산 원천 맞춤 · 호환규정 일괄적용 + { + const ini = src.start_ini || {}; + const dr = S.draftFromIni(ini); + const copy = Object.assign({}, ini); + const sc = S.sysConfigFromIni(ini, {}); + const x = S.xmlPreset(S.draftFromIni(ini)); + out.sys = { + changed: S.applyDraft(copy, dr), + errs: S.validate(dr), + cond: sc.condCodes, + conld: ini.CONLD || "", + rx: sc.rxFormula, + chn: sc.chnMode, + xml: [x.CUTNM, x.ZCDNM, x.EXHNM1 === x.EXLNM1], + }; + } + + // 10. 단가목록 — 사용처 · 코드종류 수 · 기준단가 수정 두 번 같은 값 + { + const d = fresh(); + const leaf = d.boq_rows.find(B.isLeaf); + const f = U.findUse(B.codeOf(leaf), d); + const rows = JSON.parse(JSON.stringify(d.costn_rows)); + const withCand = rows + .filter( + (r) => + U.isBase(U.codeOf(r)) && + U.adoptedSlot(r) >= 1 && + U.candidates(r)[U.adoptedSlot(r) - 1] > 0, + ) + .map(U.codeOf); + const a = U.edtHal(rows, { codes: withCand, pct: 110 }); + const b = U.edtHal(rows, { codes: withCand, pct: 110 }); + const c = U.edtHal(rows, { codes: withCand, pct: 100 }); + out.dan = { + useBoq: f.boq.map((x) => x.recno), + leafRecno: leaf._recno, + counts: U.counts(d.costn_rows), + total: d.costn_rows.length, + halFirst: a.length, + halSecond: b.length, + halBack: c.length, + }; + } + + out.menu = { + sc: M.shortcutMap(), + acts: M.allActs(), + dec: [120, 16449, 24656, 32849, 57382].map(M.decodeShortcut), + }; + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/test_stmate_kanjub_opts.py b/resources/tester/test_stmate_kanjub_opts.py new file mode 100644 index 000000000..216d220bf --- /dev/null +++ b/resources/tester/test_stmate_kanjub_opts.py @@ -0,0 +1,346 @@ +"""STMATE 재현 앱 원가계산서 선택축 — 형식 4벌 · 절사 자리 · 부가세 5택 · 관급 절상/반올림/절사 · 사급 위치 4택 +· 도급 절사 · 공급가액 절사(이윤 차감) · 총액 절사 · 추가금액 4묶음 · 안전관리비 3모드 · 공사보험 8갈래 · 고용개선 · 시공도작성비 +· 간접비 순서 코드표 · 변경 차수 보험료. + +기대값은 지식DB REA 18·20 정적 규칙(네이티브 식)을 이 파일에서 따로 손셈한 것 — 실무 표본이 없는 선택지의 지킴 시험. +""" + +import json +import math +import subprocess +from pathlib import Path + +HELPER = Path(__file__).with_name("helper_stmate_kanjub.cjs") +BOQ = {"directM": 100_000_000, "directL": 200_000_000, "directS": 50_000_000} +RATES = { + "KANNO": 14.5, + "SANBO": 3.56, + "KOBO": 1.01, + "KUNBO": 3.545, + "SLVBO": 12.95, + "YUNBO": 4.5, + "TBUGUM": 0, + "ANJUN": 1.86, + "ANJUN1": 1.86, + "ANJUN2": 1.86, + "KITA": 6, + "HWAN": 0.8, + "EQIP": 0.4, + "ILBAN": 6, + "IYUN": 15, +} +BASE_KEYS = { + "JAB_TYPE": 0, + "CAL_ACTL": 0, + "I_TOT_JEOL": 0, + "I_BUGA": 0, + "I_KWN_JEOLSA": 3, + "I_KWN_UPDN": 0, + "I_ANJUN_K": 1, + "I_ANJUNAUTO": 1, + "I_POS_SA": 3, + "C_ANJUN_G": 5_349_000, + "C_ANJUN1_G": 5_349_000, + "C_ANJUN2_G": 5_349_000, + "C_KWANGUP_D": 110_000_000, + "C_T_JODAL": 1_000_000, +} +td = lambda v, d=0: math.trunc(v / 10**d + (1e-9 if v >= 0 else -1e-9)) * 10**d # noqa: E731 +tu = lambda v, d=0: math.ceil(v / 10**d - 1e-9) * 10**d # noqa: E731 + + +def cfg(rates=None, **keys): + return { + "slot": 1, + "keys": BASE_KEYS | keys, + "rates": RATES | (rates or {}), + "addRows": keys.pop("addRows", None) or [], + } + + +def run(cases): + return json.loads( + subprocess.check_output( + ["node", str(HELPER)], input=json.dumps(cases), text=True, encoding="utf-8" + ) + ) + + +def ref_t(M=100e6, L=200e6, S=50e6, J=0, gov=110e6, jodal=1e6, rates=RATES): + """일반형식 손셈 — 순서·밑수·절사(REA 18·20).""" + r = rates + kanno = td(L * r["KANNO"] / 100, J) + Lp = L + kanno + sanbo, kobo = td(Lp * r["SANBO"] / 100, J), td(Lp * r["KOBO"] / 100, J) + kunbo = td(L * r["KUNBO"] / 100, J) + slvbo, yunbo = td(kunbo * r["SLVBO"] / 100, J), td(L * r["YUNBO"] / 100, J) + kn = (gov - jodal) / 1.1 + c190, c198 = ( + td((M + L) * r["ANJUN"] / 100 + 5_349_000), + td((M + L + kn) * r["ANJUN"] / 100 + 5_349_000), + ) + anjun = td(1.2 * c190, J) if 1.2 * c190 < c198 else td(c198, J) + kita = td((M + L + kanno) * r["KITA"] / 100, J) + T = M + L + S + hwan, eqip = td(T * r["HWAN"] / 100, J), td(T * r["EQIP"] / 100, J) + exp = S + sanbo + kobo + kunbo + slvbo + yunbo + anjun + kita + hwan + eqip + pure = M + Lp + exp + ilban = td(pure * r["ILBAN"] / 100, J) + iyun = td((pure + ilban - M) * r["IYUN"] / 100) + supply = pure + ilban + iyun + vat = td(supply * 0.1) + govf = tu(gov, 3) + return dict( + kanno=kanno, + anjun=anjun, + kita=kita, + pure=pure, + ilban=ilban, + iyun=iyun, + supply=supply, + vat=vat, + gov=govf, + grand=supply + vat + govf, + ) + + +def test_general_scheme_order_and_bases(): + (r,) = run([{"boq": BOQ, "cfg": cfg()}]) + e = ref_t() + assert ( + r["kanno"] == e["kanno"] + and r["items"]["ANJUN"] == e["anjun"] + and r["items"]["KITA"] == e["kita"] + ) + assert ( + r["pureConstructionCost"] == e["pure"] + and r["ilbanAmt"] == e["ilban"] + and r["iyunFinal"] == e["iyun"] + ) + assert (r["supplyFinal"], r["vatFinal"], r["govFinal"], r["grandTotal"]) == ( + e["supply"], + e["vat"], + e["gov"], + e["grand"], + ) + assert r["keys"]["C_KN_SA"] == (110e6 - 1e6) / 1.1 + + +def test_indirect_truncation_digits(): + (r,) = run([{"boq": BOQ, "cfg": cfg(I_KAN_JEOLSA=3)}]) + e = ref_t(J=3) + for c in ("KANNO", "SANBO", "KOBO", "KUNBO", "SLVBO", "YUNBO", "ANJUN", "KITA", "HWAN", "EQIP"): + assert r["items"][c] % 1000 == 0, c + assert r["ilbanAmt"] == e["ilban"] and r["grandTotal"] == e["grand"] + + +def test_four_schemes_change_bases(): + s_rates = {f"S_{x}": 1.0 for x in "SUDO BOKRI SOMO TONGSIN SEGUM PEGI DOSEO JIGUP".split()} + t, s, ta, sa = run( + [ + {"boq": BOQ, "cfg": cfg()}, + {"boq": BOQ, "cfg": cfg(s_rates, JAB_TYPE=3)}, + {"boq": BOQ, "cfg": cfg(CAL_ACTL=1, I_MTYP=1, I_LTYP=1)}, + {"boq": BOQ, "cfg": cfg(s_rates, JAB_TYPE=3, CAL_ACTL=1, I_MTYP=1, I_LTYP=1)}, + ] + ) + # 수공 — 기타경비 대신 세부경비 8 · 밑수 재료비+노무비(직+간) + assert "KITA" not in s["items"] and s["items"]["S_SUDO"] == td( + (100e6 + 200e6 + t["kanno"]) * 0.01 + ) + # 실적 — 기타경비 밑수 직접공사비 · 이윤 밑수 재료비 포함(계 × 율) + assert ta["items"]["KITA"] == td(350e6 * 0.06) + assert ta["iyunRaw"] == td((ta["pureConstructionCost"] + ta["ilbanAmt"]) * 0.15) + assert sa["items"]["S_SUDO"] == td(350e6 * 0.01) and "KITA" not in sa["items"] + assert len({x["grandTotal"] for x in (t, s, ta, sa)}) == 4 + + +def test_vat_five_modes_and_plus_one(): + rs = run( + [{"boq": BOQ, "cfg": cfg(I_BUGA=b, C_BUGA_M=1_000_000)} for b in range(5)] + + [{"boq": BOQ, "cfg": cfg(B_BUGA_UP=1)}] + ) + sup = rs[0]["supplyFinal"] + assert rs[0]["vatFinal"] == td(sup * 0.1) + assert rs[1]["vatFinal"] == td(100e6 * 0.1) + assert rs[2]["vatFinal"] == 0 + assert rs[3]["vatFinal"] == td((350e6 - 200e6) * 0.1) + assert rs[4]["vatFinal"] == td((350e6 - 200e6 - 1e6) * 0.1) + assert rs[5]["vatFinal"] == rs[0]["vatFinal"] + 1 + + +def test_gov_rounding_modes_and_separate_install(): + g = {"C_KWANGUP_D": 110_500_500, "C_KWANGUP_K": 20_001_500} + up, half, down, comb = run( + [ + {"boq": BOQ, "cfg": cfg(I_KWN_UPDN=0, **g)}, + {"boq": BOQ, "cfg": cfg(I_KWN_UPDN=1, I_KWN_K_UPDN=1, **g)}, + {"boq": BOQ, "cfg": cfg(I_KWN_UPDN=2, I_KWN_K_UPDN=2, **g)}, + {"boq": BOQ, "cfg": cfg(I_KWN_COMB=1, **g)}, + ] + ) + assert (up["govFinal"], up["govK"]) == (110_501_000, 20_002_000) + assert (half["govFinal"], half["govK"]) == ( + 110_500_000, + 20_002_000, + ) # 반올림 = 최근접 짝수(11번 §4.2) + assert (down["govFinal"], down["govK"]) == (110_500_000, 20_001_000) + assert (comb["govFinal"], comb["govK"]) == (130_502_000, 0) # 통합 = 도급자+관급자 한 번에 + + +def test_private_material_position_four_ways(): + K = 11_000_000 + p0, p1, p2, p3 = run([{"boq": BOQ, "cfg": cfg(C_SAGUP=K, I_POS_SA=p)} for p in (0, 1, 2, 3)]) + base = run([{"boq": BOQ, "cfg": cfg()}])[0] + assert p0["grandTotal"] == base["grandTotal"] + K # 관급자재대 다음 + assert p2["contractFinal"] == base["contractFinal"] + K # 도급공사비 직전(부가세 밖) + assert p1["supplyFinal"] == base["supplyFinal"] + K and p1["vatFinal"] == td( + p1["supplyFinal"] * 0.1 + ) # 이윤 다음(과세) + assert p3["dM"] == 100e6 + K # 재료비에 포함 + + +def test_contract_and_supply_truncation(): + dog, jeol = run([{"boq": BOQ, "cfg": cfg(I_DOG_JEOL=4)}, {"boq": BOQ, "cfg": cfg(I_JEOLSA=3)}]) + base = run([{"boq": BOQ, "cfg": cfg()}])[0] + assert dog["contractFinal"] == td(base["contractFinal"], 4) + assert jeol["supplyFinal"] == td(base["supplyFinal"], 3) + assert jeol["pretaxProfitAdj"] == base["supplyFinal"] - jeol["supplyFinal"] + assert jeol["iyunFinal"] == base["iyunFinal"] - jeol["pretaxProfitAdj"] + + +def test_total_truncation_hits_target(): + cases = [ + {"boq": dict(BOQ, directL=200_000_000 + 7_777 * i), "cfg": cfg(I_TOT_JEOL=d)} + for i in range(6) + for d in (3, 4) + ] + for c, r in zip(cases, run(cases)): + d = c["cfg"]["keys"]["I_TOT_JEOL"] + assert r["grandTotal"] % 10**d == 0, r["grandTotal"] + assert r["keys"]["C_IYUN_M"] >= 0 + + +def test_add_amount_groups_and_vat_flag(): + rows = [ + {"group": 1, "name": "추가1", "amts": [1_000_000, 0, 0, 0, 0]}, + {"group": 1, "name": "자동", "amts": [5_000_000, 0, 0, 0, 0], "addt": 1}, + {"group": 2, "name": "폐기물", "amts": [2_000_000, 0, 0, 0, 0], "vat": True}, + {"group": 3, "name": "검증수수료", "amts": [300_000, 0, 0, 0, 0]}, + {"group": 4, "name": "총액 추가", "amts": [40_000, 0, 0, 0, 0]}, + ] + base, a, forest = run( + [ + {"boq": BOQ, "cfg": cfg()}, + {"boq": BOQ, "cfg": cfg(addRows=rows)}, + {"boq": BOQ, "cfg": cfg(addRows=rows, I_BUGA=3)}, + ] + ) + assert a["totalExpense"] == base["totalExpense"] + 1_000_000 # 묶음1 = 경비(ADDT 행 제외) + assert a["ilbanAmt"] == td((base["pureConstructionCost"] + 1_000_000) * 0.06) + assert ( + a["supplyFinal"] - (a["pureConstructionCost"] + a["ilbanAmt"] + a["iyunFinal"]) == 2_000_000 + ) + assert a["contractFinal"] == a["supplyFinal"] + a["vatFinal"] + 300_000 + assert a["grandTotal"] == a["contractFinal"] + a["govFinal"] + 40_000 + assert forest["vatFinal"] == td( + (350e6 - 200e6 + 2_000_000) * 0.1 + ) # 산림조합 = 과세 추가금액 포함 + + +def test_safety_three_ways(): + k0b1, k2, manual, raw = run( + [ + {"boq": BOQ, "cfg": cfg(I_ANJUN_K=0, I_ANJUN_B=1)}, + {"boq": BOQ, "cfg": cfg(I_ANJUN_K=2, C_SAGUP=22_000_000, I_KN_SA_TYPE=0, I_POS_SA=0)}, + {"boq": BOQ, "cfg": cfg(I_ANJUNAUTO=0, I_ANJUN_B=1)}, + {"boq": BOQ, "cfg": cfg(I_ANJUN_K=0, I_ANJUN_B=1, I_AN_DN_TYPE=1)}, + ] + ) + c190 = (300e6 * 1.86 / 100) + 5_349_000 + assert k0b1["items"]["ANJUN"] == td(1.2 * td(c190)) + assert k2["items"]["ANJUN"] == td((300e6 + 22e6) * 1.86 / 100 + 5_349_000) # 사급 포함 후보 + c198 = (300e6 + 109e6 / 1.1) * 1.86 / 100 + 5_349_000 + assert manual["items"]["ANJUN"] == td(1.2 * td(c198)) # 수동 — 관급 포함 후보의 1.2배 + assert raw["items"]["ANJUN"] == td(1.2 * c190) # 소수 버림 전 1.2배 + + +def test_construction_insurance_eight_cases(): + cases = [ + {"boq": BOQ, "cfg": cfg({"KONGSA": 0.5}, I_KN_SA_TYPE=t, C_SAGUP=11e6, I_POS_SA=2)} + for t in range(8) + ] + rs = run(cases) + A, K = 109e6, 11e6 + for t, r in enumerate(rs): + # 대상액 = 공급가액(공사보험 앞) + 관급·사급 갈래 항(REA 20 C_KONGSA_J 8갈래) + term = [A / 1.1, A, K / 1.1, K, A / 1.1 + K, A + K / 1.1, A / 1.1 + K / 1.1, A + K][t] + assert abs(r["kongsaJ"] - (r["supplyFinal"] + term)) < 1, t + assert r["items"]["KONGSA"] == td(r["kongsaJ"] * 0.005) + + +def test_employment_support_and_class4_fee(): + eis, c4 = run( + [ + { + "boq": BOQ, + "cfg": cfg( + {"EIS_HOLIR1": 20, "EIS_HOLIR2": 50, "EIS_SOCIR2": 10, "EIS_INCENT": 5}, + JAB_TYPE=5, + ), + }, + {"boq": BOQ, "cfg": cfg({"S_SIGONG": 2, "S_SIGONG_1": 0.5}, JAB_TYPE=4)}, + ] + ) + L = 200e6 + hp, soc, inc = ( + td(L * 0.2 * 0.5 * 1.4), + td(L * (3.545 + 4.5) / 100 * 0.1), + td(L * 0.2 * 0.5 * 0.05), + ) + assert eis["items"]["EIS_SUPPAY"] == hp + soc + inc + k4 = 0.02 * 0.5 + A = c4["pureConstructionCost"] + c4["ilbanAmt"] + assert c4["items"]["S_SIGONG"] == td((A + c4["iyunFinal"]) * k4) + + +def test_kan_ord_code_table_orders_items(): + (r,) = run( + [ + { + "boq": BOQ, + "cfg": dict(cfg(), kanOrd={"1": "301;309;311;302;303;310;304;305;306;312;313;321"}), + } + ] + ) + assert r["order"][:4] == ["KANNO", "HWAN", "EQIP", "SANBO"] + + +def test_change_stage_insurance_modes(): + prev = {"boq": BOQ, "cfg": cfg()} + cur = dict(BOQ, directL=230_000_000) + rs = run( + [ + prev, + {"boq": cur, "cfg": cfg(I_BF_CH_BO=1), "prev": 0, "pclass": 7}, + {"boq": cur, "cfg": cfg(I_BF_CH_BO=2), "prev": 0, "pclass": 7}, + {"boq": cur, "cfg": cfg(I_COST_BO=1, C_KUNBO=123, C_YUNBO=456), "prev": 0, "pclass": 7}, + ] + ) + old = rs[0]["items"]["KUNBO"] + assert rs[1]["items"]["KUNBO"] == old + td(30e6 * 3.545 / 100) # 당초액 + 직노증감 × 요율 + assert rs[2]["items"]["KUNBO"] == old + td(30e6 * old / 200e6) # 당초액 + 증감액(비율) + assert (rs[3]["items"]["KUNBO"], rs[3]["items"]["YUNBO"]) == (123, 456) # 직접입력 유지 + + +def test_legacy_default_state_still_computes(): + out = subprocess.check_output( + [ + "node", + "-e", + "const C=require('./tools/stmate-test/calc.js');const s=C.createDefaultState();const r=C.calculateAll(s);process.stdout.write(String(r.kanjubComputed.grandTotal))", + ], + cwd=str(Path(__file__).resolve().parents[2]), + text=True, + ) + assert out == "0" diff --git a/resources/tester/test_stmate_kanjub_rate.py b/resources/tester/test_stmate_kanjub_rate.py new file mode 100644 index 000000000..dd5634a8a --- /dev/null +++ b/resources/tester/test_stmate_kanjub_rate.py @@ -0,0 +1,108 @@ +"""STMATE 재현 앱 요율 조회 — 제비율표(RATE_R · JB_R) 좌표 규칙으로 실무 7건 저장 요율(n_R_* · n_C_ANJUN_G)을 되찾음. + +표 = 그 STC 안 RATE_R 또는 저장 시각(PRJLST.SMT 압축 시각) 이전에 나온 기준 JB_R(설치 폴더에 있으면) 중 가장 잘 맞는 것. +사용자가 표를 거치지 않고 고친 요율은 지식DB 15번 §4 대로 수동값 — 아래 MANUAL 에 까닭과 함께 적음(그 밖 불일치는 실패). +""" + +import json +import re +import subprocess +import sys +import zipfile +from datetime import datetime +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +import server # noqa: E402 +from smt_parser import parse_dbf_bytes # noqa: E402 +from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_kanjub_rate.cjs") +JB_DIR = Path("C:/경영정보(주)/System/ST_BSC") +# 조회 항목 → 저장키 +ITEMS = { + "KANNO": "R_KANNO", + "KITA": "R_T_KITA", + "ILBAN": "R_ILBAN", + "IYUN": "R_IYUN", + "SANBO": "R_SANBO", + "KUNBO": "R_KUNBO", + "YUNBO": "R_YUNBO", + "SLVBO": "R_SLVBO", + "KOBO": "R_KOBO", + "ANJUN": "R_ANJUN", + "ANJUN1": "R_ANJUN1", + "ANJUN2": "R_ANJUN2", + "HWAN": "R_HWAN", + "EQIP": "R_EQIPSU", +} +# 표를 거치지 않은 수동 요율(15번 §4 · 이번 대조) +MANUAL = { + # 공사기간 0(6개월 이하)인데 7~12개월 칸 값 · 고용 5등급인데 7등급 값 · 환경 조경(13)인데 기타토목 값 — 다른 공사 설정 복사 + "대흥2024": {"KANNO", "KITA", "KOBO", "HWAN"}, + # RATE_R 10행에 사용자가 손으로 넣은 16.5/5.2(같은 행 나머지 칸 0) — 공사기간 칸과 어긋남 + "울진2025": {"KANNO", "KITA"}, + "소광원설계": {"KANNO", "KITA"}, + # 위와 같음 + 이윤 13.4355%(표에 없음 · 15번 §4) + "소광변경1차": {"KANNO", "KITA", "IYUN"}, + # 이윤 13.887%(표에 없음) · 건설기계 구분 5 인데 일반공사 0.4% + "영덕변경": {"IYUN", "EQIP"}, +} + + +def _jb_tables(before: datetime) -> list[tuple[str, list]]: + out = [] + for p in sorted(JB_DIR.glob("*.JB_R")) if JB_DIR.exists() else []: + m = re.match(r"(\d{4})\((?:상|하)_(\d{2})(\d{2})", p.name) + if m and datetime(int(m[1]), int(m[2]), int(m[3])) <= before: + out.append((p.name, parse_dbf_bytes(p.read_bytes())[1])) + return out + + +def _cases(name: str): + src = DOCS / STCS[name] + loaded = server.api_load(server.LoadReq(source_path=str(src))) + kv, prj = loaded["prjlst_smt"], loaded["prjlst"] + with zipfile.ZipFile(src) as z: + saved = datetime( + *next(i.date_time for i in z.infolist() if i.filename.upper().endswith("PRJLST.SMT")) + ) + tables = [("STC RATE_R", loaded["rate_tables"]["RATE_R.DBF"])] + _jb_tables(saved) + for n in sorted({k[0] for k in kv if k[1:] == "_R_KANNO"}): + g = lambda k: float(kv.get(f"{n}_{k}") or 0) # noqa: E731 + opts = { + "kongType": g("I_KONG_TYPE"), + "kikan": g("I_KONG_KIKAN"), + "scale": float(prj[f"TCOST{n}"]), + "anjunType": g("I_ANJUN_TYPE"), + "anjunScale": float(prj[f"MCOST{n}"]) + float(prj[f"LCOST{n}"]) + g("C_KN_SA"), + "koboType": g("I_KOBO_TYPE"), + "hwan": g("I_HWAN"), + "eqip": g("I_EQIP"), + "tbugum": g("I_TBUGUM"), + } + want = {c: g(k) for c, k in ITEMS.items()} | {"C_ANJUN_G": g("C_ANJUN_G")} + yield n, opts, want, tables + + +@pytest.mark.parametrize("name", list(STCS)) +def test_rates_come_from_table(name): + for n, opts, want, tables in _cases(name): + req = [{"matrix": rows, "opts": opts} for _, rows in tables] + res = json.loads( + subprocess.check_output( + ["node", str(HELPER)], input=json.dumps(req), text=True, encoding="utf-8" + ) + ) + scored = [] + for (tname, _), r in zip(tables, res): + got = r["rates"] | r["amounts"] + bad = {c for c, v in want.items() if abs(float(got.get(c, 0)) - v) > 1e-6} + scored.append((len(bad), tname, bad)) + scored.sort(key=lambda x: x[0]) + best = scored[0] + # 불일치 = 정확히 수동 목록(더 많으면 규칙 오류 · 적으면 MANUAL 이 버그를 숨김) + assert best[2] == MANUAL.get(name, set()), f"{name} {n}차수 표 {best[1]} 불일치 {best[2]}" diff --git a/resources/tester/test_stmate_kanjub_stc.py b/resources/tester/test_stmate_kanjub_stc.py index 9543fc6cc..46246f9c6 100644 --- a/resources/tester/test_stmate_kanjub_stc.py +++ b/resources/tester/test_stmate_kanjub_stc.py @@ -64,3 +64,63 @@ def test_kanjub_matches_stc_to_the_won(name): if abs(got[k] - float(v)) >= 0.5: diffs[f"{n}:{k}"] = (got[k], float(v)) assert not diffs, f"{name} 불일치 {diffs}" + + +KEYS_HELPER = Path(__file__).with_name("helper_stmate_kanjub_keys.cjs") +# 계산이 내는 저장키 — 실무 7건 모든 차수에서 나와야 하고 저장값과 1원 일치 +REQUIRED_KEYS = ( + "C_KANNO C_SANBO C_KOBO C_KUNBO C_SLVBO C_YUNBO C_TBUGUM C_ANJUN C_T_KITA C_HWAN C_EQIPSU" + " C_HADOSU C_KONGSU C_ILBAN C_IYUN_M C_KN_SA C_KONGSA_J C_KONGSA C_UPDNKWN C_KWANGUP" + " C_KWANGUP_D C_KWANGUP_K C_T_JODAL C_S_SUDO C_S_BOKRI C_S_SOMO C_S_TONGSIN C_S_SEGUM C_S_PEGI" + " C_S_DOSEO C_S_JIGUP C_S_SIGONG C_ANJUNP C_EIS_SUPPAY C_EIS_HOLPAY C_EIS_SOCINS C_EIS_INCENT" + " C_DOSEOH C_ANJUN_B C_PUMJIL_B C_PSAMT_B C_EXCEPT_B C_BUSAN_B C_PURCHAS C_T_PEGI C_T_KOLJAE" + " C_NBUGAM I_ANJUN_B" +).split() + + +def _keys_out(loaded): + return json.loads( + subprocess.check_output( + ["node", str(KEYS_HELPER)], + input=json.dumps(loaded["sim_state"], ensure_ascii=False), + text=True, + encoding="utf-8", + ) + ) + + +@pytest.mark.parametrize("name", list(STCS)) +def test_kanjub_every_stored_key(name): + """계산 결과 저장키(C_KN_SA · C_KONGSA_J · C_KWANGUP · 관급 절상 · 안전 1.2배 표시 I_ANJUN_B · B_BUGA_UP …) 전수 대조.""" + loaded = server.api_load(server.LoadReq(source_path=str(DOCS / STCS[name]))) + kv = loaded["prjlst_smt"] + for n, got in _keys_out(loaded).items(): + keys = got["keys"] + missing = [k for k in REQUIRED_KEYS if k not in keys] + assert not missing, f"{name} {n}차수 계산 안 된 키 {missing}" + diffs = { + k: (v, kv[f"{n}_{k}"]) + for k, v in keys.items() + if f"{n}_{k}" in kv and abs(float(v) - float(kv[f"{n}_{k}"] or 0)) >= 0.5 + } + assert not diffs, f"{name} {n}차수 저장키 불일치 {diffs}" + # 식 템플릿 재평가 = 항목 후보(F2-57) + bad = {c: v for c, v in got["tpl"].items() if abs(v[0] - v[1]) >= 0.5} + assert not bad, f"{name} {n}차수 식 템플릿 불일치 {bad}" + + +@pytest.mark.parametrize("name", list(STCS)) +def test_kanjub_smt_round_trip(name): + """PRJLST.SMT → 설정 → 되돌린 저장 사전이 원본 [n_KANJUB]·ADD_* 키를 값 그대로 담음(F2-58).""" + from kanjub_cfg import build_kanjub_config, smt_updates, stage_slots + + kv = server.api_load(server.LoadReq(source_path=str(DOCS / STCS[name])))["prjlst_smt"] + back = {} + for n in stage_slots(kv): + back |= smt_updates(build_kanjub_config(kv, n), n) + for k, v in kv.items(): + assert k in back, f"{name} 되돌린 키 없음 {k}" + try: + assert abs(float(back[k]) - float(v or 0)) < 1e-6, (k, back[k], v) + except ValueError: + assert back[k] == v, (k, back[k], v) diff --git a/resources/tester/test_stmate_qty_sheet.py b/resources/tester/test_stmate_qty_sheet.py new file mode 100644 index 000000000..5adbf6fbc --- /dev/null +++ b/resources/tester/test_stmate_qty_sheet.py @@ -0,0 +1,249 @@ +"""STMATE 재현 앱 수량산출서 — QCODE 읽기·쓰기 · 시트 셈(셀별명) · 기본/확장 수량변수 · 내역 가져가기 · +코드 종류별 수량 · 내역↔BDQTY 수량 연결 · 수량코드 검사 · 자재 후보단가 채택. + +정답 = 실무 STC 7건(QCODE 필드 정의 · 내역 수량 · BDQTY 구성 · 가격지 후보) 에서 따로 셈한 값 · 규칙 문서(REA 28 · 30). +""" + +import json +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +import api_qty # noqa: E402 +import qty_qcode # noqa: E402 +import server # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_qty_run.cjs") +STC = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC")) +STRUCT = ("T", "C", "E") + + +def call(*calls): + out = subprocess.check_output( + ["node", str(HELPER)], + input=json.dumps({"calls": [{"fn": f, "args": a} for f, *a in calls]}, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + res = json.loads(out) + for r in res: + assert not (isinstance(r, dict) and "__error" in r), r + return res + + +@pytest.fixture(scope="module") +def loads(): + return {p: server.api_load(server.LoadReq(source_path=str(p))) for p in STC} + + +def test_qcode_read_write_round_trip(tmp_path, monkeypatch): + files = server.read_project_files(str(STC[0])) + got = qty_qcode.read_qcode(files) + names = [f["name"] for f in got["fields"]] + assert ( + got["present"] + and names[:3] == ["CODE", "NAME", "BQCODE"] + and names[-2:] == ["IFORM", "XCODE"] + ) + rows = [ + { + "CODE": "Q00001", + "NAME": "절토", + "BQCODE": "B00001", + "QTY0": "12.5", + "QTY3": "7", + "IFORM": "QTY1=절토량*2", + "XCODE": "절토량=120.5\r\n성토량=80", + }, + {"CODE": "Q00002", "NAME": "빈 줄", "QTY1": "0"}, + ] + dbf, smt = qty_qcode.build_qcode(rows, files) + back = qty_qcode.read_qcode({**files, "QCODE.DBF": dbf, "QCODE.SMT": smt})["rows"] + for want, have in zip(rows, back): + for k, v in want.items(): + if k.startswith("QTY"): + assert float(have[k] or 0) == float(v), (k, have[k], v) + else: + assert have[k].replace("\r\n", "\n") == v.replace("\r\n", "\n"), (k, have[k], v) + # 저장 API — work_dbf 대신 임시 폴더 · STC 안 QCODE 두 파일이 바뀜 + monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path) + res = api_qty.api_qty_qcode_save( + api_qty.QcodeSaveReq(source_path=str(STC[0]), target_folder_name="qty시험", rows=rows) + ) + again = api_qty.api_qty_qcode(api_qty.QcodeReq(source_path=res["saved_stc"])) + assert [r["CODE"] for r in again["rows"]] == ["Q00001", "Q00002"] + with pytest.raises(Exception): + api_qty.api_qty_qcode_save( + api_qty.QcodeSaveReq(source_path=str(STC[0]), rows=[{"CODE": "X1"}]) + ) + api_qty.api_qty_eq_save( + api_qty.EqSaveReq( + items=[api_qty.EqItem(name="굴삭기", text="Q = 3600*q*K*f*E/Cm =", ins_x=1)] + ) + ) + assert api_qty.api_qty_eq()["items"][0]["ins_x"] == 1 + + +def test_sheet_aliases_assign_and_smt_transfer(): + sheet = { + "rows": 10, + "cols": 8, + "start": 4, + "end": 1, + "cells": { + "A1": "120", + "B1": "=A1*1.5", + "C1": "=절토*2", + "D1": "=D2", + "D2": "=D1", + "A2": "B00001", + "B2": "토공", + "D3": "5", + }, + "aliases": {"절토": "B1", "순환": "D1"}, + "roles": {"code": "A", "name": "B", "qty": "D", "mamt": "E", "lamt": "F", "samt": "G"}, + } + (ev,) = call(("evalSheet", sheet)) + assert ev["values"]["B1"] == 180 and ev["values"]["C1"] == 360 + assert "D1" in ev["errors"] and "@순환" in ev["errors"] + q = [{"CODE": "Q00001", "IFORM": "QTY1=절토/2\r\nQTY2=없는별명", "XCODE": ""}] + (asg,) = call(("assignFromSheet", q, sheet)) + assert float(asg["rows"][0]["QTY1"]) == 90 and "절토=180" in asg["rows"][0]["XCODE"] + assert len(asg["errors"]) == 1 + prices = {"L00001": {"M": 0, "L": 1000, "S": 0, "unit": "인"}} + (smt,) = call( + ( + "evaluate", + "&L00001& * ~Q1.QTY1~ =\n&L00001& * ~Q1.절토~ =", + prices, + {"qcode": asg["rows"]}, + ) + ) + assert smt["L"] == 1000 * 90 + 1000 * 180 and smt["errors"] == [] + + +def test_sheet_to_boq_inclusive_range_without_qty_multiply(): + cells = { + "A2": "B00001", + "B2": "토공", + "D2": "3", + "E2": "100", + "F2": "200", + "G2": "50", + "A4": "M00001", + "D4": "=D2*2", + "B5": "명칭만", + } + sheet = { + "rows": 6, + "cols": 7, + "start": 4, + "end": 1, + "cells": cells, + "aliases": {}, + "roles": { + "code": "A", + "name": "B", + "size": "C", + "unit": "C", + "qty": "D", + "mamt": "E", + "lamt": "F", + "samt": "G", + }, + } + by_code, by_name, missing = call( + ("sheetToBoq", sheet, "code"), + ("sheetToBoq", sheet, "name"), + ("sheetToBoq", {**sheet, "roles": {"code": "A"}}, "code"), + ) + assert [i["row"] for i in by_code["items"]] == [2, 4] and by_code["items"][1]["qty"] == 6 + first = by_code["items"][0] + assert first["T"] == first["M"] + first["L"] + first["S"] == 350 + assert [i["row"] for i in by_name["items"]] == [2, 5] + assert missing["error"] and not missing["items"] + + +def test_qcode_check_and_next_code_and_file_signature(): + rows = [ + {"CODE": "Q00001", "BQCODE": "B00001"}, + {"CODE": "Q00001", "QTY1": "1x"}, + {"CODE": "Q1", "XCODE": "값만", "IFORM": "식"}, + {"CODE": "Q00003", "BQCODE": "Z99999"}, + ] + errs, nxt, bad, rt = call( + ("checkQcodes", rows, ["B00001"]), + ("nextQcode", rows), + ("deserialize", '{"sig":"다른 것"}'), + ( + "roundTrip", + { + "rows": 3, + "cols": 2, + "cells": {"A1": "1"}, + "aliases": {}, + "roles": {}, + "start": 0, + "end": 0, + }, + rows, + ), + ) + joined = "\n".join(errs) + for frag in ("같은 수량코드", "숫자가 아님", "다섯 자리", "이름=값", "QTYn=식", "Z99999"): + assert frag in joined, (frag, errs) + assert ( + nxt == "Q00004" + and bad["error"] + and rt["sheet"]["cells"] == {"A1": "1"} + and len(rt["qcode"]) == 4 + ) + + +@pytest.mark.parametrize("path", STC, ids=lambda p: p.stem[:20]) +def test_qty_by_code_and_rollup_on_real_boq(loads, path): + d = loads[path] + boq, bd = d["boq_rows"], d["bdqty_rows"] + want = defaultdict(float) + for r in boq: + c = str(r.get("BDCODE1") or "").strip() + if c and not (c[0] in STRUCT and (len(c) == 1 or c[1:].isdigit())): + want[c] += float(r.get("QTY1") or 0) + got, roll = call(("qtyByCode", boq, "ALL", "QTY1"), ("rollup", boq, bd, "QTY1")) + assert {o["code"]: round(o["qty"], 6) for o in got} == {k: round(v, 6) for k, v in want.items()} + assert roll["cycles"] == [] + # 한 단계 연결 — 내역 B코드 수량 × 구성 수량이 하위 코드 소요량에 들어감 + groups = defaultdict(list) + for r in bd: + groups[r["BDCODE"]].append(r) + for c, q in want.items(): + for row in groups.get(c, []): + sub = str(row["CODE"]).strip() + if ( + sub + and sub not in ("PR", "RR") + and not (sub[0] in STRUCT and sub[1:].isdigit()) + and q + ): + assert roll["totals"][sub] >= q * float(row["QTY1"] or 0) - 1e-6, (c, sub) + + +@pytest.mark.parametrize("path", STC, ids=lambda p: p.stem[:20]) +def test_material_candidate_minimum_matches_stored(loads, path): + """최소단가 채택 — 후보가 있는 자재의 재료비는 0원 뺀 최저 후보(저장값과 같음) · 보호표시는 그대로.""" + rows = loads[path]["costn_rows"] + (res,) = call(("adopt", rows, {"mode": "min", "slot": 1})) + assert res["changed"] == [] + m = next(r for r in rows if r["CODE"].startswith("M") and float(r.get("AMT_11") or 0) > 0) + probe = [ + {**m, "AMT_21": str(float(m["AMT_11"]) / 2), "UC": ""}, + {**m, "CODE": "M99999", "AMT_21": "1", "UC": "T"}, + ] + (res,) = call(("adopt", probe, {"mode": "min", "slot": 1})) + assert res["changed"][0]["to"] == float(m["AMT_11"]) / 2 and res["changed"][0]["src"] == 2 + assert res["kept"] == ["M99999"] diff --git a/resources/tester/test_stmate_report_pps.py b/resources/tester/test_stmate_report_pps.py new file mode 100644 index 000000000..ff21e837b --- /dev/null +++ b/resources/tester/test_stmate_report_pps.py @@ -0,0 +1,84 @@ +"""STMATE 재현 앱 조달청 공사원가호환규정 XML — 공식 XSD(V1.0.5) 검증 · 금액은 STC 저장값과 1원 대조. + +XSD = 지식DB STmate/old/30_원자료/조달청_공사원가호환규정.xsd(STMATE 설치본 Valid_Xml.vsl 사본). +정답 = STC 안 PRJLST.DBF 차수별 저장값(MCOST·LCOST·SCOST·COSTW). 코드에 박지 않음. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from lxml import etree + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +import server # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_report.cjs") +DOCS = ROOT / "resources/knowledge/original/실무문서" +XSD = ( + ROOT + / "resources/knowledge/original/경쟁사 프로그램/STmate/old/30_원자료/조달청_공사원가호환규정.xsd" +) +STCS = { + "봉화2024": ( + "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC", + 1, + ), + "영월2024": ("05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC", 1), + "대흥2024": ( + "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", + 1, + ), + "울진2025": ( + "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", + 1, + ), + "영덕변경": ( + "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", + 2, + ), +} + + +def pps(name): + rel, stage = STCS[name] + load = server.api_load(server.LoadReq(source_path=str(DOCS / rel))) + out = subprocess.check_output( + ["node", str(HELPER)], + input=json.dumps({"load": load, "stage": stage, "mode": "pps"}, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + return load, stage, etree.fromstring(json.loads(out)["xml"].encode("utf-8")) + + +@pytest.mark.parametrize("name", list(STCS)) +def test_pps_xml_is_valid_and_totals_match_stc(name): + load, n, doc = pps(name) + schema = etree.XMLSchema(etree.parse(str(XSD))) + assert schema.validate(doc), f"{name} XSD 위반 {[str(e) for e in list(schema.error_log)[:3]]}" + + def amount(code): + for it in doc.iter("제경비항목"): + if it.findtext("세부공종코드") == code: + return float(it.findtext("산출금액")) + return None + + prj = load["prjlst"] + assert abs(amount("700") - float(prj[f"COSTW{n}"])) < 0.5, f"{name} 총공사비" + sums = {"재료비": 0.0, "노무비": 0.0, "경비": 0.0} + for g in doc.iter("내역서"): + if g.findtext("공종구분") != "일반공종": + continue + for it in g.findall("세부공종"): + for k in sums: + sums[k] += float(it.findtext(k)) + for k, col in (("재료비", "MCOST"), ("노무비", "LCOST"), ("경비", "SCOST")): + assert abs(sums[k] - float(prj[f"{col}{n}"])) < 0.5, f"{name} 직접{k} {sums[k]}" + codes = {r["CODE"].strip()[0] for r in load["costn_rows"]} + for tag, p in (("일위대가", "B"), ("기계경비", "X"), ("자재", "M"), ("노무", "L")): + if p in codes: + assert doc.find(f".//{tag}") is not None, f"{name} {tag} 없음" diff --git a/resources/tester/test_stmate_report_sheets.py b/resources/tester/test_stmate_report_sheets.py index e262b08ff..6c6dd2c48 100644 --- a/resources/tester/test_stmate_report_sheets.py +++ b/resources/tester/test_stmate_report_sheets.py @@ -171,3 +171,28 @@ def test_summary_cost_lines_and_groups_match_original(name): it = iter(got[:cut_g]) missing = [o for o in orig[:cut_o] if not any(o == g for g in it)] assert cut_o > 2 and not missing, f"{name} 공종 줄 없음 {missing[:3]}" + + +def test_xlsx_export_writes_every_sheet_with_model_values(): + """엑셀 내보내기(api_report → report_xlsx) — 시트 차례 · 이름 · 값이 모형 그대로, 목차는 바로가기.""" + from io import BytesIO + + from fastapi.testclient import TestClient + + client = TestClient(server.app) + sheets = list(book("영월2024").values()) + res = client.post("/api/report/xlsx", json={"sheets": sheets, "filename": "t.xlsx"}) + assert res.status_code == 200 + wb = openpyxl.load_workbook(BytesIO(res.content)) + assert wb.sheetnames == [s["name"] for s in sheets] + for s in sheets: + ws = wb[s["name"]] + for i, row in enumerate(s["rows"], start=1): + for c, v in row.items(): + if c.isupper() and v not in (None, ""): + got = ws[f"{c}{i}"].value + same = abs(got - v) < 1e-9 if isinstance(v, float) else got == v + assert same, f"{s['name']} {c}{i}" + assert wb["〓 목 차 〓"]["D5"].hyperlink is not None + csv = client.post("/api/report/csv", json={"sheets": [sheets[2]], "filename": "c"}) + assert csv.status_code == 200 and "공사원가계산서" in csv.content.decode("utf-8-sig") diff --git a/resources/tester/test_stmate_smt_lang.py b/resources/tester/test_stmate_smt_lang.py new file mode 100644 index 000000000..14415163d --- /dev/null +++ b/resources/tester/test_stmate_smt_lang.py @@ -0,0 +1,293 @@ +"""STMATE 재현 앱 단가산출근거 식 언어 — 함수·자리 처리·계표시·손료계산(금액변수·누계번지)·수량변수·문법 검사. + +정답 = 원본 편집기 문법 안내문(REA 30 TWM_EDIT_SAN Memo_Use)의 예와 결과 설명 · 실무 STC 7건의 산근 원문. +글 안의 수치를 읽어 대조(코드에 박지 않음) · 단가표·수량코드는 예마다 만든 것. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +import server # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_qty_run.cjs") +DFM = ( + ROOT / "resources/knowledge/original/경쟁사 프로그램/STmate/REA 분석/" + "30_수량문자열_수신편집기_작업자료_20261026/ST_EdSan/resource-TWM_EDIT_SAN.dfm.json" +) +DOCS = ROOT / "resources/knowledge/original/실무문서" +STC = sorted(DOCS.rglob("*.STC")) + + +def call(*calls): + out = subprocess.check_output( + ["node", str(HELPER)], + input=json.dumps({"calls": [{"fn": f, "args": a} for f, *a in calls]}, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + res = json.loads(out) + for r in res: + assert not (isinstance(r, dict) and "__error" in r), r + return res + + +def memo_use() -> list[str]: + nodes = json.loads(DFM.read_text(encoding="utf-8"))["nodes"] + node = next(n for n in nodes if n.get("name") == "Memo_Use") + return node["properties"]["Lines.Strings"] + + +def test_rounding_examples_from_grammar_text(): + """(3) A=123.784, B=873.496 · C=A/B=0.14 · D=round(A/B,4)=0.1417 · E=A/B=[?:4R] · F=[?:2U] · H=int · I=[?:0C].""" + text = "\n".join(memo_use()) + a = re.search(r"A=([\d.]+),\s*B=([\d.]+)", text) + rows = re.findall(r"\b([C-I])=([A-Za-z()/,\d ]+?)=(\[\?:\d[RUC]\]|[\d.]+)", text) + paren = dict( + m.groups() + for ln in memo_use() + if (m := re.search(r"\(([EFI])\).*\[\?:\d[RUC]\].*\(([\d.]+)\)\s*$", ln)) + ) + assert a and {k for k, *_ in rows} >= set("CDEFHI") and set(paren) == set("EFI"), (rows, paren) + lines = [f"A={a[1]}, B={a[2]}"] + [ + f"{k}={e}=" + (t if t.startswith("[") else "") for k, e, t in rows + ] + (ev,) = call(("evaluate", "\n".join(lines), {}, {"assignDigits": 2})) + for k, _e, t in rows: + want = float(paren[k] if t.startswith("[") else t) + assert abs(ev["vars"][k] - want) < 1e-9, (k, ev["vars"][k], want) + + +def test_functions_and_left_to_right(): + """함수 목록(cBoxFunc) 꼴 그대로 평가 · 곱·나눗셈 왼쪽부터 · RXXNM 18 §2.1.""" + nodes = json.loads(DFM.read_text(encoding="utf-8"))["nodes"] + items = next(n for n in nodes if n.get("name") == "cBoxFunc")["properties"]["Items.Strings"] + funcs = [s.strip() for s in items if "(" in s] + res = call(*[("expr", f, {}) for f in funcs]) + for f, r in zip(funcs, res): + assert r["error"] is None and r["value"] is not None, (f, r) + by = dict(zip(funcs, (r["value"] for r in res))) + import math + + assert by["IIF(1>0, 2, 3)"] == 2 and by["LOG(10,1)"] == 0 and by["SQRT(1)"] == math.sqrt(1) + daily = 267360 + (r,) = call(("expr", f"{daily}*1/8*16/12*25/20", {})) + step = daily + for op, n in [("/", 8), ("*", 16), ("/", 12), ("*", 25), ("/", 20)]: + step = step / n if op == "/" else step * n + assert r["value"] == step + + +def test_sonryo_amount_and_total_variables(): + """(9) 손료계산 ①~⑧ — 금액번지 A01(①+②) · 제외(->) · 누계번지 T01(①+③) · PA01 · PT01 · PC_ · PS_.""" + lines = [ln for ln in memo_use() if re.match(r"\s*[①-⑧]\s*(\[|\(=|&)", ln)][:8] + body = [re.sub(r"^\s*[①-⑧]\s*", "", ln).split(" or ")[0].split("→")[0].strip() for ln in lines] + prices = { + "L00084": {"M": 0, "L": 150000, "S": 0, "unit": "인"}, + "L00087": {"M": 0, "L": 120000, "S": 0, "unit": "인"}, + "L00088": {"M": 0, "L": 90000, "S": 0, "unit": "인"}, + } + text = "\n".join(b.replace("&L84&", "&L84&").replace("&PS_:M&", "&PS_:M&") for b in body) + (ev,) = call(("evaluate", text, prices, {})) + amt = [ln for ln in ev["lines"] if ln["kind"] == "amount"] + tot = lambda ln: ln["amounts"]["M"] + ln["amounts"]["L"] + ln["amounts"]["S"] # noqa: E731 + one, two, three, five, six, seven, eight = amt + assert ev["errors"] == [] + assert two["excluded"] and not one["excluded"] + assert ev["labels"]["A01"]["L"] == pytest.approx(one["amounts"]["L"] + two["amounts"]["L"]) + assert ev["labels"]["T01"]["L"] == pytest.approx(one["amounts"]["L"] + three["amounts"]["L"]) + trunc1 = lambda v: int(v * 10 + 1e-9) / 10 # noqa: E731 + assert five["amounts"]["S"] == pytest.approx(trunc1(ev["labels"]["A01"]["L"] * 0.1)) + assert six["amounts"]["S"] == pytest.approx(trunc1(ev["labels"]["T01"]["L"] * 0.1)) + assert seven["amounts"]["M"] == pytest.approx( + trunc1((five["amounts"]["S"] + six["amounts"]["S"]) * 0.1) + ) + allsum = tot(one) + tot(three) + tot(five) + tot(six) + tot(seven) + assert eight["amounts"]["M"] == pytest.approx(trunc1(allsum * 0.1)) + assert ev["T"] == int(allsum + tot(eight)) + + +def test_section_markers_exclusion_and_grammar_errors(): + prices = { + "M00001": {"M": 1000, "L": 0, "S": 0, "unit": "M2"}, + "L00001": {"M": 0, "L": 2000, "S": 0, "unit": "인"}, + } + text = "\n".join( + ["&M00001& * 2 =", "(=)", "&L00001& * 1 =", "(-=)", "&M00001& * 3 =", "(====)", "(=====)"] + ) + (ev,) = call(("evaluate", text, prices, {})) + assert (ev["M"], ev["L"]) == (5000, 0) and ev["errors"] == [] + bad = "\n".join( + [ + "&M00001& + &L00001& =", + "&M00001& ^ 2 =", + "x = y + 1", + "'열린 따옴표", + "&Z99999& * 1 =", + "(======)", + "~Q1.QTY1 * 2", + "&M00001& * IMG(I00001) IMG(I00002)", + ] + ) + (ev,) = call(("evaluate", bad, prices, {"selfCode": "D00001"})) + lines = {e["lineNo"] for e in ev["errors"]} + assert lines == set(range(1, 9)), ev["errors"] + (ev,) = call( + ( + "evaluate", + "&D00001& * 1 =", + {"D00001": {"M": 1, "L": 0, "S": 0, "unit": "M3"}}, + {"selfCode": "D00001"}, + ) + ) + assert any("순환" in e["msg"] for e in ev["errors"]) + + +def test_quantity_variables_and_code_normalization(): + """~Q1.QTY1~ → Q00001 · QTY0~9 직접 · 그 밖 XCODE 「이름=값」 · 빈 값 0 · 없는 이름 오류(REA 30).""" + q = [{"CODE": "Q00001", "QTY1": "12.5", "XCODE": "절토량=120.5\r\n빈칸=\r\n"}] + prices = {"L00001": {"M": 0, "L": 1000, "S": 0, "unit": "인"}} + text = "\n".join( + [ + "A = ~Q1.QTY1~", + "B = ~Q00001.절토량~", + "C = ~q01.빈칸~", + "&L00001& * A =", + "D = ~Q00001.없음~", + "E = ~Q00002.QTY1~", + ] + ) + (ev,) = call(("evaluate", text, prices, {"qcode": q})) + assert ev["vars"]["A"] == 12.5 and ev["vars"]["B"] == 120.5 and ev["vars"]["C"] == 0 + assert ev["L"] == 12500 + assert {e["lineNo"] for e in ev["errors"]} == {5, 6} + n = call( + ("normQCode", "Q1"), + ("normQCode", "q000001"), + ("normQCode", "Q1234567"), + ("normQCode", "X1"), + ) + assert n == ["Q00001", "Q00001", None, None] + + +def test_code_insert_lines_zero_skip_and_result_strip(): + row = { + "NAME": "굴삭기", + "SIZE": "0.7㎥", + "UNIT": "HR", + "MAMT1": "7701", + "LAMT1": "55700", + "SAMT1": "0", + } + prices = {"X00001": {"M": 7701, "L": 55700, "S": 0, "unit": "HR"}} + full, skip, gen = call( + ("codeLines", "X00001", row, {"op": "/", "qtyVar": "Q"}), + ("codeLines", "X00001", row, {"op": "*", "qtyVar": "T", "skipZero": True}), + ( + "codeLines", + "M00009", + {"NAME": "합판", "SIZE": "12mm", "UNIT": "M2"}, + {"trans": 3, "qty": 2}, + ), + ) + assert len(full) == 3 and len(skip) == 2 and all("* T =" in s for s in skip) + assert "'합판 12mm'" in gen[0] and "'M2'" in gen[0] + (ev,) = call(("evaluate", "Q = 15.71\n" + "\n".join(full), prices, {})) + assert ev["L"] == int(55700 / 15.71 * 10) / 10 // 1 or ev["L"] == int( + int(55700 / 15.71 * 10) / 10 + ) + marks = call(*[("marker", lv, ex) for lv in range(1, 6) for ex in (False, True)]) + stripped = call( + ( + "stripResults", + "\n".join( + [ + "소계", + "총합계(제외금액)", + "&M00001& * 2 = 2,000 '원'", + "&M00001& * 2 = 2,000", + "그냥 글", + ] + ), + ) + )[0].split("\r\n") + assert stripped[0] == marks[0] and stripped[1] == marks[9] + assert ( + stripped[2] == "&M00001& * 2 ='원'" + and stripped[3] == "&M00001& * 2 =" + and stripped[4] == "'그냥 글'" + ) + + +@pytest.mark.parametrize("path", STC, ids=lambda p: p.stem[:20]) +def test_real_memos_check_clean_and_strip_keeps_amounts(path): + """실무 산근 원문 — 문법 오류 0 · 금액결과 삭제 뒤에도 금액 그대로 · 참조 그래프 순환 없음.""" + d = server.api_load(server.LoadReq(source_path=str(path))) + load = { + k: d[k] + for k in ("costn_rows", "costn_memos", "start_ini", "bdqty_rows", "boq_rows", "sim_state") + } + smt, cyc = call(("smtAll", load), ("cascadeCycles", load)) + errs = {c: v["errors"] for c, v in smt.items() if v["errors"]} + assert not errs, list(errs.items())[:3] + moved = {c: (v["sum"], v["stripped"]) for c, v in smt.items() if v["sum"] != v["stripped"]} + assert not moved, list(moved.items())[:3] + assert cyc == [] + + +def test_cycles_composition_devd_rr_and_result_options(): + (cyc,) = call( + ( + "cycles", + { + "B00001": ["B00002"], + "B00002": ["D00001"], + "D00001": ["B00001"], + "B00003": ["L00001"], + }, + ) + ) + assert len(cyc) == 1 and set(cyc[0]) == {"B00001", "B00002", "D00001"} + table = { + "L00001": {"M": 0, "L": 100000, "S": 0, "unit": "인"}, + "M00001": {"M": 3000, "L": 0, "S": 0, "unit": "M3"}, + } + rows = [ + {"CODE": "L00001", "QTY1": "4", "DEVD1": "T", "PRPT": "1"}, + {"CODE": "M00001", "QTY1": "2", "DEVD1": "F", "PRPT": "0"}, + {"CODE": "RR", "QTY1": "0.03", "PRIN": "2", "PROUT": "1"}, + {"CODE": "PR", "QTY1": "10", "PRIN": "2", "PROUT": "3"}, + ] + ev, adj, dv1, dv2, dv0 = call( + ("composition", rows, table, {}), + ("composition", rows, table, {"adj": {"M": 1, "L": 1.15, "S": 1}, "sbmok": "3"}), + ("devd", 4, "인"), + ("devd", 3, "%"), + ("devd", 0, "인"), + ) + lab = 100000 / 4 + assert (ev["L"], ev["M"], ev["S"]) == (int(lab), int(6000 + lab * 0.03), int(lab * 0.1)) + assert ( + adj["M"] == 0 + and adj["L"] == 0 + and adj["S"] == ev["M"] + int(ev["L"] * 1.15 + 1e-9) + ev["S"] + ) + assert dv1 == 0.25 and dv2 == round(100 / 3, 7) and dv0 == 0 + + +def test_labor_rx_branches(): + ini = { + "CONLD": "L00008L00009", + "CONTD": "L00030", + "RXXNM": "1/8*16/12*25/20", + "RXTNM": "1/8*16/12*25/20*1.2", + } + each, forced = call(("laborMap", ini, 0, ""), ("laborMap", ini, 2, "")) + assert each == {"L00008": ini["RXXNM"], "L00009": ini["RXXNM"], "L00030": ini["RXTNM"]} + assert set(forced.values()) == {ini["RXTNM"]} diff --git a/resources/tester/test_stmate_ui.py b/resources/tester/test_stmate_ui.py new file mode 100644 index 000000000..b0d74db20 --- /dev/null +++ b/resources/tester/test_stmate_ui.py @@ -0,0 +1,140 @@ +"""STMATE 재현 앱 화면 편집(설계내역서 wBoq · 도구 대화상자 · 초기값 wM_Set_Sys · 단가목록표 wM_JabPum · 메뉴) 지킴 시험. + +계산 = tools/stmate-test/ui_*.js(화면 없이 node) · 자료 = 실무 STC 불러오기 결과(server.api_load). +행 넣기·지우기·이동·계표시·붙여넣기 뒤 저장 원천(boq_rows)과 계산 원천(boqItems)이 함께 움직이는지 직공비 증감으로 대조. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +import server # noqa: E402 + +HERE = Path(__file__).parent +DOCS = ROOT / "resources/knowledge/original/실무문서" +PROJECTS = { + "봉화2024": "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC", + "영월2024": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC", + "대흥2024": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", + "울진2025": "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC", + "영덕변경": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC", +} +# 공종번호 다시 매김 계획 — 원본 번호 꼴(봉화 T4 「1.」 · T2 「1.1」 / 영월 T3 「1」 · T2 「1.1」) +ITNUM_PLAN = { + "봉화2024": [ + {"level": 4, "first": "", "last": ".", "prefix": "none"}, + {"level": 2, "first": ".", "last": "", "prefix": "parent"}, + ], + "영월2024": [ + {"level": 3, "first": "", "last": "", "prefix": "none"}, + {"level": 2, "first": ".", "last": "", "prefix": "parent"}, + ], +} +_CACHE: dict = {} + + +def run(name): + if name not in _CACHE: + d = server.api_load(server.LoadReq(source_path=str(DOCS / PROJECTS[name]))) + d["itnum_plan"] = ITNUM_PLAN.get(name, []) + out = subprocess.check_output( + ["node", str(HERE / "helper_stmate_ui.cjs")], + input=json.dumps(d, ensure_ascii=False, default=str), + text=True, + encoding="utf-8", + ) + _CACHE[name] = (d, json.loads(out)) + return _CACHE[name] + + +@pytest.mark.parametrize("name", list(PROJECTS)) +def test_boq_structure_untouched_and_items_in_step(name): + _d, r = run(name) + assert r["structure"] == {"same": True, "nest": True, "items": r["structure"]["items"]} + assert r["structure"]["items"][0] == r["structure"]["items"][1] + + +@pytest.mark.parametrize("name", list(PROJECTS)) +def test_insert_delete_rows_move_direct_cost(name): + _d, r = run(name) + ins, dl = r["insert"], r["deleteOrig"] + assert ins["ok"] and ins["rowT"] > 0 and ins["delta"] == ins["rowT"] + assert ins["back"] == 0 and ins["deleted"] == [] # 새 행은 지워도 원본 지움 목록에 안 듦 + assert dl["ok"] and dl["deleted"] == [dl["recno"]] + assert dl["delta"] == -dl["leafT"] # 원본 행 지움 = 그 행 저장 금액만큼 직공비 감소 + + +@pytest.mark.parametrize("name", list(PROJECTS)) +def test_move_copy_paste_group_level(name): + d, r = run(name) + assert r["move"] == {"up": True, "down": True, "back": True, "badRejected": True, "nest": True} + g = r["group"] + ini = d["start_ini"] + assert g["wrap"] and g["eTotal"] == g["sumLeaf"] > 0 + assert g["eName"] == (ini.get("DISP_N1") or "소계").strip() + assert g["shift"] and g["shiftName"] == (ini.get("DISP_N3") or "합계").strip() + assert g["note"] and g["nest"] + p = r["paste"] + assert p["ok"] and p["added"] == p["clipRows"] and p["pairs"] >= 1 and p["nest"] + assert p["delta"] == p["srcT"] # 붙여넣은 묶음 금액만큼 직공비 증가 + + +@pytest.mark.parametrize("name", list(PROJECTS)) +def test_expand_ilwi_components(name): + _d, r = run(name) + if "expand" not in r: + pytest.skip("구성표 있는 일위대가 내역 행 없음") + e = r["expand"] + assert e["ok"] and e["made"] == e["want"] and e["deleted"] and e["closing"] == "E1" + + +@pytest.mark.parametrize("name", list(ITNUM_PLAN)) +def test_itnum_reproduces_original_sequence(name): + _d, r = run(name) + for lv in r["itnum"]: + got, want = lv["got"], lv["want"] + # 원본은 지운 공종 자리를 비워 둔 번호가 있음(영월 1.9) — 첫 빈자리 앞까지는 같아야 함 + k = next((i for i, (g, w) in enumerate(zip(got, want)) if g != w), len(got)) + assert k >= 3, (lv["level"], got[: k + 1], want[: k + 1]) + assert len(got) == len(want) + + +def test_qty_round_and_hal_rate(): + _d, r = run("봉화2024") + assert r["qty"]["first"] == [1, 12.34, 12.34, 12.34] + assert r["qty"]["second"] == [1, 12] + assert r["qty"]["hal"] == [1, "3", 3] + + +@pytest.mark.parametrize("name", list(PROJECTS)) +def test_initial_values_roundtrip(name): + d, r = run(name) + s = r["sys"] + assert s["changed"] == [] and s["errs"] == [] + conld = s["conld"] + assert s["cond"] == ", ".join(conld[i : i + 6] for i in range(0, len(conld), 6)) + assert s["xml"] == ["0", "2", True] + + +@pytest.mark.parametrize("name", list(PROJECTS)) +def test_unit_price_list(name): + _d, r = run(name) + a = r["dan"] + assert a["leafRecno"] in a["useBoq"] + assert a["counts"]["ALL"] == a["total"] + assert a["counts"]["BD"] == a["counts"]["B"] + a["counts"]["D"] + assert a["halFirst"] > 0 and a["halSecond"] == 0 # 기준단가 기준 — 두 번 적용해도 같은 값 + + +def test_menu_shortcuts_follow_original(): + _d, r = run("봉화2024") + m = r["menu"] + assert m["dec"] == ["F9", "Ctrl+A", "Ctrl+Shift+P", "Alt+Q", "Ctrl+Shift+Alt+Up"] + assert m["sc"]["F9"] == "Act_Calc_Grass" and m["sc"]["Ctrl+B"] == "Act_iLWi_San" + assert m["sc"]["Ctrl+N"] == "Act_PrjLst" and m["sc"]["Ctrl+F5"] == "Act_Refresh" + assert len(m["acts"]) == len(set(m["acts"])) diff --git a/tools/stmate-test/api_qty.py b/tools/stmate-test/api_qty.py index e01224e71..639c53ffe 100644 --- a/tools/stmate-test/api_qty.py +++ b/tools/stmate-test/api_qty.py @@ -15,7 +15,6 @@ import zipfile from fastapi import APIRouter, HTTPException from pydantic import BaseModel - from qty_qcode import build_qcode, read_qcode router = APIRouter() diff --git a/tools/stmate-test/api_report.py b/tools/stmate-test/api_report.py new file mode 100644 index 000000000..c308d5bfa --- /dev/null +++ b/tools/stmate-test/api_report.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""보고서 내보내기 끼워 넣기 (api_report.py) — 시트 모형 → 엑셀 · CSV · 조달청 XML 검증. + +근거: 지식DB STmate/old/20_분석 19 §3 · 20 §3 · 30(조달청 XSD). +시트 모형은 화면(scr_report.js)이 report_*.js 로 만들어 보냄 — 계산은 브라우저 엔진과 같은 코드. +""" + +from __future__ import annotations + +import pathlib +import urllib.parse + +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel + +from report_xlsx import build_csv, build_report_xlsx + +HERE = pathlib.Path(__file__).resolve().parent +XSD = ( + HERE.parent.parent + / "resources/knowledge/original/경쟁사 프로그램/STmate/old/30_원자료/조달청_공사원가호환규정.xsd" +) +router = APIRouter() + + +class ReportReq(BaseModel): + sheets: list[dict] = [] + filename: str = "STMATE_보고서.xlsx" + + +class XmlReq(BaseModel): + xml: str + + +def _disposition(name: str) -> str: + return f"attachment; filename*=UTF-8''{urllib.parse.quote(name)}" + + +@router.post("/api/report/xlsx") +def api_report_xlsx(req: ReportReq): + if not req.sheets: + raise HTTPException(status_code=400, detail="시트 모형 없음") + return Response( + content=build_report_xlsx(req.sheets), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": _disposition(req.filename)}, + ) + + +@router.post("/api/report/csv") +def api_report_csv(req: ReportReq): + if not req.sheets: + raise HTTPException(status_code=400, detail="시트 모형 없음") + body = "" + build_csv(req.sheets[0]) + name = req.filename if req.filename.endswith(".csv") else req.filename + ".csv" + return Response( + content=body.encode("utf-8"), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": _disposition(name)}, + ) + + +@router.post("/api/report/validate-pps") +def api_validate_pps(req: XmlReq): + """조달청 호환규정 XSD(V1.0.5) 검증 — 위반 줄을 돌려줌.""" + from lxml import etree + + schema = etree.XMLSchema(etree.parse(str(XSD))) + try: + doc = etree.fromstring(req.xml.encode("utf-8")) + except etree.XMLSyntaxError as e: + return {"ok": False, "errors": [str(e)]} + ok = schema.validate(doc) + return {"ok": ok, "errors": [str(e) for e in list(schema.error_log)[:20]]} diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js index 39b6360fa..02f9c9757 100644 --- a/tools/stmate-test/app.js +++ b/tools/stmate-test/app.js @@ -134,7 +134,7 @@ const sel = $("top-project-select"); if (sel) sel.value = sourcePath; if (badge) badge.textContent = `DBF/SMT 직결 (${data.costn_count}단가·${data.boq_count}내역)`; - Object.assign(ctx.uiState, { boqDeleted: [], boqSel: [] }); + Object.assign(ctx.uiState, { boqSel: [], sysDraft: null }); if (API.noteRecent) API.noteRecent(sourcePath); renderWorkspace(); } catch (err) { @@ -161,7 +161,10 @@ const kj = ctx.computed.kanjubComputed, bt = ctx.computed.boqComputed.totals; try { - const saved = await postJson("/api/save", decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx))); + const saved = await postJson( + "/api/save", + decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)), + ); renderWorkspace(); alert( `[계산완료(Act_Calc_All) 및 DBF/SMT/STC 저장 성공]\n• 5단 연쇄재계산: 중기(X) ${stat.xCount}건 · 산근(D) ${stat.dCount}건 · 일위대가(B) ${stat.bCount}건\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`, @@ -171,14 +174,10 @@ } } - // 저장 묶음 덧붙임 — 내역 행 차례·새 행·지운 행(boq_layout) · 초기값(start_ini) · 공사목록(prjlst_rows) + // 저장 묶음 덧붙임 — 초기값(start_ini) · 공사목록 전체 행(prjlst_rows) · 서버 반영은 파일 담당 function decorateSavePayload(p) { const d = ctx.dbfData; if (!d) return p; - const keys = "_recno,_new,SORTCODE,ENDCODE,BDCODE1,ITNUM,NAME1,SIZE1,UNIT1,QTY1,RATE,RM,RL,RS"; - const pick = (r) => Object.fromEntries(keys.split(",").map((k) => [k, r[k]])); - const deleted = (ctx.uiState.boqDeleted || []).slice(); - p.boq_layout = { table: d.boq_table_name, deleted, rows: (d.boq_rows || []).map(pick) }; return Object.assign(p, { start_ini: d.start_ini || {}, prjlst_rows: d.prjlst_all || [] }); } @@ -208,15 +207,13 @@ const p = btn.getAttribute("data-path"); p === ctx.currentSourcePath ? goScreen("wBoq") : loadProjectFromServer(p, "wPrjList"); }); - "prj-btn-open-boq:wBoq,prj-btn-kanjub:wM_KanJub,prj-btn-copy-reg:wStage,kj-btn-open-rate:wM_KAN_RATE" + "prj-btn-open-boq:wBoq,prj-btn-kanjub:wM_KanJub,kj-btn-open-rate:wM_KAN_RATE" .split(",") .forEach((p) => { const [id, scr] = p.split(":"); onClick(id, () => goScreen(scr)); }); - ["prj-btn-pack-stc", "boq-btn-save-dbf", "kj-btn-save-dbf"].forEach((id) => - onClick(id, saveAllToDbfServer), - ); + ["boq-btn-save-dbf", "kj-btn-save-dbf"].forEach((id) => onClick(id, saveAllToDbfServer)); onClick("prj-btn-toggle-preview", () => { ctx.uiState.showBoqPreview = !ctx.uiState.showBoqPreview; renderWorkspace(); diff --git a/tools/stmate-test/app_modals.js b/tools/stmate-test/app_modals.js index b6483d335..b2011aa0d 100644 --- a/tools/stmate-test/app_modals.js +++ b/tools/stmate-test/app_modals.js @@ -37,8 +37,12 @@ const { ctx, renderWorkspace } = api; const B = global.STMATE_UI_BOQ; if (!ctx.dbfData || !ctx.dbfData.boq_rows) return alert("열린 내역서 없음"); - ctx.uiState.boqDeleted = ctx.uiState.boqDeleted || []; - const model = { rows: ctx.dbfData.boq_rows, items: ctx.state.boqItems, deleted: ctx.uiState.boqDeleted }; + ctx.dbfData.boq_deleted = ctx.dbfData.boq_deleted || []; + const model = { + rows: ctx.dbfData.boq_rows, + items: ctx.state.boqItems, + deleted: ctx.dbfData.boq_deleted, + }; const c = String(code || "").trim(); const cr = (ctx.dbfData.costn_rows || []).find((r) => String(r.CODE || "").trim() === c); const row = B.makeRow(model, { diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js index 2bfacb5c1..e11598246 100644 --- a/tools/stmate-test/calc.js +++ b/tools/stmate-test/calc.js @@ -297,6 +297,15 @@ const StmateCalc = (() => { const u = su ? Object.assign({}, lookupUnit(row.refId), { M: su[0], L: su[1], S: su[2] }) : lookupUnit(row.refId); + // 행 고정 단가(fixedUnit) · 내역 할증율(wBoqRate RATE/RM·RL·RS = 비목별 단가 가산, 원 단위 절사) + // — 수량 할증(BDQTY X_HAL)과 다른 금액 가산(07번 §4 · 34번 wBoqRate) · 저장 차수 단가는 이미 반영된 값이라 건너뜀 + if (!su && row.fixedUnit) + ["M", "L", "S"].forEach((c) => row.fixedUnit[c] != null && (u[c] = num(row.fixedUnit[c]))); + const hr = row.halRate; + if (!su && hr != null) { + const pct = (c) => num(typeof hr === "object" ? hr[c] : hr); + ["M", "L", "S"].forEach((c) => pct(c) && (u[c] = roundDown(u[c] * (1 + pct(c) / 100), 0))); + } u.T = u.M + u.L + u.S; const qty = Number( diff --git a/tools/stmate-test/report_book.js b/tools/stmate-test/report_book.js index 2ee7587f3..85f2667ec 100644 --- a/tools/stmate-test/report_book.js +++ b/tools/stmate-test/report_book.js @@ -20,7 +20,12 @@ const StmateReportBook = (() => { const { dbf, idx, ini, po } = ctx; const sh = C.newSheet("자재단가대비표", "자재단가 대비표", C.projectName(dbf), "R"); const slots = ["E", "G", "I", "K", "M"]; - const h3 = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격", D: "단위" }; + const h3 = { + A: po.codeMode ? "코드번호" : "호표", + B: "명 칭", + C: "규 격", + D: "단위", + }; const h4 = { _k: "head" }; slots.forEach((c, i) => { h3[c] = str(ini[`JMOK${i + 1}`] || "").trim() || `가격${i + 1}`; @@ -72,24 +77,34 @@ const StmateReportBook = (() => { // 목차 시트(〓 목 차 〓) — 시트 이름과 바로가기(엑셀에서 하이퍼링크) function tocSheet(names, pname) { const sh = { name: "〓 목 차 〓", title: "목 차", rows: [], cols: {} }; - C.put(sh, 2, { B: "목 차", D: "※ 본 엑셀자료는 STMATE 재현 앱에서 만든 출력본입니다.", _k: "title" }); + C.put(sh, 2, { + B: "목 차", + D: "※ 본 엑셀자료는 STMATE 재현 앱에서 만든 출력본입니다.", + _k: "title", + }); C.put(sh, 3, { D: ` 공사명 : ${pname}` }); - ["※※안내※※"].concat(names).forEach((n, i) => - C.put(sh, 4 + i, { B: `${i + 1}. ${n}`, D: `${n} →`, _link: n }), - ); + ["※※안내※※"] + .concat(names) + .forEach((n, i) => C.put(sh, 4 + i, { B: `${i + 1}. ${n}`, D: `${n} →`, _link: n })); return sh; } // 안내 시트(※※안내※※) — 원본 안내문 요지 + 금액 확인 줄 function guideSheet(k) { const sh = { name: "※※안내※※", title: "안내", rows: [], cols: {} }; - C.put(sh, 2, { A: "목차 →", B: "=== 엑셀파일에 작성에 대한 안내문(반드시 읽어보세요) ===", _link: "〓 목 차 〓" }); + C.put(sh, 2, { + A: "목차 →", + B: "=== 엑셀파일에 작성에 대한 안내문(반드시 읽어보세요) ===", + _link: "〓 목 차 〓", + }); C.put(sh, 4, { B: "◈ 금액은 STMATE 재현 앱 계산 엔진(calc.js) 결과를 값으로 적었습니다." }); C.put(sh, 5, { B: "◈ 각 시트의 비목 열 순서는 설정(BMKNM)을 따릅니다." }); C.put(sh, 6, { B: "◈ [목차] 및 [안내] 시트의 이름을 바꾸지 마십시요." }); if (k) { C.put(sh, 8, { B: "※※※ 아래의 내용(안내 및 오류)을 확인/검토 하세요 ※※※" }); - C.put(sh, 9, { C: `【총괄내역서】 총공사비 = ${C.commas(k.grandTotal)}원 (이윤 보정액 ${C.commas(k.pretaxProfitAdj || 0)}원)` }); + C.put(sh, 9, { + C: `【총괄내역서】 총공사비 = ${C.commas(k.grandTotal)}원 (이윤 보정액 ${C.commas(k.pretaxProfitAdj || 0)}원)`, + }); } return sh; } diff --git a/tools/stmate-test/report_boq.js b/tools/stmate-test/report_boq.js index 95e41baf7..9bb68b015 100644 --- a/tools/stmate-test/report_boq.js +++ b/tools/stmate-test/report_boq.js @@ -193,7 +193,12 @@ const StmateReportBoq = (() => { ["ANJUN", "산업안전보건관리비", "산업안전보건관리비", ""], ["KITA", "기타경비", "기 타 경 비", "(직접노무비+간접노무비+재료비)"], ["HWAN", "환경보전비", "환 경 보 전 비", "(재료비 + 직접노무비 + 산출경비)"], - ["EQIP", "건설기계대여금지급보증 금액", "건설기계대여금지급보증 금액", "(재료비 + 직접노무비 + 산출경비)"], + [ + "EQIP", + "건설기계대여금지급보증 금액", + "건설기계대여금지급보증 금액", + "(재료비 + 직접노무비 + 산출경비)", + ], ]; // 안전관리비 설명 — A) 관급재/1.1 포함 · B) 관급 빼고 1.2배 상한(지식DB 06) @@ -255,7 +260,9 @@ const StmateReportBoq = (() => { } const k = stage.kanjub; const L = costLines(k, stage.cfg); - const direct = design._tree.finalRow ? design._tree.finalRow.node.sum : { M: k.dM, L: k.dL, S: k.dS }; + const direct = design._tree.finalRow + ? design._tree.finalRow.node.sum + : { M: k.dM, L: k.dL, S: k.dS }; const ga = { A: " 가.", B: "순공사비계", _k: "sub" }; put4(ga, direct); C.push(sh, ga); @@ -266,14 +273,30 @@ const StmateReportBoq = (() => { if (note) cells.E = note; return C.push(sh, Object.assign(cells, extra || {})); }; - const sub = (a, label, amount) => C.push(sh, { A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub" }); - line("간접노무비", k.kanno, L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`); + const sub = (a, label, amount) => + C.push(sh, { A: ` ${a}`, B: ` ${label}`, D: amount, _k: "sub" }); + line( + "간접노무비", + k.kanno, + L.fixedKeys.has("KANNO") ? "" : `직접노무비 x ${rateText(k.itemMap.KANNO.rate)}%`, + ); for (const e of L.exp.filter((x) => x.code !== "KANNO")) - line(e.short, e.amount, e.note, e.code === "ANJUN" && L.govMat > 0 ? { C: "A)관급재/1.1포함 적용" } : null); + line( + e.short, + e.amount, + e.note, + e.code === "ANJUN" && L.govMat > 0 && !L.fixedKeys.has("ANJUN") + ? { C: "A)관급재/1.1포함 적용" } + : null, + ); for (const a of L.adds(1)) line(a.name, num(a.amount)); const na = k.pureConstructionCost; sub("나.", "소 계", na); - line("일반관리비", k.ilbanAmt, L.fixedKeys.has("ILBAN") ? "" : `(나.소 계) x ${rateText(num(stage.cfg?.rates?.ILBAN))}%`); + line( + "일반관리비", + k.ilbanAmt, + L.fixedKeys.has("ILBAN") ? "" : `(나.소 계) x ${rateText(num(stage.cfg?.rates?.ILBAN))}%`, + ); sub("다.", "소 계", na + k.ilbanAmt); const iyunR = rateText(num(stage.cfg?.rates?.IYUN)); line("이 윤", k.iyunFinal, `(다.소계 - 재료비) x ${iyunR}% = ${commas(k.iyunRaw)}`); @@ -284,17 +307,25 @@ const StmateReportBoq = (() => { for (const a of L.adds(3)) line(a.name, num(a.amount)); sub("마.", "도급공사비", k.contractFinal); if (k.govFinal) - line(govLabel(dbf, stage, false), k.govFinal, `원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`); + line( + govLabel(dbf, stage, false), + k.govFinal, + `원자재대: ${commas(L.govMat)} (조달수수료: ${commas(k.govFeeTotal)} 포함)`, + ); for (const a of L.adds(4)) line(a.name, num(a.amount)); sub("바.", "총공사비", k.grandTotal); return sh; } // 부가세 설명 — I_BUGA 갈래(공급가액 · 산림조합 · 면세품 · 재료비) + // 설정 두 꼴 — 옛 vatMode/vatExempt · 새 keys(I_BUGA · C_BUGA_M 원키) + const VAT_MODES = { 0: "supply_10", 1: "mat_10", 2: "zero", 3: "forest", 4: "forest_exempt" }; function vatNote(cfg) { - const m = (cfg && cfg.vatMode) || "supply_10"; - if (m === "forest_exempt") - return `(재료비 - 면세품목:₩${commas(num(cfg.vatExempt))} + 산출경비) x 10%`; + const keys = (cfg && cfg.keys) || {}; + const m = + (cfg && cfg.vatMode) || ("I_BUGA" in keys ? VAT_MODES[num(keys.I_BUGA)] : "") || "supply_10"; + const exempt = cfg && cfg.vatExempt != null ? num(cfg.vatExempt) : num(keys.C_BUGA_M); + if (m === "forest_exempt") return `(재료비 - 면세품목:₩${commas(exempt)} + 산출경비) x 10%`; if (m === "forest") return "(재료비 + 산출경비) x 10%"; if (m === "mat_10") return "재료비 x 10%"; return m === "zero" ? "" : "총 원 가 x 10%"; @@ -325,13 +356,21 @@ const StmateReportBoq = (() => { add("sL", { C: "소 계" }, k.totalLabor); add("dS", { B: "경 비", C: "산 출 경 비" }, k.dS); for (const e of L.exp.filter((x) => x.code !== "KANNO")) { - const note = e.code === "ANJUN" && stage.cfg.anjunAuto === 2 && stage.slot > 1 ? "당초금액 + 안전관리비 증감액" : e.note; + const note = + e.code === "ANJUN" && stage.cfg.anjunAuto === 2 && stage.slot > 1 + ? "당초금액 + 안전관리비 증감액" + : e.note; add(e.code, { C: e.long }, e.amount, note); } for (const a of L.adds(1)) add(`A1${a.name}`, { C: a.name }, num(a.amount)); add("sS", { C: "소 계" }, k.totalExpense); const ilR = rateText(num(stage.cfg?.rates?.ILBAN)); - add("ILBAN", { A: "일 반 관 리 비" }, k.ilbanAmt, L.fixedKeys.has("ILBAN") ? "" : `순공사원가 x ${ilR}%`); + add( + "ILBAN", + { A: "일 반 관 리 비" }, + k.ilbanAmt, + L.fixedKeys.has("ILBAN") ? "" : `순공사원가 x ${ilR}%`, + ); const iyR = rateText(num(stage.cfg?.rates?.IYUN)); add("IYUN", { A: "이 윤" }, k.iyunFinal, `(노무비+경비+일반관리비) x ${iyR}%`); for (const a of L.adds(2)) add(`A2${a.name}`, { A: a.name }, num(a.amount)); @@ -353,7 +392,12 @@ const StmateReportBoq = (() => { * base(당초 차수)를 주면 변경 양식 — 당초 · 변경 · 증감 · 구성비(변경) · 비고(변경) */ function wonSheet(dbf, stage, base) { - const sh = C.newSheet("공사원가계산서", "공 사 원 가 계 산 서", C.projectName(dbf), base ? "I" : "G"); + const sh = C.newSheet( + "공사원가계산서", + "공 사 원 가 계 산 서", + C.projectName(dbf), + base ? "I" : "G", + ); const now = wonRows(dbf, stage); const grand = stage.kanjub.grandTotal || 1; if (!base) { diff --git a/tools/stmate-test/report_core.js b/tools/stmate-test/report_core.js index 4e2cf309f..e63edabad 100644 --- a/tools/stmate-test/report_core.js +++ b/tools/stmate-test/report_core.js @@ -55,7 +55,10 @@ const StmateReportCore = (() => { // 시트 머리 — 1행 제목 · 2행 공사명 · 오른쪽 끝 시트 이름표 function newSheet(name, title, pname, tagCol) { - const rows = [{ A: title, _k: "title" }, { A: `공사명 : ${pname}`, _k: "pname" }]; + const rows = [ + { A: title, _k: "title" }, + { A: `공사명 : ${pname}`, _k: "pname" }, + ]; if (tagCol) rows[1][tagCol] = name; return { name, title, rows, cols: {} }; } @@ -117,9 +120,7 @@ const StmateReportCore = (() => { if (!n) return adj; const ord = order.filter((k) => "MLS".includes(k)); for (const k of ord) { - const ahead = ord.filter((o) => - sign > 0 ? frac[o] > frac[k] : frac[o] < frac[k], - ).length; + const ahead = ord.filter((o) => (sign > 0 ? frac[o] > frac[k] : frac[o] < frac[k])).length; const tieAhead = ord.slice(0, ord.indexOf(k)).filter((o) => frac[o] === frac[k]).length; if (ahead + tieAhead < n) adj[k] = sign * step; } @@ -127,7 +128,9 @@ const StmateReportCore = (() => { } function compositionLines(rows, lookup, opts = {}) { - const order = String(opts.order || "SML").toUpperCase().split(""); + const order = String(opts.order || "SML") + .toUpperCase() + .split(""); const lines = []; const marked = { M: 0, L: 0, S: 0 }; const sum = { M: 0, L: 0, S: 0 }; @@ -160,7 +163,13 @@ const StmateReportCore = (() => { if (/^C\d$/.test(c)) closed = true; // 글 줄 — 코드 없는 설명 줄(EXIST1=1) if (!c && String(row.EXIST1 || "").trim() === "1") { - lines.push({ code: "", text: true, qty: 0, u: { M: 0, L: 0, S: 0 }, a: { M: 0, L: 0, S: 0 } }); + lines.push({ + code: "", + text: true, + qty: 0, + u: { M: 0, L: 0, S: 0 }, + a: { M: 0, L: 0, S: 0 }, + }); continue; } const r = lookup(c); @@ -194,7 +203,16 @@ const StmateReportCore = (() => { sum[k] += a[k]; if (isMarked) marked[k] += a[k]; } - lines.push({ code: c, ref: r, qty: q, unit, u: p, a, marked: isMarked, labor: !!opts.labor?.has(c) }); + lines.push({ + code: c, + ref: r, + qty: q, + unit, + u: p, + a, + marked: isMarked, + labor: !!opts.labor?.has(c), + }); } const raw = { M: fix(sum.M, 4), L: fix(sum.L, 4), S: fix(sum.S, 4) }; const tot = { M: trunc(raw.M), L: trunc(raw.L), S: trunc(raw.S) }; diff --git a/tools/stmate-test/report_pps.js b/tools/stmate-test/report_pps.js new file mode 100644 index 000000000..a900d2ead --- /dev/null +++ b/tools/stmate-test/report_pps.js @@ -0,0 +1,385 @@ +/* -*- coding: utf-8 -*- */ +/** + * 조달청 공사원가호환규정 XML (report_pps.js) — 스키마 V1.0.5 한글 요소 이름 그대로 + * 근거: 지식DB STmate/old/20_분석 30(조달청 호환규정 XSD) · old/30_원자료/조달청_공사원가호환규정.xsd + * 28 §3(Conv_ST8_XML 변환) · 20 §2.2(간접비 코드 301~370) + * 정답 대조: resources/tester/test_stmate_report_pps.py (XSD 검증 + 금액 합 = 원가계산서) + * 짜임: 공사원가호환규정 → 작성정보 · 공사정보(기계경비산출) · 공사내역서(공사제경비 · 단일공사내역서) + */ +const StmateReportPps = (() => { + const g = typeof globalThis !== "undefined" ? globalThis : {}; + const C = g.STMATE_REPORT_CORE || require("./report_core.js"); + const U = g.STMATE_REPORT_UNIT || require("./report_unit.js"); + const B = g.STMATE_REPORT_BOQ || require("./report_boq.js"); + const { num, str, code } = C; + + const esc = (s) => + String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + // normalizedString — 줄바꿈·탭 없음 · 길이 제한(XSD maxLength) + const ns = (s, max) => + String(s ?? "") + .replace(/[\r\n\t]+/g, " ") + .trim() + .slice(0, max); + const codeOf = (s, max) => + String(s ?? "") + .toUpperCase() + .replace(/[^0-9A-Z]/g, "") + .slice(0, max); + const dbl = (v) => { + const n = Number(v); + return Number.isFinite(n) ? String(C.fix(n, 6)) : "0"; + }; + const tag = (name, inner) => `<${name}>${inner}`; + const leaf = (name, v) => `<${name}>${esc(v)}`; + const RES = { M: "자재", L: "노무", S: "경비항목" }; + // 자원구분 — 기계경비상세는 손료(경비 천원 단위)·기계경비 갈래 · 그 밖은 일위대가 + const resKind = (c, machine, unit) => + machine && c[0] === "S" && unit === "천원" + ? "손료" + : RES[c[0]] || (machine ? "기계경비" : "일위대가"); + + // 단가 3칸 + 금액 3칸 + const money = (u, a) => + leaf("재료비단가", dbl(u.M)) + + leaf("노무비단가", dbl(u.L)) + + leaf("경비단가", dbl(u.S)) + + leaf("재료비", dbl(a.M)) + + leaf("노무비", dbl(a.L)) + + leaf("경비", dbl(a.S)); + + // 구성 줄 — 일위대가상세 · 기계경비상세 + function detailLines(ev, machine) { + const nm = machine ? "기계경비상세" : "일위대가상세"; + return ev.lines + .filter((ln) => !ln.text) + .map((ln) => { + const c = ln.code === "PR" ? "PR" : ln.code; + const ref = ln.ref || {}; + let body = + leaf("자원구분", resKind(c, machine, String(ref.unit || "").trim())) + + leaf("자원코드", codeOf(c, 17) || "PR") + + leaf("계산제외", "false") + + leaf("품명", ns(ref.name || "비율 항목", 50)) + + leaf("규격", ns(ref.spec || "", 100)) + + leaf("단위", ns(ln.pct ? "%" : ref.unitDisp || ln.unit || "", 10)) + + leaf("수량", dbl(ln.qty)) + + money(ln.u, ln.a); + if (!machine) body += leaf("관급", "false"); + return tag(nm, body + leaf("비고", "")); + }) + .join(""); + } + + // 공사제경비 — 원가계산서 줄(간접비 코드 · 식 설명 · 요율 · 금액) + function costItems(stage) { + const k = stage.kanjub; + const rates = (stage.cfg && stage.cfg.rates) || {}; + const DTP = { KANNO: 201, SANBO: 301, KOBO: 302, KUNBO: 303, SLVBO: 310, YUNBO: 304 }; + Object.assign(DTP, { TBUGUM: 305, ANJUN: 306, KITA: 321, HWAN: 309, EQIP: 311 }); + const out = []; + const item = (dtp, sum, name, formula, rate, add, amount, desc) => + out.push( + tag( + "제경비항목", + leaf("세부공종코드", String(dtp)) + + leaf("집계항목여부", sum ? "true" : "false") + + leaf("비목명", ns(name, 50)) + + leaf("적용산식", ns(formula, 200)) + + leaf("적용요율", dbl(rate)) + + leaf("추가금액", dbl(add)) + + leaf("산출금액", dbl(amount)) + + leaf("적용산식설명", ns(desc, 200)), + ), + ); + item(100, true, "순공사비계", "재료비+노무비+경비", 0, 0, k.dT, "직접공사비"); + for (const [cd, short] of B.ITEMS) { + const it = k.itemMap[cd]; + if (!it || !it.amount) continue; + const add = cd === "ANJUN" ? num(stage.cfg.anjunBaseAdd) : 0; + item( + DTP[cd], + false, + short, + `${it.baseDesc || ""} x ${it.rate}%`, + it.rate, + add, + it.amount, + it.baseDesc || "", + ); + } + item(400, true, "소계", "순공사원가", 0, 0, k.pureConstructionCost, "재료비+노무비+경비"); + item( + 401, + false, + "일반관리비", + `순공사원가 x ${num(rates.ILBAN)}%`, + num(rates.ILBAN), + 0, + k.ilbanAmt, + k.ilbanDesc || "", + ); + item( + 402, + false, + "이윤", + `(노무비+경비+일반관리비) x ${num(rates.IYUN)}%`, + num(rates.IYUN), + 0, + k.iyunFinal, + k.iyunDesc || "", + ); + item(500, true, "공급가액", "순공사원가+일반관리비+이윤", 0, 0, k.supplyFinal, "총원가"); + item(502, false, "부가가치세", "공급가액 x 10%", 10, 0, k.vatFinal, "부가가치세"); + item(600, true, "도급공사비", "공급가액+부가가치세", 0, 0, k.contractFinal, "도급액"); + if (k.govFinal) item(653, false, "관급자재대", "관급자재 합계", 0, 0, k.govFinal, "관급자재"); + item(700, true, "총공사비", "도급공사비+관급자재대", 0, 0, k.grandTotal, "총공사비"); + return tag("공사제경비", out.join("")); + } + + // 내역서 — 공종(T 묶음)마다 하나 · 내역서번호 = 단계별 두 자리 · 세부공종 = 직속 품목 + function boqXml(tree, idx) { + const out = []; + const walk = (node, prefix, govFlag, exFlag) => { + let i = 0; + const items = node.children.filter((c) => c.kind === "item"); + if (node.kind === "T" && node.row) { + const r = node.row; + const ex = exFlag || node.excluded; + const gov = govFlag || (node.excluded && /관급/.test(str(r.NAME1))); + const kind = ex ? (gov ? "관급자재공종" : "제요율적용제외공종") : "일반공종"; + const sub = items + .map((it) => { + const u = it.unitP, + a = it.amt; + return tag( + "세부공종", + leaf("세부공종코드", codeOf(it.code, 17)) + + leaf("자원구분", resKind(it.code, false)) + + leaf("계산제외", "false") + + leaf("세부공종번호", ns(it.row.ITNUM, 20)) + + leaf("세부공종명", ns(it.row.NAME1, 50)) + + leaf("규격", ns(it.row.SIZE1, 100)) + + leaf("단위", ns(C.unitText(it.row.UNIT1), 10)) + + leaf("수량", dbl(it.qty)) + + money(u, a) + + leaf("관급", gov ? "true" : "false") + + leaf("자재구분", "구분없음") + + leaf("세부공종구분", "일반내역") + + leaf("비고", ns((idx.by[it.code] && idx.by[it.code].BQCODE) || "", 50)), + ); + }) + .join(""); + out.push( + tag( + "내역서", + leaf("내역서번호", prefix) + + leaf("공종구분", kind) + + leaf("계산제외", "false") + + leaf("공종번호", ns(r.ITNUM, 20)) + + leaf("공종명", ns(r.NAME1 || r.SIZE1 || "공종", 50)) + + leaf("재료비", dbl(node.sum.M)) + + leaf("노무비", dbl(node.sum.L)) + + leaf("경비", dbl(node.sum.S)) + + leaf("비고", "") + + sub, + ), + ); + govFlag = gov; + exFlag = ex; + } + for (const ch of node.children) + if (ch.kind === "T") walk(ch, prefix + String(++i).padStart(2, "0"), govFlag, exFlag); + }; + walk(tree.root, "", false, false); + return out.join(""); + } + + /** + * XML 문서 — dbf(불러온 자료) · state · computed(calculateAll) · n 차수 + * opts.today = 파일제작일(YYYY-MM-DD) · opts.company = 작성업체명 + */ + function buildPpsXml(dbf, state, computed, n = 1, opts = {}) { + const ctx = U.unitContext(dbf); + const { idx, ini } = ctx; + const stage = B.stageOf(state, computed, n); + const design = B.designSheet(dbf, stage); + const tree = design._tree; + const pr = (dbf && dbf.prjlst) || {}; + const pname = ns(C.projectName(dbf) || "공사", 100); + const today = opts.today || new Date().toISOString().slice(0, 10); + const rx = String(ini.RXXNM || "1/8*16/12*25/20"); + const coef = C.fix(eval1(rx), 8); + const writer = tag( + "작성정보", + leaf("작성업체명", ns(opts.company || ini.SUBNM || "작성업체", 50)) + + leaf("프로그램명", "STMATE w 8.0 재현") + + leaf("프로그램버전", "8.0") + + leaf("파일제작일", today) + + leaf("파일검증여부", "false"), + ); + const mach = tag( + "기계경비산출", + tag( + "환율", + leaf("미국_1USD", dbl(num(ini.EXLNM1))) + + leaf("유럽연합_1EUR", dbl(num(ini.EXEUR1))) + + leaf("일본_100엔", dbl(num(ini.EXYEN1))), + ) + + tag( + "시간당노임산출", + tag( + "H0-일반구간", + leaf("산출식", ns(rx, 200)) + leaf("계산값", dbl(coef)) + leaf("적용구분", "산출식"), + ), + ), + ); + const info = tag( + "공사정보", + leaf("공사관리번호", ns(pr.PNUM || pr.PCODE || "0", 20) || "0") + + leaf("주공사", "토목공사") + + leaf("공사명", pname) + + leaf("비고", "") + + mach, + ); + const head = (c, r, tot, extra) => + leaf(extra, codeOf(c, extra === "기계경비코드" ? 17 : 12)) + + leaf("번호", String(ctx.hopyoNo[c] || 1)) + + leaf("품명", ns(r.NAME, 50) || c) + + leaf("규격", ns(r.SIZE, 100)) + + leaf("단위", ns(C.unitText(r.UNIT), 10)); + const ilwi = idx + .ofPrefix("B") + .map((r) => { + const c = code(r), + ev = ctx.evals[c], + t = ctx.totals[c] || idx.price(c, 1); + const bm = str(r.SBMOK).trim() === "S" ? "경비" : "합계"; + return tag( + "일위대가", + head(c, r, t, "일위대가코드") + + leaf("재료비", dbl(t.M)) + + leaf("노무비", dbl(t.L)) + + leaf("경비", dbl(t.S)) + + leaf("비목구분", bm) + + leaf("비고", ns(r.BQCODE, 50)) + + (ev ? detailLines(ev, false) : ""), + ); + }) + .join(""); + const memos = (dbf && dbf.costn_memos) || {}; + const san = idx + .ofPrefix("D") + .map((r) => { + const c = code(r), + p = idx.price(c, 1); + return tag( + "중기단가산출", + head(c, r, p, "중기단가산출코드") + + leaf("재료비", dbl(p.M)) + + leaf("노무비", dbl(p.L)) + + leaf("경비", dbl(p.S)) + + leaf("비목구분", "합계") + + leaf("비고", ns(r.BQCODE, 50)) + + leaf("중기단가산출식", (memos[c] && memos[c].text) || ""), + ); + }) + .join(""); + const machine = idx + .ofPrefix("X") + .map((r) => { + const c = code(r), + ev = ctx.evals[c], + t = ctx.totals[c] || idx.price(c, 1); + return tag( + "기계경비", + head(c, r, t, "기계경비코드") + + leaf("작업시간", "E0") + + leaf("작업장소", "H0") + + leaf("재료비", dbl(t.M)) + + leaf("노무비", dbl(t.L)) + + leaf("경비", dbl(t.S)) + + leaf("비고", ns(r.BQCODE, 50)) + + (ev ? detailLines(ev, true) : ""), + ); + }) + .join(""); + const base = (r) => + leaf("품명", ns(r.NAME, 50) || code(r)) + + leaf("규격", ns(r.SIZE, 100)) + + leaf("단위", ns(C.unitText(r.UNIT), 10)); + const res = (p, nm, codeTag, body) => + idx + .ofPrefix(p) + .map((r) => tag(nm, leaf(codeTag, codeOf(code(r), 16)) + base(r) + body(r))) + .join(""); + const pv = (r) => idx.price(code(r), 1); + const mats = res( + "M", + "자재", + "자재코드", + (r) => leaf("재료비단가", dbl(pv(r).M + pv(r).L + pv(r).S)) + leaf("비고", ns(r.BQCODE, 50)), + ); + const labs = res( + "L", + "노무", + "노무코드", + (r) => leaf("노무비단가", dbl(pv(r).M + pv(r).L + pv(r).S)) + leaf("비고", ns(r.BQCODE, 50)), + ); + const exps = idx + .ofPrefix("S") + .map((r) => { + const p = pv(r); + const machineCost = str(r.UNIT).trim() === "천원"; + return tag( + "경비항목", + leaf("경비항목코드", codeOf(code(r), 16)) + + leaf("경비구분", machineCost ? "건설기계" : "일반경비") + + base(r) + + leaf("통화구분", "대한민국_천원") + + leaf("재료비단가", dbl(p.M)) + + leaf("노무비단가", dbl(p.L)) + + leaf("경비단가", dbl(p.S)) + + leaf("비고", ns(r.BQCODE, 50)), + ); + }) + .join(""); + const single = tag( + "단일공사내역서", + tag("단일공사정보", leaf("공사구분", "토목공사") + leaf("공사설명", pname)) + + boqXml(tree, idx) + + ilwi + + san + + machine + + mats + + labs + + exps, + ); + return ( + '\n' + + tag( + "공사원가호환규정", + leaf("스키마버전", "1.0.5") + writer + info + tag("공사내역서", costItems(stage) + single), + ) + ); + } + + // 노임 환산 계수 — 식을 왼쪽부터 곱·나눔(Start8x.Ini RXXNM) + function eval1(expr) { + let acc = 1; + for (const [, op, n] of String(expr || "") + .replace(/\s+/g, "") + .matchAll(/([*/])?(\d+(?:\.\d+)?)/g)) + acc = op === "/" ? acc / Number(n) : acc * Number(n); + return acc; + } + + return { buildPpsXml, eval1 }; +})(); + +if (typeof window !== "undefined") window.STMATE_REPORT_PPS = StmateReportPps; +if (typeof module !== "undefined" && module.exports) module.exports = StmateReportPps; diff --git a/tools/stmate-test/report_unit.js b/tools/stmate-test/report_unit.js index 2ddc25421..26795df0f 100644 --- a/tools/stmate-test/report_unit.js +++ b/tools/stmate-test/report_unit.js @@ -74,7 +74,8 @@ const StmateReportUnit = (() => { p === "B" || p === "D" ? `${po.kind[p]}${pad(n, 4)}호표` : `${po.kind[p] || ""}${pad(n, 5)}`; // 목록 첫 칸 호표 글 — 일위대가·산근 「제 n호표」 · 중기·자원 번호 function hopyoLabel(po, prefix, n) { - if (prefix === "B" || prefix === "D" || (prefix === "X" && po.padNo)) return `제${pad(n, 4)}호표`; + if (prefix === "B" || prefix === "D" || (prefix === "X" && po.padNo)) + return `제${pad(n, 4)}호표`; return po.padNo ? pad(n, 4) : String(n); } @@ -138,7 +139,14 @@ const StmateReportUnit = (() => { const { dbf, idx, ini, order, po, hopyoNo } = ctx; const isB = prefix === "B"; const sh = C.newSheet(name, title, C.projectName(dbf), "N"); - const h3 = { A: "명 칭", B: "규 격", C: "수 량", D: "단위", M: "비 고", _k: "head" }; + const h3 = { + A: "명 칭", + B: "규 격", + C: "수 량", + D: "단위", + M: "비 고", + _k: "head", + }; const h4 = { _k: "head" }; order.forEach((k, i) => { h3[PAIR_COLS[i][0]] = C.bimokLabel(ini, k); @@ -165,7 +173,11 @@ const StmateReportUnit = (() => { if (!ev) continue; for (const ln of ev.lines) { if (ln.text) { - C.push(sh, { C: 0, _k: "text", M: po.remarkHopyo && !po.codeMode ? " " : undefined }); + C.push(sh, { + C: 0, + _k: "text", + M: po.remarkHopyo && !po.codeMode ? " " : undefined, + }); continue; } const ref = ln.ref; @@ -219,7 +231,14 @@ const StmateReportUnit = (() => { let acc = daily; for (const [, op, n] of rx.replace(/\s+/g, "").matchAll(/([*/])?(\d+(?:\.\d+)?)/g)) acc = op === "/" ? acc / Number(n) : acc * Number(n); - C.put(sh, r++, { C: p.name, D: daily, E: `* ${rx} =`, G: C.trunc(acc), H: rmk(c), _code: c }); + C.put(sh, r++, { + C: p.name, + D: daily, + E: `* ${rx} =`, + G: C.trunc(acc), + H: rmk(c), + _code: c, + }); } r += 1; C.put(sh, r++, { A: "3. 단가 및 재료비" }); @@ -235,7 +254,14 @@ const StmateReportUnit = (() => { const items = lab.concat([...mats].filter((c) => idx.by[c]).sort()); items.forEach((c, i) => { const p = idx.price(c, 1); - const cells = { B: String(i + 1), C: p.name, E: p.unitDisp, F: p.M + p.L + p.S, H: rmk(c), _code: c }; + const cells = { + B: String(i + 1), + C: p.name, + E: p.unitDisp, + F: p.M + p.L + p.S, + H: rmk(c), + _code: c, + }; if (p.spec.trim()) cells.D = p.spec; C.put(sh, r++, cells); }); @@ -276,7 +302,10 @@ const StmateReportUnit = (() => { ctx, 일위대가목록표: F(listSheet(ctx, "일위대가목록표", "일위대가 목록표", "B"), "0301"), 일위대가표: F(compositionSheet(ctx, "일위대가표", "일 위 대 가 표", "B"), "0302"), - 단가산출근거목록표: F(listSheet(ctx, "단가산출근거목록표", "단가산출근거 목록표", "D"), "0401"), + 단가산출근거목록표: F( + listSheet(ctx, "단가산출근거목록표", "단가산출근거 목록표", "D"), + "0401", + ), 환율및기초자료: basicSheet(ctx), 중기목록표: F(listSheet(ctx, "중기목록표", "중기 목록표", "X"), "0202"), 중기사용료: F(compositionSheet(ctx, "중기사용료", "중 기 사 용 료", "X"), "0203"), diff --git a/tools/stmate-test/scr_edit_comp.js b/tools/stmate-test/scr_edit_comp.js index 98d0f2d27..ac6968df4 100644 --- a/tools/stmate-test/scr_edit_comp.js +++ b/tools/stmate-test/scr_edit_comp.js @@ -10,10 +10,31 @@ "use strict"; const num = (v) => Number(v) || 0; const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); - const RXX = ["초기값설정 개별적용", "기 본 노임산출계수", "기 타 노임산출계수", "기타-1노임산출계수", "기타-2노임산출계수", "기타-3노임산출계수", "기타-4노임산출계수"]; - const SHIFT = [["E0", "주간"], ["E1", "야간"], ["E2", "주야간 2교대"], ["E3", "주야간 3교대"]]; - const SB = [["", "0 각 해당비목 적용"], ["M", "1 재료비로 적용"], ["L", "2 노무비로 적용"], ["S", "3 경 비로 적용"]]; + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const RXX = [ + "초기값설정 개별적용", + "기 본 노임산출계수", + "기 타 노임산출계수", + "기타-1노임산출계수", + "기타-2노임산출계수", + "기타-3노임산출계수", + "기타-4노임산출계수", + ]; + const SHIFT = [ + ["E0", "주간"], + ["E1", "야간"], + ["E2", "주야간 2교대"], + ["E3", "주야간 3교대"], + ]; + const SB = [ + ["", "0 각 해당비목 적용"], + ["M", "1 재료비로 적용"], + ["L", "2 노무비로 적용"], + ["S", "3 경 비로 적용"], + ]; const COMP = { 1: "재", 2: "노", 3: "경", 4: "계" }; const E = () => global.STMATE_SMT_EVAL; @@ -35,7 +56,11 @@ function rowView(ctx, code, row, opts) { const c = String(row.CODE || "").trim(); const r = byCode(ctx)[c]; - const q = ["T", "t", "1", true].includes(row.DEVD1) ? (num(row.QTY1) ? 1 / num(row.QTY1) : 0) : num(row.QTY1); + const q = ["T", "t", "1", true].includes(row.DEVD1) + ? num(row.QTY1) + ? 1 / num(row.QTY1) + : 0 + : num(row.QTY1); if (!r) return { name: c === "PR" || c === "RR" ? "비율 줄" : "", p: null, q }; let p = { M: num(r.MAMT1), L: num(r.LAMT1), S: num(r.SAMT1) }; if (num(r.NAGCD) > 0) p = { M: num(r.MAMT2), L: num(r.LAMT2), S: num(r.SAMT2) }; @@ -51,7 +76,10 @@ function evalDraft(ctx, code) { const row = byCode(ctx)[code] || {}; const opts = ENG().compositionOptions(ctx, code, row); - return { opts, ev: E().evaluateComposition(draft(ctx, code), ENG().costnLookup(ctx).lookup, opts) }; + return { + opts, + ev: E().evaluateComposition(draft(ctx, code), ENG().costnLookup(ctx).lookup, opts), + }; } function render(ctx, code, app) { @@ -61,7 +89,13 @@ const { opts, ev } = evalDraft(ctx, code); const sel = new Set(ctx.uiState.compSel || []); const sys = (ctx.state && ctx.state.sysConfig) || {}; - const opt = (list, cur) => list.map(([v, l]) => ``).join(""); + const opt = (list, cur) => + list + .map( + ([v, l]) => + ``, + ) + .join(""); const isX = code[0] === "X"; const errs = checkDraft(ctx, code); return ` @@ -73,9 +107,23 @@ - ${isX ? `` : ""} - ${isX ? `` : ""} - ${isX ? `` : ""} + ${ + isX + ? `` + : "" + } + ${ + isX + ? `` + : "" + } + ${isX ? `` : ""} ${errs.length ? `
${errs.map(esc).join("
")}
` : ""} @@ -96,7 +144,11 @@ ${i + 1} ${esc(v.name || "")}${esc(v.size || "")}${esc(v.unit || "")} - + ${pr ? `${COMP[r.PRIN] || "-"}→${COMP[r.PROUT] || "-"}` : String(r.PRPT || "0").trim() !== "0" && String(r.PRPT || "").trim() ? "밑수" : ""} ${amt("M")}${amt("L")}${amt("S")} @@ -129,19 +181,24 @@ function depsOf(ctx, code) { const groups = {}; for (const r of (ctx.dbfData && ctx.dbfData.bdqty_rows) || []) - if (/^[BX]/.test(r.BDCODE || "")) (groups[r.BDCODE] = groups[r.BDCODE] || []).push(String(r.CODE || "").trim()); + if (/^[BX]/.test(r.BDCODE || "")) + (groups[r.BDCODE] = groups[r.BDCODE] || []).push(String(r.CODE || "").trim()); const u = ctx.uiState.compEdit || {}; for (const k of Object.keys(u)) groups[k] = u[k].map((r) => String(r.CODE || "").trim()); const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {}; const edited = ctx.uiState.editedSmtMap || {}; return (c) => groups[c] || - [...String(edited[c] ?? (memos[c] && memos[c].text) ?? "").matchAll(/&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g)].map((m) => m[1]); + [ + ...String(edited[c] ?? (memos[c] && memos[c].text) ?? "").matchAll( + /&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g, + ), + ].map((m) => m[1]); } function reach(deps, start) { const seen = new Set(), st = [start]; - while (st.length) for (const d of deps(st.pop())) if (!seen.has(d)) seen.add(d), st.push(d); + while (st.length) for (const d of deps(st.pop())) if (!seen.has(d)) (seen.add(d), st.push(d)); return seen; } @@ -184,7 +241,15 @@ const on = (id, fn) => $(id) && ($(id).onclick = fn); on("comp-add", () => { const at = sel().length ? sel().pop() + 1 : rows.length; - rows.splice(at, 0, { BDCODE: code, CODE: "", QTY1: "0", DEVD1: "F", PRPT: "0", PRIN: "0", PROUT: "0" }); + rows.splice(at, 0, { + BDCODE: code, + CODE: "", + QTY1: "0", + DEVD1: "F", + PRPT: "0", + PRIN: "0", + PROUT: "0", + }); ctx.uiState.compSel = [at]; redraw(); }); @@ -227,11 +292,17 @@ onc("comp-shift", (v) => setRow("SHIFT", v)); onc("comp-shift-f", (v) => { const sys = ctx.state.sysConfig; - sys.shiftFactors = Object.assign({}, sys.shiftFactors, { [row.SHIFT || sys.shiftMode || "E0"]: num(v) || "" }); + sys.shiftFactors = Object.assign({}, sys.shiftFactors, { + [row.SHIFT || sys.shiftMode || "E0"]: num(v) || "", + }); }); on("comp-apply", () => { const errs = checkDraft(ctx, code); - if (errs.length && !confirm(`검사 오류 ${errs.length}건 — 그래도 반영할까요?\n${errs.slice(0, 5).join("\n")}`)) return; + if ( + errs.length && + !confirm(`검사 오류 ${errs.length}건 — 그래도 반영할까요?\n${errs.slice(0, 5).join("\n")}`) + ) + return; const all = ctx.dbfData.bdqty_rows || (ctx.dbfData.bdqty_rows = []); const at = all.findIndex((r) => r.BDCODE === code); const rest = all.filter((r) => r.BDCODE !== code); @@ -241,7 +312,8 @@ delete ctx.uiState.compEdit[code]; app.cascadeAndRender(); const cyc = ctx.uiState.cycleErrors || []; - if (cyc.length) alert(`순환참조 ${cyc.length}건 — 저장 단가로 이어 셈\n${cyc.slice(0, 5).join("\n")}`); + if (cyc.length) + alert(`순환참조 ${cyc.length}건 — 저장 단가로 이어 셈\n${cyc.slice(0, 5).join("\n")}`); }); } diff --git a/tools/stmate-test/scr_edit_eq.js b/tools/stmate-test/scr_edit_eq.js index 1fc5c0248..34df0352e 100644 --- a/tools/stmate-test/scr_edit_eq.js +++ b/tools/stmate-test/scr_edit_eq.js @@ -7,9 +7,16 @@ */ (function (global) { "use strict"; - const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); function load(ctx, app) { if (ctx.uiState.eqDraft || ctx.uiState.eqDraftLoading) return; @@ -55,7 +62,11 @@
-
문법 검사 ${ev.errors.length}건 · 변수 ${esc(Object.entries(ev.vars).map(([k, v]) => `${k}=${v}`).join(", ") || "없음")}
+
문법 검사 ${ev.errors.length}건 · 변수 ${esc( + Object.entries(ev.vars) + .map(([k, v]) => `${k}=${v}`) + .join(", ") || "없음", + )}
${ev.errors.map((e) => `
${e.lineNo}번째줄: ${esc(e.msg)}
`).join("")}
` : "
등록된 중기공식 없음 — [추가]
" @@ -71,13 +82,20 @@ const items = u.eqDraft || []; const i = Math.min(u.eqSel || 0, Math.max(0, items.length - 1)); const redraw = () => app.renderWorkspace(); - document.querySelectorAll(".eq-row").forEach((tr) => (tr.onclick = () => ((u.eqSel = Number(tr.dataset.i)), redraw()))); + document + .querySelectorAll(".eq-row") + .forEach((tr) => (tr.onclick = () => ((u.eqSel = Number(tr.dataset.i)), redraw()))); if ($("eq-name")) $("eq-name").onchange = (e) => ((items[i].name = e.target.value), redraw()); - if ($("eq-insx")) $("eq-insx").onchange = (e) => ((items[i].ins_x = Number(e.target.value)), redraw()); + if ($("eq-insx")) + $("eq-insx").onchange = (e) => ((items[i].ins_x = Number(e.target.value)), redraw()); if ($("eq-text")) $("eq-text").onchange = (e) => ((items[i].text = e.target.value), redraw()); $("eq-add").onclick = () => { if (!confirm("새로운 중기공식을 추가하시겠습니까?")) return; - items.push({ name: `신규공식 ${items.length + 1}`, text: "q = 0.7 , K = 0.9 , f = 1/1.25 , E = 0.6\r\nCm = 21\r\nQ = 3600*q*K*f*E/Cm =", ins_x: 0 }); + items.push({ + name: `신규공식 ${items.length + 1}`, + text: "q = 0.7 , K = 0.9 , f = 1/1.25 , E = 0.6\r\nCm = 21\r\nQ = 3600*q*K*f*E/Cm =", + ins_x: 0, + }); u.eqSel = items.length - 1; redraw(); }; @@ -86,7 +104,10 @@ items.splice(i, 1); redraw(); }; - $("eq-cancel").onclick = () => ((u.eqDraft = JSON.parse(JSON.stringify(u.eqItems || []))), redraw()); + $("eq-cancel").onclick = () => ( + (u.eqDraft = JSON.parse(JSON.stringify(u.eqItems || []))), + redraw() + ); $("eq-save").onclick = async () => { if (!confirm("수정된 중기공식을 저장하시겠습니까?")) return; try { @@ -101,6 +122,10 @@ } P.screens.wM_Edit_EQ = { render, bind }; - P.actions.Act_Qty_EditEQ = (ctx, app) => ((ctx.uiState.activeScreen = "wM_Edit_EQ"), app.renderWorkspace()); - if (!P.menus.some((m) => m.act === "Act_Qty_EditEQ")) P.menus.push({ menu: "m2", label: "표준중기공식 편집", act: "Act_Qty_EditEQ" }); + P.actions.Act_Qty_EditEQ = (ctx, app) => ( + (ctx.uiState.activeScreen = "wM_Edit_EQ"), + app.renderWorkspace() + ); + if (!P.menus.some((m) => m.act === "Act_Qty_EditEQ")) + P.menus.push({ menu: "m2", label: "표준중기공식 편집", act: "Act_Qty_EditEQ" }); })(window); diff --git a/tools/stmate-test/scr_edit_expr.js b/tools/stmate-test/scr_edit_expr.js index e3caf04eb..37717066d 100644 --- a/tools/stmate-test/scr_edit_expr.js +++ b/tools/stmate-test/scr_edit_expr.js @@ -76,7 +76,7 @@ const StmateSmtExpr = (() => { i += num[0].length; continue; } - const id = s.slice(i).match(/^[A-Za-z_][A-Za-z_0-9]*/); + const id = s.slice(i).match(/^[A-Za-z_가-힣][A-Za-z_0-9가-힣]*/); if (id) { out.push({ t: "id", v: id[0] }); i += id[0].length; @@ -110,7 +110,8 @@ const StmateSmtExpr = (() => { */ function evaluate(src, vars = {}) { const missing = []; - let toks, p = 0; + let toks, + p = 0; const peek = () => toks[p]; const isOp = (v) => toks[p] && toks[p].t === "op" && toks[p].v === v; const expect = (v) => { @@ -119,11 +120,25 @@ const StmateSmtExpr = (() => { }; function cmp() { let a = add(); - while (toks[p] && toks[p].t === "op" && [">", "<", ">=", "<=", "=", "<>"].includes(toks[p].v)) { + while ( + toks[p] && + toks[p].t === "op" && + [">", "<", ">=", "<=", "=", "<>"].includes(toks[p].v) + ) { const op = toks[p++].v, b = add(); a = - op === ">" ? a > b : op === "<" ? a < b : op === ">=" ? a >= b : op === "<=" ? a <= b : op === "=" ? Math.abs(a - b) < EPS : Math.abs(a - b) >= EPS; + op === ">" + ? a > b + : op === "<" + ? a < b + : op === ">=" + ? a >= b + : op === "<=" + ? a <= b + : op === "=" + ? Math.abs(a - b) < EPS + : Math.abs(a - b) >= EPS; a = a ? 1 : 0; } return a; @@ -147,8 +162,8 @@ const StmateSmtExpr = (() => { return a; } function unary() { - if (isOp("-")) return p++, -unary(); - if (isOp("+")) return p++, unary(); + if (isOp("-")) return (p++, -unary()); + if (isOp("+")) return (p++, unary()); return power(); } function power() { @@ -164,7 +179,7 @@ const StmateSmtExpr = (() => { expect("("); if (!isOp(")")) { out.push(cmp()); - while (isOp(",")) p++, out.push(cmp()); + while (isOp(",")) (p++, out.push(cmp())); } expect(")"); return out; @@ -172,16 +187,17 @@ const StmateSmtExpr = (() => { function primary() { const t = peek(); if (!t) throw new Error("식이 끝남"); - if (t.t === "num") return p++, t.v; + if (t.t === "num") return (p++, t.v); if (t.t === "op" && t.v === "(") { p++; const v = cmp(); expect(")"); return v; } - if (t.t === "op" && t.v === "√") return p++, Math.sqrt(isOp("(") ? (p++, ((v) => (expect(")"), v))(cmp())) : primary()); - if (t.t === "op" && t.v === "∏") return p++, Math.PI; - if (t.t === "op" && t.v === "[") return p++, bracket(); + if (t.t === "op" && t.v === "√") + return (p++, Math.sqrt(isOp("(") ? (p++, ((v) => (expect(")"), v))(cmp())) : primary())); + if (t.t === "op" && t.v === "∏") return (p++, Math.PI); + if (t.t === "op" && t.v === "[") return (p++, bracket()); if (t.t === "id") { p++; const name = t.v.toUpperCase(); @@ -206,7 +222,13 @@ const StmateSmtExpr = (() => { // [이름=식] 대입 · [식:자리R|U|C] 자리 처리 · [식] 값 function bracket() { let name = null; - if (toks[p] && toks[p].t === "id" && toks[p + 1] && toks[p + 1].t === "op" && toks[p + 1].v === "=") { + if ( + toks[p] && + toks[p].t === "id" && + toks[p + 1] && + toks[p + 1].t === "op" && + toks[p + 1].v === "=" + ) { name = toks[p].v.toUpperCase(); p += 2; } @@ -256,12 +278,45 @@ const StmateSmtExpr = (() => { } return at; } - const lastTopEq = (s) => (topEqs(s).pop() ?? -1); - const firstTopEq = (s) => (topEqs(s)[0] ?? -1); + const lastTopEq = (s) => topEqs(s).pop() ?? -1; + const firstTopEq = (s) => topEqs(s)[0] ?? -1; - const FUNC_LIST = ["∏", "INT(0)", "SIN(0)", "COS(0)", "TAN(0)", "SQRT(1)", "√(1)", "ROUND(1,0)", "ROUNDDOWN(1,0)", "CUT(1,0)", "LOG(10,1)", "LN(1)", "ABS(1)", "EXP(1)", "MOD(1,1)", "POWER(1,1)", "FACT(1)", "IIF(1>0, 2, 3)", ";;", "[2/3]"]; + const FUNC_LIST = [ + "∏", + "INT(0)", + "SIN(0)", + "COS(0)", + "TAN(0)", + "SQRT(1)", + "√(1)", + "ROUND(1,0)", + "ROUNDDOWN(1,0)", + "CUT(1,0)", + "LOG(10,1)", + "LN(1)", + "ABS(1)", + "EXP(1)", + "MOD(1,1)", + "POWER(1,1)", + "FACT(1)", + "IIF(1>0, 2, 3)", + ";;", + "[2/3]", + ]; - return { evaluate, tokenize, splitTop, lastTopEq, firstTopEq, trunc, roundEven, roundUp, RND, FUNCS, FUNC_LIST }; + return { + evaluate, + tokenize, + splitTop, + lastTopEq, + firstTopEq, + trunc, + roundEven, + roundUp, + RND, + FUNCS, + FUNC_LIST, + }; })(); if (typeof window !== "undefined") window.STMATE_SMT_EXPR = StmateSmtExpr; diff --git a/tools/stmate-test/scr_edit_lang.js b/tools/stmate-test/scr_edit_lang.js index 6d58d5330..f24b61e55 100644 --- a/tools/stmate-test/scr_edit_lang.js +++ b/tools/stmate-test/scr_edit_lang.js @@ -8,15 +8,25 @@ */ const StmateSmtLang = (() => { const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); const num = (v) => Number(v) || 0; // 계표시 — 레벨 1~5 · 제외금액 const MARK_NAMES = ["소계", "계", "합계", "총계", "총합계"]; - const marker = (level, excluded) => `(${excluded ? "-" : ""}${"=".repeat(Math.max(1, Math.min(5, level)))})`; + const marker = (level, excluded) => + `(${excluded ? "-" : ""}${"=".repeat(Math.max(1, Math.min(5, level)))})`; // 일반코드 입력 꼴(cBox_Trans) 0 코드 · 1 명칭 코드 · 2 명칭 규격 코드 · 3 명칭 규격 코드 단위 · 4 코드번호만 - const TRANS = ["코드 * 수량(0) =", "명칭 코드 * 수량(0) =", "명칭 규격 코드 * 수량(0) =", "명칭 규격 코드 * 수량(0) 단위 =", "코드번호(만 입력)"]; + const TRANS = [ + "코드 * 수량(0) =", + "명칭 코드 * 수량(0) =", + "명칭 규격 코드 * 수량(0) =", + "명칭 규격 코드 * 수량(0) 단위 =", + "코드번호(만 입력)", + ]; const COMP_LABEL = { L: "노 무 비", M: "재 료 비", S: "경 비" }; /** @@ -61,15 +71,21 @@ const StmateSmtLang = (() => { const amps = (ln.match(/&/g) || []).length; const eq = ln.lastIndexOf("="); if (amps === 2 && eq > ln.lastIndexOf("&")) { + // ':= A01' 금액변수 · '->' 제외 · 첫 따옴표부터의 단위 주석만 남김 const tail = ln.slice(eq + 1); const q = tail.indexOf("'"); - let keep = ""; - if (tail.includes("->")) keep = tail.slice(0, tail.indexOf("->") + 2); - else if (q >= 0) keep = tail.slice(q); - else if (/^\s*A/.test(tail)) keep = tail.trim().slice(0, 3); - return ln.slice(0, eq + 1) + keep; + const lbl = /^\s*A/.test(tail) ? tail.trim().slice(0, 3) : ""; + const arrow = tail.includes("->") ? `${lbl ? " " : ""}->` : ""; + return ln.slice(0, eq + 1) + lbl + arrow + (q >= 0 && !arrow ? tail.slice(q) : ""); } - if (t && !t.includes("=") && !t.includes("'") && !/^\(-?=+\)/.test(t) && !t.startsWith(";;") && !t.startsWith("//")) + if ( + t && + !t.includes("=") && + !t.includes("'") && + !/^\(-?=+\)/.test(t) && + !t.startsWith(";;") && + !t.startsWith("//") + ) return ln.replace(t, `'${t}'`); return ln; }) diff --git a/tools/stmate-test/scr_edit_price.js b/tools/stmate-test/scr_edit_price.js index 9bf8e466c..262d29539 100644 --- a/tools/stmate-test/scr_edit_price.js +++ b/tools/stmate-test/scr_edit_price.js @@ -46,10 +46,22 @@ // 화면 — 단가표 메뉴에 「자재 후보단가 채택」 창 if (typeof document === "undefined") return; - const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); P.actions.Act_Qty_AdoptPrice = (ctx, app) => { if (!ctx.dbfData) return alert("먼저 프로젝트를 여세요."); - const opts = ["min:최소단가(0원 제외)", "1:1 단가", "2:2 단가", "3:3 단가", "4:4 단가", "5:5 단가", "keep:변동없음"]; + const opts = [ + "min:최소단가(0원 제외)", + "1:1 단가", + "2:2 단가", + "3:3 단가", + "4:4 단가", + "5:5 단가", + "keep:변동없음", + ]; app.openModal( "자재 후보단가 채택 (wM_Boxa RG_MComp)", `
@@ -61,12 +73,18 @@
`, ); const $ = (id) => document.getElementById(id); - const run = () => adoptCandidates(ctx.dbfData.costn_rows, { mode: $("adopt-mode").value, slot: $("adopt-slot").value }); + const run = () => + adoptCandidates(ctx.dbfData.costn_rows, { + mode: $("adopt-mode").value, + slot: $("adopt-slot").value, + }); $("adopt-run-preview").onclick = () => { const res = run(); $("adopt-preview").innerHTML = `
바뀔 자재 ${res.changed.length}건 · 보호표시로 그대로 ${res.kept.length}건
` + - res.changed.map((c) => `
${c.code}: ${app.fmt(c.from)} → ${app.fmt(c.to)} (${c.src}단가)
`).join(""); + res.changed + .map((c) => `
${c.code}: ${app.fmt(c.from)} → ${app.fmt(c.to)} (${c.src}단가)
`) + .join(""); }; $("adopt-run-apply").onclick = () => { const res = run(); @@ -76,7 +94,11 @@ for (const c of res.changed) { const r = by[c.code]; if (slot === 1 && sync) sync(ctx, c.code, c.to, num(r.LAMT1), num(r.SAMT1)); - else Object.assign(r, { [`MAMT${slot}`]: String(c.to), [`TAMT${slot}`]: String(c.to + num(r[`LAMT${slot}`]) + num(r[`SAMT${slot}`])) }); + else + Object.assign(r, { + [`MAMT${slot}`]: String(c.to), + [`TAMT${slot}`]: String(c.to + num(r[`LAMT${slot}`]) + num(r[`SAMT${slot}`])), + }); } app.closeModal(); app.cascadeAndRender(); diff --git a/tools/stmate-test/scr_edit_san.js b/tools/stmate-test/scr_edit_san.js index b1575419a..385f1d02c 100644 --- a/tools/stmate-test/scr_edit_san.js +++ b/tools/stmate-test/scr_edit_san.js @@ -9,15 +9,30 @@ */ (function (global) { "use strict"; - const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); const num = (v) => Number(v) || 0; const L = () => global.STMATE_SMT_LANG; const X = () => global.STMATE_SMT_EXPR; const ENG = () => global.STMATE_ENGINE; const COMP = () => global.STMATE_EDIT_COMP; const esc = (s) => L().esc(s); - const SB = [["", "0 각 해당비목 적용"], ["M", "1 재료비로 적용"], ["L", "2 노무비로 적용"], ["S", "3 경 비로 적용"]]; - const FILTERS = [["ALL_BDX", "전체 대가·산근·중기"], ["SMT_ONLY", "산식 보유"], ["D", "단가산출 D"], ["B", "일위대가 B"], ["X", "중기사용료 X"]]; + const SB = [ + ["", "0 각 해당비목 적용"], + ["M", "1 재료비로 적용"], + ["L", "2 노무비로 적용"], + ["S", "3 경 비로 적용"], + ]; + const FILTERS = [ + ["ALL_BDX", "전체 대가·산근·중기"], + ["SMT_ONLY", "산식 보유"], + ["D", "단가산출 D"], + ["B", "일위대가 B"], + ["X", "중기사용료 X"], + ]; const memoText = (ctx, code) => { const ed = ctx.uiState.editedSmtMap || {}; @@ -63,7 +78,8 @@ const rows = listRows(ctx); const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {}; let code = ctx.uiState.selectedIlwiCode; - if (!rows.some((r) => String(r.CODE).trim() === code)) code = rows.length ? String(rows[0].CODE).trim() : ""; + if (!rows.some((r) => String(r.CODE).trim() === code)) + code = rows.length ? String(rows[0].CODE).trim() : ""; ctx.uiState.selectedIlwiCode = code; const row = rows.find((r) => String(r.CODE).trim() === code) || {}; const hasComp = ((ctx.dbfData && ctx.dbfData.bdqty_rows) || []).some((r) => r.BDCODE === code); @@ -113,14 +129,21 @@ ``; const marks = [["", "계표시(F5)"]].concat( L().MARK_NAMES.map((n, i) => [L().marker(i + 1, false), `${n} ${L().marker(i + 1, false)}`]), - L().MARK_NAMES.map((n, i) => [L().marker(i + 1, true), `${n}(제외금액) ${L().marker(i + 1, true)}`]), + L().MARK_NAMES.map((n, i) => [ + L().marker(i + 1, true), + `${n}(제외금액) ${L().marker(i + 1, true)}`, + ]), ); const qrows = (ctx.dbfData && ctx.dbfData.qcode_rows) || []; const qopts = [["", qrows.length ? "수량코드" : "수량코드 없음"]]; for (const q of qrows) { const c = String(q.CODE).trim(); - for (let k = 0; k < 10; k++) if (String(q[`QTY${k}`] ?? "").trim()) qopts.push([`~${c}.QTY${k}~`, `${c}.QTY${k} = ${q[`QTY${k}`]} ${q.NAME || ""}`]); - for (const ln of String(q.XCODE || "").split(/\r?\n/)) if (ln.includes("=")) qopts.push([`~${c}.${ln.split("=")[0].trim()}~`, `${c}.${ln.trim()}`]); + for (let k = 0; k < 10; k++) + if (String(q[`QTY${k}`] ?? "").trim()) + qopts.push([`~${c}.QTY${k}~`, `${c}.QTY${k} = ${q[`QTY${k}`]} ${q.NAME || ""}`]); + for (const ln of String(q.XCODE || "").split(/\r?\n/)) + if (ln.includes("=")) + qopts.push([`~${c}.${ln.split("=")[0].trim()}~`, `${c}.${ln.trim()}`]); } const eq = u.eqItems || []; const adjOn = String(row.S_ADJ || "").trim() === "T"; @@ -139,8 +162,23 @@
${((ctx.dbfData && ctx.dbfData.costn_rows) || []).map((r) => ``).join("")} - ${sel("san-trans", L().TRANS.map((t, i) => [String(i), t]), u.sanTrans ?? "1")} - 중기비 코드${sel("san-op", [["/", "/"], ["*", "*"]], u.sanOp || "/")}${sel("san-qv", ["Q", "A", "T", "N"].map((v) => [v, v]), u.sanQv || "Q")}(으)로 입력 + ${sel( + "san-trans", + L().TRANS.map((t, i) => [String(i), t]), + u.sanTrans ?? "1", + )} + 중기비 코드${sel( + "san-op", + [ + ["/", "/"], + ["*", "*"], + ], + u.sanOp || "/", + )}${sel( + "san-qv", + ["Q", "A", "T", "N"].map((v) => [v, v]), + u.sanQv || "Q", + )}(으)로 입력 결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())} @@ -156,13 +194,19 @@ ${errs.map((e) => `
${e.lineNo ? `${e.lineNo}번째줄: ` : ""}${esc(e.msg)}
`).join("") || "
오류 없음
"}
-
계산결과(Alt+F9) — 변수 ${esc(Object.entries(ev.vars).map(([k, v]) => `${k}=${v}`).join(", ") || "없음")} +
계산결과(Alt+F9) — 변수 ${esc( + Object.entries(ev.vars) + .map(([k, v]) => `${k}=${v}`) + .join(", ") || "없음", + )} 재 ${fmt(ev.sumM)} + 노 ${fmt(ev.sumL)} + 경 ${fmt(ev.sumS)} = ${fmt(ev.sumT)}원
${lines .map( - (ln) => ` + ( + ln, + ) => ` @@ -205,20 +249,30 @@ const code = ctx.uiState.selectedIlwiCode; const u = ctx.uiState; u.editedSmtMap = u.editedSmtMap || {}; - document.querySelectorAll(".ilwi-sub-btn").forEach( - (b) => (b.onclick = () => ((u.ilwiSubFilter = b.dataset.sub), app.renderWorkspace())), - ); - document.querySelectorAll(".ilwi-master-row").forEach( - (tr) => (tr.onclick = () => ((u.selectedIlwiCode = tr.dataset.code), (u.compSel = []), app.renderWorkspace())), - ); - if ($("san-btn-eq")) $("san-btn-eq").onclick = () => ((u.activeScreen = "wM_Edit_EQ"), app.renderWorkspace()); + document + .querySelectorAll(".ilwi-sub-btn") + .forEach( + (b) => (b.onclick = () => ((u.ilwiSubFilter = b.dataset.sub), app.renderWorkspace())), + ); + document + .querySelectorAll(".ilwi-master-row") + .forEach( + (tr) => + (tr.onclick = () => ( + (u.selectedIlwiCode = tr.dataset.code), + (u.compSel = []), + app.renderWorkspace() + )), + ); + if ($("san-btn-eq")) + $("san-btn-eq").onclick = () => ((u.activeScreen = "wM_Edit_EQ"), app.renderWorkspace()); const area = $("smt-formula-textarea"); if (!area) return COMP().bind(ctx, code, app); const keep = () => (u.editedSmtMap[code] = area.value); area.oninput = keep; const redo = () => (keep(), app.renderWorkspace()); area.onkeydown = (e) => { - if (e.key === "F9") e.preventDefault(), $("san-calc").click(); + if (e.key === "F9") (e.preventDefault(), $("san-calc").click()); }; const pick = (id, fn) => $(id) && @@ -238,14 +292,31 @@ const ls = String(it.text || "").split(/\r?\n/); insertLines(area, it.ins_x ? ls : ls.filter((l) => !/&[^&]+&/.test(l))); }); - const keepOpt = (id, key) => $(id) && ($(id).onchange = (e) => (u[key] = e.target.type === "checkbox" ? e.target.checked : e.target.value)); - [["san-trans", "sanTrans"], ["san-op", "sanOp"], ["san-qv", "sanQv"], ["san-skip0", "sanSkip0"], ["san-code", "sanCode"]].forEach(([i, k]) => keepOpt(i, k)); + const keepOpt = (id, key) => + $(id) && + ($(id).onchange = (e) => + (u[key] = e.target.type === "checkbox" ? e.target.checked : e.target.value)); + [ + ["san-trans", "sanTrans"], + ["san-op", "sanOp"], + ["san-qv", "sanQv"], + ["san-skip0", "sanSkip0"], + ["san-code", "sanCode"], + ].forEach(([i, k]) => keepOpt(i, k)); $("san-ins").onclick = () => { const c = $("san-code").value.trim().toUpperCase(); const r = ENG().costnLookup(ctx).byCode[c]; if (!r) return alert(`${c || "(빈 코드)"} 는 등록된 단가코드가 아님`); if (c === code) return alert("자기 자신은 넣을 수 없음(순환참조)"); - insertLines(area, L().codeLines(c, r, { trans: $("san-trans").value, op: $("san-op").value, qtyVar: $("san-qv").value, skipZero: $("san-skip0").checked })); + insertLines( + area, + L().codeLines(c, r, { + trans: $("san-trans").value, + op: $("san-op").value, + qtyVar: $("san-qv").value, + skipZero: $("san-skip0").checked, + }), + ); redo(); }; $("san-strip").onclick = () => ((area.value = L().stripResults(area.value)), redo()); @@ -269,16 +340,34 @@ for (const k of "MLS") if (!num(row[`ADJ_${k}`])) row[`ADJ_${k}`] = "1"; redo(); }; - document.querySelectorAll(".san-adjv").forEach((el) => (el.onchange = () => ((row[`ADJ_${el.dataset.k}`] = String(num(el.value))), redo()))); + document + .querySelectorAll(".san-adjv") + .forEach( + (el) => + (el.onchange = () => ((row[`ADJ_${el.dataset.k}`] = String(num(el.value))), redo())), + ); $("san-calc").onclick = () => { keep(); const txt = area.value; const ev = ENG().evaluateSmtFormulaText(txt, ctx, code); const cyc = cycleErrors(ctx, code); const n = ev.errors.length + cyc.length; - if (n && !confirm(`문법 오류 ${n}건 — 그래도 계산 결과를 단가에 반영할까요?\n${ev.errors.slice(0, 5).map((e) => `${e.lineNo}번째줄: ${e.msg}`).concat(cyc).join("\n")}`)) return app.renderWorkspace(); + if ( + n && + !confirm( + `문법 오류 ${n}건 — 그래도 계산 결과를 단가에 반영할까요?\n${ev.errors + .slice(0, 5) + .map((e) => `${e.lineNo}번째줄: ${e.msg}`) + .concat(cyc) + .join("\n")}`, + ) + ) + return app.renderWorkspace(); const memos = ctx.dbfData.costn_memos || (ctx.dbfData.costn_memos = {}); - memos[code] = Object.assign({ code, blockNo: 0 }, memos[code], { text: txt, byteLen: txt.length }); + memos[code] = Object.assign({ code, blockNo: 0 }, memos[code], { + text: txt, + byteLen: txt.length, + }); global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice(ctx, code, ev.sumM, ev.sumL, ev.sumS); app.cascadeAndRender(); }; diff --git a/tools/stmate-test/scr_kanjub.js b/tools/stmate-test/scr_kanjub.js new file mode 100644 index 000000000..65e0bdb6d --- /dev/null +++ b/tools/stmate-test/scr_kanjub.js @@ -0,0 +1,372 @@ +/* -*- coding: utf-8 -*- */ +/** + * 제잡비 계산(wM_KanJub) 화면 (scr_kanjub.js) — 원본 폼 TWM_KANJUB 의 선택축 전부를 저장키 그대로 고치고 바로 다시 셈 + * 윗줄(Panel1): 차수 · 제잡비 방식 · 간접비 절사 · 제비율표 적용 · 저장 + * 공통(Panel3·pnSet_Optn): 공사종류·적용기준·공사기간·고용보험·퇴직공제·계산형식·환경·안전공종·관급/사급·사급위치·건설기계 + * ·일반관리비·계약형식·낙찰방식·부가세·건물종류 · 안전비 자동/수동·1.2배 버림·안전품질·도급절사·보험료 변경·관급 인쇄 + * 형식 탭(T·S·TA·SA, Pn01~05): 안전비 대상·1.2배 · 건강/연금 밑수 · 이윤/수수료 · 실적 노무·재료 산정 · 공급가액·관급·총액 절사 + * 추가금액(묶음 1~4 · 차수 칸 5) · 간접비 순서 코드표(KAN_ORD) · 요율/고정(D_) · 원가계산서 + * 형식별 설정 한 벌씩(cfg.schemeSnap) — 탭을 바꾸면 그 형식 설정으로 갈아 끼움(12번 §10.1-2) + * 근거: 지식DB STmate/old/20_분석 12 §10 · 20 §2 · 25 §1 · 30_원자료/settings_axis_map.txt(화면 순번 ≠ 내부값) + */ +(function (global) { + "use strict"; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const n0 = (v) => Number(String(v ?? "").replace(/,/g, "")) || 0; + const esc = (s) => + String(s ?? "").replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const fmt = (v) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(n0(v)); + // 선택축·입력칸 표 — scr_kanjub_axes.js(화면을 그릴 때 읽음) + const X = () => global.STMATE_KANJUB_AXES; + const RK = { KITA: "T_KITA", EQIP: "EQIPSU" }; + const keyOf = (c) => RK[c] || c; + + // 지금 고치는 차수 설정 — 1차수는 kanjubConfig 와 같은 개체로 묶음 · 추가금액 행은 모든 차수가 함께 씀 + function stageCfg(ctx) { + const st = (ctx.state.kanjubStages = ctx.state.kanjubStages || {}); + if (!st[1]) st[1] = ctx.state.kanjubConfig; + else if (st[1] !== ctx.state.kanjubConfig) ctx.state.kanjubConfig = st[1]; + const n = st[ctx.uiState.kjChasu] ? ctx.uiState.kjChasu : 1; + const rows = st[1].addRows || (st[1].addRows = []); + for (const c of Object.values(st)) c.addRows = rows; + const cfg = st[n]; + cfg.keys = cfg.keys || {}; + cfg.rates = cfg.rates || {}; + return { n, cfg, st }; + } + const schemeOf = (k) => + n0(k.CAL_ACTL) + ? n0(k.JAB_TYPE) === 3 || n0(k.JAB_TYPE) === 4 + ? "SA" + : "TA" + : n0(k.JAB_TYPE) === 3 || n0(k.JAB_TYPE) === 4 + ? "S" + : "T"; + // 키 하나 고침 — 옛 칸(rates·fixed·anjunAuto·anjunBaseAdd·scheme)도 함께 맞춤 + function setKey(cfg, name, v) { + cfg.keys[name] = v; + if (name.startsWith("R_")) { + const code = Object.keys(RK).find((c) => RK[c] === name.slice(2)) || name.slice(2); + cfg.rates[code] = n0(v); + } + if (name === "I_ANJUNAUTO") cfg.anjunAuto = n0(v); + if (name === "C_ANJUN_G") cfg.anjunBaseAdd = n0(v); + if (name.startsWith("D_") || name.startsWith("C_")) { + const body = name.slice(2), + code = Object.keys(RK).find((c) => RK[c] === body) || body; + cfg.fixed = cfg.fixed || {}; + if (n0(cfg.keys["D_" + body]) === 1) cfg.fixed[code] = n0(cfg.keys["C_" + body]); + else delete cfg.fixed[code]; + } + if (name === "JAB_TYPE" || name === "CAL_ACTL") cfg.scheme = schemeOf(cfg.keys); + } + // 형식 바꿈 — 지금 형식 패널 값을 그 형식 스냅샷에 두고 새 형식 스냅샷을 꺼냄(F2-56) + function switchScheme(cfg, to) { + const from = schemeOf(cfg.keys); + if (from === to) return; + cfg.schemeSnap = cfg.schemeSnap || {}; + cfg.schemeSnap[from] = Object.fromEntries( + X() + .schemeKeys(from) + .map((k) => [k, cfg.keys[k] ?? 0]), + ); + const snap = cfg.schemeSnap[to] || {}; + for (const [k, v] of Object.entries(snap)) cfg.keys[k] = v; + const su = to === "S" || to === "SA"; + let jab = n0(cfg.keys.JAB_TYPE); + if (su && jab !== 3 && jab !== 4) jab = 3; + if (!su && (jab === 3 || jab === 4)) jab = 0; + setKey(cfg, "JAB_TYPE", jab); + setKey(cfg, "CAL_ACTL", to === "TA" || to === "SA" ? 1 : 0); + } + + const sel = (key, label, opts, val) => + `
`; + const inp = (key, label, val, w = 110) => + `
`; + + function renderAdds(cfg, n) { + const g = cfg._addTab || 1, + rows = cfg.addRows.map((r, i) => [r, i]).filter(([r]) => n0(r.group) === g); + const tabs = ["일반관리비 직전", "이윤 ~ 공급가액", "도급공사비 직전", "관급자재 ~ 총공사비"] + .map( + (t, i) => + ``, + ) + .join(""); + const body = rows + .map(([r, i]) => { + const amts = [0, 1, 2, 3, 4] + .map( + (s) => + ``, + ) + .join(""); + const extra = + g === 2 + ? ` 부가세` + : g === 1 + ? ` 자동` + : ""; + return `${amts}`; + }) + .join(""); + return `
${tabs}
+
줄산식대입·금액변수참조참조단가금액
${ln.lineNo}
${ln.lineNo} ${u.sanHilite === false ? esc(ln.raw) : L().highlight(ln.raw)} ${esc(ln.assigned || "")}${ln.excluded ? " (제외)" : ""} ${ln.refInfo ? `${esc(ln.refInfo.refCode)}[${ln.refInfo.compTag}] ${esc(ln.refInfo.refName)}` : ""}
${extra}
${body || ``}
추가비용 명칭단위원설계1차2차3차예비선택비고
행 없음
`; + } + + function renderRates(cfg, kj, sch) { + const su = sch === "S" || sch === "SA", + ac = sch === "TA" || sch === "SA"; + const hide = (c) => + (su && c === "KITA") || + (!su && c.startsWith("S_") && !c.startsWith("S_SIGONG")) || + (!ac && /^R_/.test(c)); + return `${X() + .RATE_ROWS.filter((c) => !hide(c)) + .map((c) => { + const k = keyOf(c), + it = kj.itemMap[c] || {}, + D = n0(cfg.keys["D_" + k]) === 1; + return ``; + }) + .join("")}
항목요율(%)고정(D_)저장 금액(C_)밑수계산 금액식
${esc(it.label || c)}${it.baseAmt != null ? fmt(Math.round(it.baseAmt)) : ""}${it.amount != null ? fmt(it.amount) : ""}${esc(it.tpl || it.formula || "")}
`; + } + + function renderSheet(kj) { + return `${kj.lines + .map((l) => { + const note = + l.rate != null && l.baseAmt != null + ? `${esc(l.formula)} x ${fmt(l.rate)}%` + : esc(l.formula); + return ``; + }) + .join("")}
구분금액비고
${esc(l.label)}${fmt(l.amount)}${note}
`; + } + + function render(ctx) { + const u = ctx.uiState; + u.kjChasu = u.kjChasu || 1; + const { n, cfg, st } = stageCfg(ctx); + const k = cfg.keys, + sch = schemeOf(k); + const comp = ctx.computed, + kj = (comp.stages && comp.stages[n] && comp.stages[n].kanjub) || comp.kanjubComputed; + const jbr = (ctx.sourcesData && ctx.sourcesData.jbr_files) || []; + const chasu = Object.keys(st) + .map( + (s) => + ``, + ) + .join(""); + const axes = X().SCHEME_AXES[sch].flatMap((a) => X().AXIS[a]); + const tabs = ["T", "S", "TA", "SA"] + .map( + (s, i) => + ``, + ) + .join(""); + const tab = u.kjTab || "sheet"; + const subtabs = [ + ["sheet", "원가계산서"], + ["rates", "요율·고정"], + ["inputs", "직접입력 금액"], + ["adds", "추가금액"], + ["ord", "간접비 순서"], + ] + .map( + ([t, l]) => + ``, + ) + .join(""); + const ordBox = [1, 2] + .map( + (i) => + `
`, + ) + .join(""); + const body = + tab === "rates" + ? renderRates(cfg, kj, sch) + : tab === "inputs" + ? `
${X() + .INPUTS.map(([key, l]) => inp(key, l, k[key])) + .join("")}
` + : tab === "adds" + ? renderAdds(cfg, n) + : tab === "ord" + ? `
${ordBox}
301 간접노무비 · 302 산재 · 303 고용 · 310 건강 · 304 장기요양 · 305 연금 · 306 퇴직공제 · 312 산업안전 · 313 안전품질 별도 · 321 기타경비 · 331~338 수공 세부경비 · 351 수공 품질 · 307 이행보증 · 308 하도급 · 309 환경 · 311 건설기계 (20번 §2.2)
` + : renderSheet(kj); + const warn = u.kjMsg ? `
${esc(u.kjMsg)}
` : ""; + return `
+
제잡비 계산 [TWM_KANJUB · ${n}차수 · ${sch}형식] + 직접공사비 ${fmt(kj.dT)} → 도급 ${fmt(kj.contractFinal)} + 관급 ${fmt(kj.govFinal)} = 총공사비 ${fmt(kj.grandTotal)}
+
+ + + + + + + +
${warn} +
${X() + .COMMON.map(([key, l, o]) => sel(key, l, o, k[key])) + .join("")}${X() + .OPTN.map(([key, l, o]) => sel(key, l, o, k[key])) + .join("")}
+
${tabs}
+
${axes.map(([key, l, o]) => sel(key, l, o, k[key])).join("")}
+
${subtabs}
+
${body}
`; + } + + // 저장 사전(PRJLST.SMT) — 원본 키 + 계산 결과 키 + 추가금액(kanjub_cfg.smt_updates 와 같은 꼴) + function smtUpdates(st, comp) { + const out = {}, + G = ["JAB_TYPE", "CAL_ACTL", "VIS_TOTL"]; + for (const [n, cfg] of Object.entries(st)) { + const kj = comp.stages && comp.stages[n] && comp.stages[n].kanjub; + const keys = Object.assign({}, cfg.keys, kj ? kj.keysOut : {}); + for (const [c, r] of Object.entries(cfg.rates || {})) keys["R_" + keyOf(c)] = r; + for (const [key, v] of Object.entries(keys)) + out[G.includes(key) ? key : `${n}_${key}`] = String(v); + } + const rows = (st[1] && st[1].addRows) || []; + for (let g = 1; g <= 4; g++) { + const mine = rows.filter((r) => n0(r.group) === g); + out[`ADD_CNT_${g}`] = String(mine.length); + mine.forEach((r, i) => { + const id = `${g}_${String(i + 1).padStart(2, "0")}`; + Object.assign(out, { + [`ADD_NAME_${id}`]: r.name || "", + [`ADD_UNIT_${id}`]: r.unit || "", + [`ADD_BIGO_${id}`]: r.bigo || "", + }); + for (let s = 1; s <= 5; s++) out[`ADD_AMT_${id}_${s}`] = String(n0((r.amts || [])[s - 1])); + if (g === 2) out[`ADD_BUGA_${id}`] = r.vat ? "1" : "0"; + if (g === 1) out[`ADD_ADDT_${id}`] = String(n0(r.addt)); + }); + } + return out; + } + + function bind(ctx, api) { + const re = () => api.renderWorkspace(); + const { cfg, st } = stageCfg(ctx); + const u = ctx.uiState; + api.onChange("kjx-chasu", (e) => ((u.kjChasu = n0(e.target.value)), re())); + api.bindEach(".kjx-key", "onchange", (el) => { + const key = el.getAttribute("data-key"), + v = el.value.trim(); + const val = /^-?[\d,.]+$/.test(v) ? n0(v) : v; + if (key === "JAB_TYPE" || key === "CAL_ACTL") { + const k2 = Object.assign({}, cfg.keys, { [key]: val }); + switchScheme(cfg, schemeOf(k2)); + } + setKey(cfg, key, val); + re(); + }); + api.bindEach(".kjx-fix", "onchange", (el) => { + const k = el.getAttribute("data-k"), + kj = + ctx.computed.stages && + ctx.computed.stages[u.kjChasu] && + ctx.computed.stages[u.kjChasu].kanjub; + if (el.checked && kj && kj.keysOut["C_" + k] != null && !n0(cfg.keys["C_" + k])) + cfg.keys["C_" + k] = kj.keysOut["C_" + k]; + setKey(cfg, "D_" + k, el.checked ? 1 : 0); + re(); + }); + api.bindEach(".kjx-sch", "onclick", (b) => (switchScheme(cfg, b.getAttribute("data-s")), re())); + api.bindEach(".kjx-tab", "onclick", (b) => ((u.kjTab = b.getAttribute("data-t")), re())); + api.bindEach( + ".kjx-addtab", + "onclick", + (b) => ((cfg._addTab = n0(b.getAttribute("data-g"))), re()), + ); + api.onClick("kjx-add-new", () => { + cfg.addRows.push({ + group: cfg._addTab || 1, + name: "추가금액", + unit: "식", + bigo: "", + amts: [0, 0, 0, 0, 0], + vat: false, + addt: 0, + }); + re(); + }); + api.bindEach( + ".kjx-add-del", + "onclick", + (b) => (cfg.addRows.splice(n0(b.getAttribute("data-i")), 1), re()), + ); + api.bindEach(".kjx-add", "onchange", (el) => { + const r = cfg.addRows[n0(el.getAttribute("data-i"))], + f = el.getAttribute("data-f"); + if (f.startsWith("amt")) (r.amts = r.amts || [0, 0, 0, 0, 0])[n0(f.slice(3))] = n0(el.value); + else if (f === "vat" || f === "addt") + r[f] = el.checked ? (f === "vat" ? true : 1) : f === "vat" ? false : 0; + else r[f] = el.value; + re(); + }); + api.bindEach(".kjx-ord", "onchange", (el) => { + cfg.kanOrd = Object.assign({}, cfg.kanOrd, { [el.getAttribute("data-i")]: el.value }); + re(); + }); + api.onClick("kjx-open-rate", () => ((u.activeScreen = "wM_KAN_RATE"), re())); + api.onClick("kjx-apply-rate", async () => { + const R = global.StmateRate, + p = (api.$("kjx-jbr") || {}).value; + let rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables["RATE_R.DBF"]; + if (p) rows = (await api.postJson("/api/load-jbr", { jbr_path: p })).rows; + if (!R || !rows) return alert("제비율표(RATE_R) 없음"); + const m = R.matrixFromRows(rows), + n = stageCfg(ctx).n; + const calc = () => ( + api.recompute(), + (ctx.computed.stages[n] || {}).kanjub || ctx.computed.kanjubComputed + ); + const res = R.applyWithLoopCheck(cfg, m, calc); + u.kjMsg = res.loop ? res.msg : ""; + re(); + }); + api.onClick("kjx-save", async () => { + if (!ctx.currentSourcePath) return alert("먼저 공사를 불러옴"); + try { + const body = { + source_path: ctx.currentSourcePath, + target_folder_name: "stmate_kanjub", + smt_updates: smtUpdates(st, ctx.computed), + }; + const r = await api.postJson("/api/save", body); + alert(`원가계산 설정·결과 저장 — ${r.saved_stc || r.saved_dir || ""}`); + } catch (e) { + alert("저장 실패: " + e.message); + } + }); + } + + P.screens.wM_KanJub = { render, bind }; + global.STMATE_KANJUB_SCREEN = { + setKey, + switchScheme, + schemeOf, + smtUpdates, + stageCfg, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/scr_kanjub_axes.js b/tools/stmate-test/scr_kanjub_axes.js new file mode 100644 index 000000000..e9eda142e --- /dev/null +++ b/tools/stmate-test/scr_kanjub_axes.js @@ -0,0 +1,386 @@ +/* -*- coding: utf-8 -*- */ +/** + * 제잡비 계산(wM_KanJub) 선택축·입력칸 표 (scr_kanjub_axes.js) — 화면 순번 ≠ 내부값인 축은 원본 그대로 + * 근거: 지식DB STmate/old/30_원자료/settings_axis_map.txt(83축) · 20_분석 12 §10.1~10.3 · 25 §1(저장키) + */ +(function (global) { + "use strict"; + const UNITS = [ + "없음", + "10원", + "100원", + "1,000원", + "10,000원", + "100,000원", + "1,000,000원", + "10,000,000원", + "100,000,000원", + ]; + const unitOpts = (first) => UNITS.map((u, i) => [i, i ? `${u} 미만` : first]); + const idx = (labels) => labels.map((l, i) => [i, l]); + // 선택축 — [키, 이름, [[내부값, 화면 글]…]] · 화면 순서대로 · 내부값이 다른 축은 settings_axis_map 그대로 + const COMMON = [ + [ + "JAB_TYPE", + "계산형식", + idx([ + "일반 형식", + "토지개발공사 형식", + "인천상수도 형식", + "수자원공사 형식", + "지역난방공사 형식", + "고용개선지원(서울)", + ]), + ], + [ + "I_KONG_TYPE", + "공사의 종류", + [ + [0, "(1) 일반(토목)공사"], + [2, "(2) 조경공사"], + [4, "(3) 산업설비(토목)"], + [3, "(4) 건축공사"], + [1, "(5) 산업설비(건축)"], + [5, "(6) 국가유산수리"], + [8, "(7) 폐기물처리"], + [9, "(8) 소규모용역"], + ], + ], + [ + "I_SET_BASE", + "적용기준", + [ + [2, "일반(국가계약법)"], + [0, "일반(지방계약법)"], + [1, "행자부기준"], + ], + ], + [ + "I_KONG_KIKAN", + "공사기간", + idx(["6개월이하", "7개월 ~ 12개월이하", "13개월 ~ 36개월이하", "36개월이상"]), + ], + [ + "I_KOBO_TYPE", + "고용보험료", + idx(["없음", "1 등급", "2 등급", "3 등급", "4 등급", "5 등급", "6 등급", "7 등급이하"]), + ], + ["I_TBUGUM", "퇴직공제부금비", idx(["토목공사", "준설공사", "건축공사", "기타"])], + [ + "I_ANJUN_TYPE", + "안전관리비 공종", + [ + [0, "건축공사"], + [1, "토목공사"], + [3, "중건설공사"], + [4, "특수건설공사"], + ], + ], + [ + "I_HWAN", + "환경보전비", + idx([ + "없음(전기·정보통신·소방·국가유산)", + "도로(교량·터널·활주로)", + "플랜트", + "지하철", + "철도", + "상하수도", + "항만(방지막 필요)", + "항만(방지막 불필요)", + "댐", + "택지개발", + "주택(재개발·재건축)", + "주택(신축)", + "주택 외 건축", + "조경", + "기타토목(하천 등)", + ]), + ], + [ + "I_EQIP", + "건설기계대여금 지급보증", + [ + [-1, "없음(국가유산수리)"], + [0, "일반공사(토목·산업설비·조경·건축)"], + [1, "준설·포장·토공·비계"], + [2, "상하수도·보링·수중"], + [3, "석공·시설물·철근콘크리트·가스"], + [4, "조경시설물·식재·궤도·도장·철강재"], + [5, "기계설비 및 그외"], + ], + ], + [ + "I_KN_SA_TYPE", + "관급/사급 내용", + idx([ + "관급자재대/1.1", + "관급자재대", + "사급자재대/1.1", + "사급자재대", + "관급/1.1+사급재", + "관급재+사급/1.1", + "(관급+사급)/1.1", + "관급재+사급재", + ]), + ], + [ + "I_POS_SA", + "사급비 위치", + [ + [3, "재료비에 포함"], + [1, "이윤 다음"], + [2, "도급공사비 직전"], + [0, "관급자재대 다음"], + ], + ], + ["I_ILBAN", "일반관리비", idx(["(주)공사", "전문공사"])], + [ + "I_KOSU_TYPE", + "계약형식", + [ + [0, "일반계약"], + [1, "최저가·기술제안"], + ], + ], + ["I_BID_TYPE", "낙찰방식", idx(["종합심사 외", "종합심사 대상", "턴키(대안)"])], + [ + "I_BUGA", + "부가가치세", + idx([ + "(0) 공급가액의 10%", + "(1) 재료비의 10%", + "(2) 없음", + "(3) 산림조합 형식", + "(4) 산림조합-면세품", + ]), + ], + [ + "I_KUN_TYPE", + "건물 종류", + idx([ + "청사(11층이하)", + "청사(12층이상)", + "학교", + "국가유산", + "관람집회시설", + "전시시설", + "기타", + ]), + ], + ]; + const OPTN = [ + [ + "I_ANJUNAUTO", + "산업안전보건관리비", + [ + [0, "사용자 수동계산"], + [1, "제비율표자동계산"], + [2, "변경 — 당초액+증감율"], + ], + ], + [ + "I_AN_DN_TYPE", + "1.2배 비교", + [ + [0, "소수 버림 후 1.2배"], + [1, "소수 버림 전 1.2배"], + ], + ], + [ + "I_ANPUM", + "안전·품질관리비", + [ + [1, "산출경비로 포함"], + [0, "산출경비로 미포함"], + ], + ], + ["I_DOG_JEOL", "도급액 절사", unitOpts("도급액 버림없음")], + [ + "I_BF_CH_BO", + "보험료 변경 계산", + [ + [0, "직접노무비 x 요율"], + [1, "당초액 + 직노증감x요율"], + [2, "당초액 + 증감액(비율)"], + ], + ], + [ + "I_COST_BO", + "건강·연금 금액", + [ + [0, "계산"], + [1, "직접입력 유지"], + [2, "직접입력 유지(퇴직공제 포함)"], + ], + ], + [ + "I_KWN_COMB", + "관급 인쇄", + [ + [0, "(0) 분리인쇄"], + [1, "(1) 통합인쇄"], + ], + ], + ]; + const UPDN = idx(["절상", "반올림", "절사"]); + const LAB = [ + [0, "ㄴ.1)직노"], + [1, "당해 상시근로자노무비"], + ]; + // 형식 탭 패널(Pn01~05_x) — 같은 이름 축이 형식마다 한 벌(12번 §10.1-2) + const SCHEME_AXES = { + T: ["ANJ_K3", "ANJ_B", "KUN", "YUN", "IYUN", "JEOL", "DOGUP", "KWN", "TOT"], + S: ["ANJ_K2", "ANJ_B", "KUN", "YUN", "JEOL", "DOGUP", "KWN", "TOT"], + TA: [ + "ANJ_K3", + "ANJ_B", + "KUN", + "YUN", + "LTYP", + "MXA", + "MXK", + "MTYP", + "IYUN", + "JEOL", + "DOGUP", + "KWN", + "TOT", + ], + SA: [ + "ANJ_K2", + "ANJ_B", + "KUN", + "YUN", + "LTYP", + "MXA", + "MXK", + "MTYP", + "JEOL", + "DOGUP", + "KWN", + "TOT", + ], + }; + const AXIS = { + ANJ_K3: [["I_ANJUN_K", "안전비 대상", idx(["없음", "관급/사급 내용", "사급자재대"])]], + ANJ_K2: [["I_ANJUN_K", "안전비 대상", idx(["없음", "관급/사급 내용"])]], + ANJ_B: [ + [ + "I_ANJUN_B", + "1.2배", + [ + [0, "(적용 안 함)"], + [1, "의 1.2배"], + ], + ], + ], + KUN: [["I_KUNBO", "건강보험 밑수", LAB]], + YUN: [["I_YUNBO", "연금보험 밑수", LAB]], + IYUN: [ + [ + "I_IYUN", + "이윤/수수료", + [ + [0, "이윤"], + [1, "수수료"], + ], + ], + ], + LTYP: [ + [ + "I_LTYP", + "노무비 산정", + [ + [0, "노무비 = 직공비 x 조정계수"], + [1, "노무비 = 내역서 직접노무비"], + ], + ], + ], + MXA: [ + ["I_MXA", "안전비 상한비", idx(["상한비 자동계산", "상한비 강제적용", "상한비 적용안함"])], + ], + MXK: [ + ["I_MXK", "기타경비 상한비", idx(["상한비 자동계산", "상한비 강제적용", "상한비 적용안함"])], + ], + MTYP: [ + [ + "I_MTYP", + "재료비 산정", + [ + [1, "직접공사비 * 요율 방식"], + [0, "조정계수 입력방식"], + ], + ], + ], + JEOL: [["I_JEOLSA", "공급가액 절사", unitOpts("총공사비에서 조정")]], + DOGUP: [ + [ + "I_DOGUP", + "도급 표기", + [ + [0, "도급공사비"], + [1, "도급예정액"], + ], + ], + ], + KWN: [ + ["I_KWN_JEOLSA", "관급(도급자설치) 자리", unitOpts("절상(절사) 없음")], + ["I_KWN_UPDN", "관급(도급자설치) 처리", UPDN], + ["I_KWN_K_JEOLSA", "관급(관급자설치) 자리", unitOpts("절상(절사) 없음")], + ["I_KWN_K_UPDN", "관급(관급자설치) 처리", UPDN], + ], + TOT: [["I_TOT_JEOL", "총액·이윤보정 절사", unitOpts("이윤보정액 입력")]], + }; + const schemeKeys = (sch) => SCHEME_AXES[sch].flatMap((a) => AXIS[a].map((x) => x[0])); + // 직접입력 금액(C_·Y_·B_) + const INPUTS = [ + ["C_ANJUN_G", "안전비 기초액"], + ["C_ANJUN1_G", "기초액(관급 제외)"], + ["C_ANJUN2_G", "기초액(관급 포함)"], + ["C_KWANGUP_D", "관급자재(도급자설치)"], + ["C_KWANGUP_K", "관급자재(관급자설치)"], + ["C_T_JODAL", "조달수수료"], + ["C_SAGUP", "사급자재대"], + ["C_SANGSI", "상시근로자 노무비"], + ["C_T_ANJUN_N", "내역서 안전관리비"], + ["C_ANJUN_B", "안전관리비 별도"], + ["C_PUMJIL_B", "품질관리비 별도"], + ["C_PSAMT_B", "미확정설계공종(PS)"], + ["C_EXCEPT_B", "제비율 적용제외"], + ["C_BUSAN", "부산물(안전 밑수)"], + ["C_BUSAN_B", "작업설 감액"], + ["C_NBUGAM", "비과세자재대"], + ["C_T_K_MAT", "관리품목자재"], + ["C_PURCHAS", "매입세"], + ["C_T_PEGI", "폐기물수수료"], + ["C_T_KOLJAE", "골재사용료"], + ["C_BUGA_M", "면세품목"], + ["C_KONGSU_A", "이행보증 기초금액"], + ["C_KONGSU_MA", "이행보증 기준금액"], + ["Y_KONGSU", "공기(년)"], + ["C_IYUN_M", "이윤보정액"], + ["C_EIS_HOLLAB", "고용개선 주휴노무비"], + ["C_KONGSA_J", "공사보험 대상액"], + ["B_KONGSA", "공사보험 대상액 고정(1)"], + ["B_BUGA_UP", "부가세 +1원(1)"], + ["B_KWANGUP_D", "관급 내역에서(1)"], + ["B_T_JODAL", "조달수수료 내역에서(1)"], + ]; + // 요율 칸(옛 rates 코드) — 형식별로 보이는 것 + const RATE_ROWS = + "KANNO SANBO KOBO KUNBO SLVBO YUNBO TBUGUM ANJUN ANJUN1 ANJUN2 KITA S_SUDO S_BOKRI S_SOMO S_TONGSIN S_SEGUM S_PEGI S_DOSEO S_JIGUP KONGSU HADOSU HWAN EQIP BUDM_SM BUDM_IC ILBAN IYUN ANJUNP KONGSA S_SIGONG S_SIGONG_1 DOSEOH EIS_HOLIR1 EIS_HOLIR2 EIS_SOCIR2 EIS_INCENT R_DM R_DL R_L R_DMDL R_DML R_MxA R_MxK".split( + " ", + ); + + global.STMATE_KANJUB_AXES = { + UNITS, + COMMON, + OPTN, + AXIS, + SCHEME_AXES, + INPUTS, + RATE_ROWS, + schemeKeys, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/scr_kanjub_rate.js b/tools/stmate-test/scr_kanjub_rate.js new file mode 100644 index 000000000..ccf41cbbb --- /dev/null +++ b/tools/stmate-test/scr_kanjub_rate.js @@ -0,0 +1,374 @@ +/* -*- coding: utf-8 -*- */ +/** + * 제비율표(wM_KAN_RATE) 화면 + 요율 조회 (scr_kanjub_rate.js) + * RATE_R(50×35) 행렬 = 5행 묶음(값 4행 + 빈 1행) · 열 1~5 간접노무비 · 6~10 기타경비(토목·산설토목·조경·건축·산설건축) + * 묶음 시작 행 = 공사규모(20 50억 미만 · 30 50~300억 · 35 300~1000억 · 40 1000억 이상 · 10 소규모 — 값이 있는 표만) + * 묶음 안 행 = 공사기간(I_KONG_KIKAN 화면 순번) · 일반관리비 열 11 · 이윤 열 16(묶음 첫 행) + * 부속표 — 산재[45,1] · 건강[46,1] · 연금[46,6] · 장기요양[46,11] · 고용 등급[45,10+등급] · 환경[47,공종] · 건설기계[49,구분+1] + * 안전관리비 — 열 17 율 · 18 기초액(천원) · 행 = 규모 묶음(10 5억 미만 · 20 5~50억 · 25 50억 이상) + 공종 자리(건축0·토목1·특수2·철도3·중건설4) + * 근거: 지식DB STmate/old/20_분석 06 §1~2 · 10 §4 · 12 §8~10 · 15 §4(좌표 확정) · 30_원자료/TWM_KAN_RATE.json + * 순환: 공사규모 밑수가 총공사비면 요율 → 금액 → 규모 → 요율 고리 — 왕복하면 자동 확정하지 않고 알림(06 §2 · 11 §8) + * 정답 대조: resources/tester/test_stmate_kanjub_rate.py(실무 7건 저장 요율) + */ +const StmateRate = (() => { + const n0 = (v) => Number(String(v ?? "").replace(/,/g, "")) || 0; + // 공사종류 내부값(cmBx_Kong_Type 0 토목·2 조경·4 산설토목·3 건축·1 산설건축·5 국가유산) → 간접노무비 열 + const KONG_COL = { 0: 1, 4: 2, 2: 3, 3: 4, 1: 5, 5: 21 }; + const KONG_NAME = { + 0: "토목", + 4: "산업설비(토목)", + 2: "조경", + 3: "건축", + 1: "산업설비(건축)", + 5: "국가유산", + }; + // 공사규모 묶음(간접노무비·기타경비·일반관리비·이윤) — 시작 행 · 상한(원) + const BANDS = [ + { row: 10, max: 5e8, name: "5억 미만(소규모)", optional: true }, + { row: 20, max: 50e8, name: "50억 미만" }, + { row: 30, max: 300e8, name: "50억~300억 미만" }, + { row: 35, max: 1000e8, name: "300억~1000억 미만" }, + { row: 40, max: Infinity, name: "1000억 이상" }, + ]; + const KIKAN = ["6개월 이하", "7개월~12개월 이하", "13개월~36개월 이하", "36개월 이상"]; + // 안전관리비 — 공종 내부값(cmBx_AnJun_Type 건축0·토목1·중건설3·특수4) → 행 자리(옛 철도·궤도 = 2) + const ANJUN_OFF = { 0: 0, 1: 1, 4: 2, 2: 3, 3: 4 }; + const ANJUN_BANDS = [ + { row: 10, max: 5e8, name: "5억 미만" }, + { row: 20, max: 50e8, name: "5억~50억 미만", base: true }, + { row: 25, max: Infinity, name: "50억 이상" }, + ]; + const HEALTH_ROW = 35; // 보건관리자 선임 대상(800억 이상) + + // DBF 행(RATE_01..·RATE0..) → 숫자 행렬 + function matrixFromRows(rows) { + if (!Array.isArray(rows) || !rows.length) return []; + if (Array.isArray(rows[0])) return rows.map((r) => r.map(n0)); + const keys = Object.keys(rows[0]) + .filter((k) => /^RATE_?\d+$/.test(k)) + .sort((a, b) => Number(a.replace(/\D/g, "")) - Number(b.replace(/\D/g, ""))); + return rows.map((r) => keys.map((k) => n0(r[k]))); + } + const cell = (m, r, c) => (m[r] && m[r][c - 1] != null ? n0(m[r][c - 1]) : 0); + + // 규모 묶음 고르기 — 소규모 묶음은 그 표에 값이 있을 때만 + function bandOf(m, scale, col) { + for (const b of BANDS) { + if (b.optional && !cell(m, b.row, col)) continue; + if (scale < b.max) return b; + } + return BANDS[BANDS.length - 1]; + } + + // 요율 조회 — opts: kongType · kikan · scale(직접공사비) · anjunType · anjunScale · koboType · hwan · eqip · tbugum + function lookup(m, opts = {}) { + const rates = {}, + cells = {}, + amounts = {}, + notes = []; + const put = (code, r, c, v = cell(m, r, c)) => ((rates[code] = v), (cells[code] = [r, c]), v); + const col = KONG_COL[opts.kongType ?? 0] ?? 1, + kik = Math.min(3, Math.max(0, n0(opts.kikan))); + const band = bandOf(m, n0(opts.scale), col); + if (col === 21) { + // 국가유산 — 행 0~19 4묶음(0·5·10·15) · 열 21 간접노무비 · 26 기타경비(10 §4) + const b = [0, 5, 10, 15][Math.max(0, BANDS.indexOf(band) - 1)] ?? 0; + put("KANNO", b + kik, 21); + put("KITA", b + kik, 26); + } else { + put("KANNO", band.row + kik, col); + put("KITA", band.row + kik, col + 5); + } + const head = band.optional ? 20 : band.row; + put("ILBAN", head, opts.ilbanType === 1 ? 15 : 11); + put("IYUN", head, 16); + put("SANBO", 45, 1); + put("KUNBO", 46, 1); + put("YUNBO", 46, 6); + put("SLVBO", 46, 11); + const kg = n0(opts.koboType); + if (kg > 0) put("KOBO", 45, 10 + Math.min(7, kg)); + else rates.KOBO = 0; + // 안전관리비 — 대상액 규모 · 공종 · 5~50억은 기초액(천원 → 원) + const off = ANJUN_OFF[n0(opts.anjunType)] ?? 1, + as = n0(opts.anjunScale); + const ab = opts.health ? { row: HEALTH_ROW } : ANJUN_BANDS.find((b) => as < b.max); + for (const c of ["ANJUN", "ANJUN1", "ANJUN2"]) put(c, ab.row + off, 17); + const g = ab.base ? cell(m, ab.row + off, 18) * 1000 : 0; + Object.assign(amounts, { C_ANJUN_G: g, C_ANJUN1_G: g, C_ANJUN2_G: g }); + const hw = n0(opts.hwan); + if (hw > 0) put("HWAN", 47, hw); + else rates.HWAN = 0; + const eq = opts.eqip == null ? 0 : n0(opts.eqip); + if (eq >= 0) put("EQIP", 49, eq + 1); + else rates.EQIP = 0; + // 퇴직공제부금비 — 46행 16~19열(토목·준설·건축·기타) · 사용자 선택일 때만 넣음 + put("TBUGUM", 46, 16 + Math.min(3, Math.max(0, n0(opts.tbugum)))); + for (const [c, v] of Object.entries(rates)) + if (!v && cells[c] && !["KOBO", "HWAN", "EQIP", "TBUGUM"].includes(c)) + notes.push(`${c} — 표 칸 [${cells[c]}] 이 비어 있음(직접 입력 요율 유지)`); + return { rates, cells, amounts, band, anjunBand: ab, notes }; + } + + // 설정 → 조회 입력(직접공사비 · 안전관리비 대상액은 계산 결과에서) + function optsFromCfg(cfg, kj) { + const k = (cfg && cfg.keys) || {}; + return { + kongType: n0(k.I_KONG_TYPE), + kikan: n0(k.I_KONG_KIKAN), + scale: kj ? n0(kj.dT) : 0, + anjunType: n0(k.I_ANJUN_TYPE), + anjunScale: kj ? n0(kj.anjunBaseRaw) : 0, + koboType: n0(k.I_KOBO_TYPE), + hwan: n0(k.I_HWAN), + eqip: k.I_EQIP == null ? 0 : n0(k.I_EQIP), + tbugum: n0(k.I_TBUGUM), + ilbanType: n0(k.I_ILBAN), + }; + } + + // 조회 결과 → 설정(R_·C_ANJUN_G 키와 옛 rates 칸 함께) · 빈 칸은 건너뜀 · 퇴직공제는 withTbugum 일 때만 + function applyToCfg(cfg, res, { withTbugum = false } = {}) { + cfg.rates = cfg.rates || {}; + cfg.keys = cfg.keys || {}; + const RK = { KITA: "T_KITA", EQIP: "EQIPSU" }; + for (const [c, v] of Object.entries(res.rates)) { + if (c === "TBUGUM" && !withTbugum) continue; + if (!v && !["KOBO", "HWAN", "EQIP", "TBUGUM"].includes(c)) continue; + cfg.rates[c] = v; + cfg.keys["R_" + (RK[c] || c)] = v; + } + Object.assign(cfg.keys, res.amounts); + cfg.anjunBaseAdd = res.amounts.C_ANJUN_G; + return cfg; + } + + // 순환 점검 — 규모 밑수를 계산 결과에서 다시 잡을 때 묶음이 왕복하면 멈추고 알림(06 §2 계약) + function applyWithLoopCheck(cfg, m, calcFn, { scaleOf = (kj) => kj.dT, maxIter = 6 } = {}) { + const seen = []; + let kj = calcFn(cfg); + for (let i = 0; i < maxIter; i++) { + const res = lookup(m, Object.assign(optsFromCfg(cfg, kj), { scale: scaleOf(kj) })); + const sig = `${res.band.row}/${res.anjunBand.row}`; + if (seen.includes(sig) && seen[seen.length - 1] !== sig) + return { + cfg, + kj, + loop: true, + msg: "공사규모에 따른 제비율 변경으로 공사규모가 계속 변경되는 무한루프 — 일반관리비·이윤 등 요율을 직접 수정", + }; + if (seen[seen.length - 1] === sig) return { cfg, kj, loop: false }; + seen.push(sig); + applyToCfg(cfg, res); + kj = calcFn(cfg); + } + return { cfg, kj, loop: true, msg: "요율 확정이 수렴하지 않음 — 직접 확정" }; + } + + const api = { KONG_COL, KONG_NAME, BANDS, KIKAN, ANJUN_OFF, ANJUN_BANDS, HEALTH_ROW }; + Object.assign(api, { matrixFromRows, cell, bandOf, lookup, optsFromCfg, applyToCfg }); + return Object.assign(api, { applyWithLoopCheck }); +})(); + +if (typeof window !== "undefined") window.StmateRate = StmateRate; +if (typeof module !== "undefined" && module.exports) module.exports = StmateRate; + +// ── 화면 wM_KAN_RATE — 표 고르기(공사 안 RATE_* · 기준 JB_R) · 이름 붙은 표(규모·기간 · 부속표) · 원본 행렬 · 조회·적용 +(function (global) { + if (!global.document) return; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const R = StmateRate; + const n0 = (v) => Number(String(v ?? "").replace(/,/g, "")) || 0; + const esc = (s) => + String(s ?? "").replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const STC_TABLES = ["RATE_R.DBF", "RATE_RA.DBF", "RATE_M.DBF", "RATE.DBF"]; + + function current(ctx) { + const u = ctx.uiState; + u.krMats = u.krMats || {}; + const tables = (ctx.dbfData && ctx.dbfData.rate_tables) || {}; + for (const t of STC_TABLES) + if (tables[t] && !u.krMats[t]) u.krMats[t] = R.matrixFromRows(tables[t]); + const src = u.krSrc && u.krMats[u.krSrc] ? u.krSrc : Object.keys(u.krMats)[0] || ""; + return { u, src, m: u.krMats[src] || [] }; + } + const box = (m, r, c, w = 54) => + ``; + + function scaleGrid(m) { + const kongs = [ + [1, "토목"], + [2, "산설(토)"], + [3, "조경"], + [4, "건축"], + [5, "산설(건)"], + [21, "국가유산"], + ]; + const head = `공사규모공사기간${kongs.map(([, l]) => `간노 ${l}`).join("")}${kongs.map(([, l]) => `기타 ${l}`).join("")}일반관리비(일반)일반관리비(전문)이윤`; + const body = R.BANDS.flatMap((b) => + R.KIKAN.map((k, p) => { + const row = b.row + p, + rr = (c) => (c === 21 ? Math.max(0, row - 20) : row); + const cells = + kongs.map(([c]) => `${box(m, rr(c), c)}`).join("") + + kongs.map(([c]) => `${box(m, rr(c), c + 5)}`).join(""); + const tail = + p === 0 + ? `${box(m, row, 11)}${box(m, row, 15)}${box(m, row, 16)}` + : ""; + return `${p === 0 ? `${esc(b.name)}` : ""}${k}${cells}${tail}`; + }), + ); + return `${head}${body.join("")}
`; + } + + function annexGrid(m) { + const ins = [ + ["산재보험료", 45, 1], + ["건강보험료", 46, 1], + ["연금보험료", 46, 6], + ["노인장기요양", 46, 11], + ] + .map(([l, r, c]) => `${l}${box(m, r, c)}`) + .join(""); + const kobo = [1, 2, 3, 4, 5, 6, 7] + .map( + (g) => + `${g < 7 ? `${g} 등급` : "7 등급이하"}${box(m, 45, 10 + g)}`, + ) + .join(""); + const ag = [ + ["건축", 0], + ["토목", 1], + ["중건설", 3], + ["특수건설", 4], + ] + .map(([l, t]) => { + const o = R.ANJUN_OFF[t]; + return `${l}${box(m, 10 + o, 17)}${box(m, 20 + o, 17)} + ${box(m, 20 + o, 18, 60)}천원${box(m, 25 + o, 17)}${box(m, R.HEALTH_ROW + o, 17)}`; + }) + .join(""); + const strip = (r, c0, nn, lab) => + Array.from({ length: nn }, (_, i) => `${lab(i)}
${box(m, r, c0 + i, 44)}`).join( + "", + ); + return `
+ ${ins}
보험요율
+ ${kobo}
고용보험 등급요율
+ ${ag}
안전 공종5억 미만5~50억 미만 + 기초액50억 이상보건관리자 대상
+ ${strip(47, 1, 14, (i) => i + 1)}
환경보전비 (재료비+직접노무비+산출경비) × 율 — 공종 번호
+ ${strip(49, 1, 10, (i) => i)}
건설기계대여금 지급보증 — 구분 번호(0 일반공사)
+ ${strip(46, 16, 4, (i) => ["토목", "준설", "건축", "기타"][i])}
퇴직공제부금비 직접노무비 × 율
`; + } + + function rawGrid(m) { + const cols = m[0] ? m[0].length : 0; + const head = Array.from({ length: cols }, (_, c) => `${c + 1}`).join(""); + const body = m.map( + (row, r) => + `${r}${row.map((_, c) => `${box(m, r, c + 1, 46)}`).join("")}`, + ); + return `${head}${body.join("")}
행
`; + } + + function lookupPanel(ctx, m) { + const S = global.STMATE_KANJUB_SCREEN; + if (!S || !m.length) return ""; + const { n, cfg } = S.stageCfg(ctx); + const kj = + (ctx.computed.stages && ctx.computed.stages[n] && ctx.computed.stages[n].kanjub) || + ctx.computed.kanjubComputed; + const res = R.lookup(m, R.optsFromCfg(cfg, kj)); + const rows = Object.entries(res.rates) + .map(([c, v]) => { + const cur = cfg.rates[c]; + return `${c}${res.cells[c] ? `[${res.cells[c]}]` : "-"}${v}${cur ?? ""}${cur != null && cur !== v ? "다름(수동값)" : ""}`; + }) + .join(""); + return `
${n}차수 조회 — 규모 ${esc(res.band.name)} · 안전 ${esc(res.anjunBand.name || "보건관리자 대상")} · 기초액 ${res.amounts.C_ANJUN_G} +
+ ${rows}
항목칸표 값지금 요율
+ ${res.notes.map((t) => `
${esc(t)}
`).join("")}`; + } + + function render(ctx) { + const { u, src, m } = current(ctx); + const jbr = (ctx.sourcesData && ctx.sourcesData.jbr_files) || []; + const tab = u.krTab || "scale"; + const srcOpts = Object.keys(u.krMats) + .map((t) => ``) + .join(""); + const tabs = [ + ["scale", "규모·기간(Rate_R/R1)"], + ["annex", "부속표(보험·안전·환경·보증)"], + ["raw", "원본 행렬"], + ] + .map( + ([t, l]) => + ``, + ) + .join(""); + const body = !m.length + ? "
표 없음 — 공사를 불러오거나 기준 JB_R 을 엶
" + : tab === "annex" + ? annexGrid(m) + : tab === "raw" + ? rawGrid(m) + : scaleGrid(m); + return `
원가계산 제비율표 [TWM_KAN_RATE · ${esc(src)} ${m.length}행×${m[0] ? m[0].length : 0}열]
+
+ +
+ ${lookupPanel(ctx, m)}
${tabs}
${body}
`; + } + + function bind(ctx, api) { + const re = () => api.renderWorkspace(); + const { u, src, m } = current(ctx); + api.onChange("kr-src", (e) => ((u.krSrc = e.target.value), re())); + api.bindEach(".kr-tab2", "onclick", (b) => ((u.krTab = b.getAttribute("data-t")), re())); + api.onClick("kr-back", () => ((u.activeScreen = "wM_KanJub"), re())); + api.onClick("kr-load", async () => { + const p = (api.$("kr-jbr") || {}).value; + if (!p) return; + const d = await api.postJson("/api/load-jbr", { jbr_path: p }); + u.krMats[d.name] = R.matrixFromRows(d.rows); + u.krSrc = d.name; + re(); + }); + api.bindEach(".kr-cell", "onchange", (el) => { + const r = n0(el.getAttribute("data-r")), + c = n0(el.getAttribute("data-c")); + if (!m[r]) return; + m[r][c - 1] = n0(el.value); + // 공사 안 표면 원본 행(RATE_xx 칸)에도 적음 + const rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src]; + if (rows && rows[r]) { + const keys = Object.keys(rows[r]) + .filter((k) => /^RATE_?\d+$/.test(k)) + .sort((a, b) => n0(a.replace(/\D/g, "")) - n0(b.replace(/\D/g, ""))); + if (keys[c - 1]) rows[r][keys[c - 1]] = String(n0(el.value)); + } + re(); + }); + api.onClick("kr-apply", () => { + const S = global.STMATE_KANJUB_SCREEN, + { n, cfg } = S.stageCfg(ctx); + api.recompute(); + const kj = (ctx.computed.stages[n] || {}).kanjub || ctx.computed.kanjubComputed; + const res = R.lookup(m, R.optsFromCfg(cfg, kj)); + R.applyToCfg(cfg, res, { withTbugum: !!(api.$("kr-tbugum") || {}).checked }); + u.kjMsg = res.notes.join(" · "); + re(); + }); + } + + P.screens.wM_KAN_RATE = { render, bind }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/scr_qty_list.js b/tools/stmate-test/scr_qty_list.js new file mode 100644 index 000000000..ca5243a56 --- /dev/null +++ b/tools/stmate-test/scr_qty_list.js @@ -0,0 +1,497 @@ +/* -*- coding: utf-8 -*- */ +/** + * 수량산출서 화면 (scr_qty_list.js) — wQtyList + * 탭1 수량산출서 목록표(기본 수량변수 · QCODE) · 탭2 수량산출서 보기(확장 수량변수 · 셀별명 · 내역서로 가져가기) · + * 탭3 코드 종류별 수량(9종) · 내역↔BDQTY 수량 연결(자원 역집계) + * 근거: REA 28 TWQTYLIST(Tab1 · Tab2 · sBtnTrans 기본 수량변수 전송 · sBtnSaveToFile · ActDef* 필드 설정 · + * ActSheetToBoqByName/ByCode · LBoxGoTo 변수셀 찾기 · LBoxQty 수량변수 산근에 전송 · cBoxCode 9종) · + * 지식DB 28 §2 · 02 QCODE · 10 ST_U_ConBD · 계산 = scr_qty_sheet.js + */ +(function (global) { + "use strict"; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const S = () => global.STMATE_QTY_SHEET; + const num = (v) => Number(String(v ?? "").replace(/,/g, "")) || 0; + const esc = (s) => + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const ROLES = [ + ["name", "내역명칭"], + ["size", "내역규격"], + ["unit", "내역단위"], + ["qty", "내역수량"], + ["code", "내역코드"], + ["mamt", "자재단가"], + ["lamt", "노무단가"], + ["samt", "경비단가"], + ["no", "공종번호"], + ]; + const TABS = [ + ["list", "수량산출서 목록표 (기본 수량변수)"], + ["sheet", "수량산출서 보기 (확장 수량변수)"], + ["code", "코드별 수량 · 자원 역집계"], + ]; + + function state(ctx, app) { + const u = ctx.uiState; + const d = ctx.dbfData; + if (d && !d.qcode_rows && !u.qcodeLoading && ctx.currentSourcePath) { + u.qcodeLoading = true; + app + .postJson("/api/qty/qcode", { source_path: ctx.currentSourcePath }) + .then((r) => ((d.qcode_rows = r.rows || []), app.renderWorkspace())) + .catch(() => (d.qcode_rows = [])); + } + u.qtySheets = u.qtySheets || {}; + const key = ctx.currentSourcePath || "-"; + u.qtySheets[key] = u.qtySheets[key] || S().newSheet(); + return { sheet: u.qtySheets[key], key, qrows: (d && d.qcode_rows) || [] }; + } + + function render(ctx, app) { + const { sheet, qrows } = state(ctx, app); + const tab = ctx.uiState.qtyTab || "list"; + const body = + tab === "list" + ? renderList(ctx, app, qrows) + : tab === "sheet" + ? renderSheet(ctx, app, sheet) + : renderCode(ctx, app); + return ` +
+
수량산출서 [wQtyList · QCODE.DBF ${qrows.length}건]${ctx.dbfData ? "" : "프로젝트를 먼저 여세요"}
+
${TABS.map(([k, l]) => ``).join("")} + + +
+ ${body} +
`; + } + + // 탭1 — 수량코드(기본 수량변수) + function renderList(ctx, app, qrows) { + const u = ctx.uiState; + const sel = u.qtyQSel ?? -1; + const f = (r, k) => + ``; + const errs = u.qtyCheck || []; + return ` +
+ + + + 전송 + → 내역 + + +
+ ${errs.length ? `
${errs.map(esc).join("
")}
` : u.qtyCheck ? `
검사 통과
` : ""} +
+
+ ${Array.from({ length: 10 }, (_v, k) => ``).join("")} + ${qrows + .map( + ( + r, + i, + ) => ` + + + + ${Array.from({ length: 10 }, (_v, k) => ``).join("")}`, + ) + .join("")}
수량코드명칭내역코드QTY${k}
${f(r, `QTY${k}`)}
+ ${ + sel >= 0 && qrows[sel] + ? `
+
${esc(qrows[sel].CODE)} 연결식(IFORM · QTYn=셀별명 식)
+ +
확장 수량변수(XCODE · 이름=값)
+ +
수량변수 산근에 전송 — 산출근거에 넣을 글
+
${varTokens(qrows[sel]).map(esc).join("
") || "-"}
+
` + : "" + } +
`; + } + const varTokens = (r) => { + const c = String(r.CODE || "").trim(); + const out = []; + for (let k = 0; k < 10; k++) if (String(r[`QTY${k}`] ?? "").trim()) out.push(`~${c}.QTY${k}~`); + for (const ln of String(r.XCODE || "").split(/\r?\n/)) + if (ln.includes("=")) out.push(`~${c}.${ln.split("=")[0].trim()}~`); + return out; + }; + + // 탭2 — 시트(셀별명 · 필드 설정 · 내역서로 가져가기) + function renderSheet(ctx, app, sheet) { + const u = ctx.uiState; + const ev = S().evalSheet(sheet); + const cur = u.qtyCell || "A1"; + const aliasAt = {}; + for (const [n, a] of Object.entries(sheet.aliases)) aliasAt[a.toUpperCase()] = n; + const roleOpt = (k) => + ``; + const kind = u.qtyPickKind || "ALL"; + const test = (S().KINDS.find((k) => k[0] === kind) || S().KINDS[0])[2]; + const picks = ((ctx.dbfData && ctx.dbfData.costn_rows) || []) + .filter((r) => test(String(r.CODE).trim())) + .slice(0, 400); + return ` +
+ 셀 ${esc(cur)} + + + 시작줄끝줄 + ${ROLES.map(([k, l]) => ``).join("")} + + +
+
+
+ ${Array.from( + { length: sheet.cols }, + (_v, c) => + ``, + ).join("")} + ${Array.from({ length: sheet.rows }, (_v, r) => { + const inRange = + r >= Math.min(sheet.start, sheet.end) && r <= Math.max(sheet.start, sheet.end); + return `${Array.from( + { length: sheet.cols }, + (_w, c) => { + const a = S().addr(r, c); + const v = ev.values[a]; + const shown = + v === undefined + ? "" + : typeof v === "number" + ? app.fmt(v, Number.isInteger(v) ? 0 : 3) + : v; + return ``; + }, + ).join("")}`; + }).join("")}
${S().colName(c)}${Object.entries(sheet.roles) + .filter(([, v]) => v === S().colName(c)) + .map(([k]) => ` (${(ROLES.find((x) => x[0] === k) || [])[1]})`) + .join("")}
${r + 1}${aliasAt[a] ? `${esc(aliasAt[a])} ` : ""}${esc(shown)}
+
+
변수셀 찾기(셀별명)
+
${ + Object.entries(sheet.aliases) + .map( + ([n, a]) => + `
${esc(n)} = ${esc(a)} → ${app.fmt(ev.aliasValues[n.toUpperCase()] || 0, 3)} ×
`, + ) + .join("") || "-" + }
+
내역코드란에 단가코드 입력
+ + +
${picks.map((r) => `
${esc(String(r.CODE).trim())} ${esc(r.NAME)} ${esc(r.SIZE)}
`).join("")}
+
+
`; + } + + // 탭3 — 코드 종류별 내역 수량 · 내역↔BDQTY 자원 역집계 + function renderCode(ctx, app) { + const u = ctx.uiState; + const kind = u.qtyCodeKind || "ALL"; + const field = u.qtyCodeField || "QTY1"; + const boq = (ctx.dbfData && ctx.dbfData.boq_rows) || []; + const list = S().qtyByCode(boq, kind, field); + const roll = S().rollup(boq, (ctx.dbfData && ctx.dbfData.bdqty_rows) || [], field); + const test = (S().KINDS.find((k) => k[0] === kind) || S().KINDS[0])[2]; + const by = Object.fromEntries( + ((ctx.dbfData && ctx.dbfData.costn_rows) || []).map((r) => [String(r.CODE).trim(), r]), + ); + const res = Object.entries(roll.totals) + .filter(([c]) => test(c)) + .sort(([a], [b]) => a.localeCompare(b)); + return ` +
+ + ${roll.cycles.length ? `순환참조 ${roll.cycles.length}건 — ${esc(roll.cycles[0])}` : ""}
+
+
내역 코드별 수량 ${list.length}건
+ + ${list.map((o) => ``).join("")}
코드명칭규격단위내역 행수량 계
${esc(o.code)}${esc(o.name)}${esc(o.size)}${esc(o.unit)}${o.rows}${app.fmt(o.qty, 3)}
+
내역↔BDQTY 수량 연결(자원 소요량) ${res.length}건
+ + ${res.map(([c, q]) => ``).join("")}
코드명칭규격단위소요 수량
${esc(c)}${esc((by[c] || {}).NAME)}${esc((by[c] || {}).SIZE)}${esc((by[c] || {}).UNIT)}${app.fmt(q, 4)}
+
`; + } + + // 내역 반영 — 코드가 같은 행 수량 바꿈 · 없으면 끝에 새 행(STMATE_UI_BOQ 모델) + function applyToBoq(ctx, items, dest) { + const B = global.STMATE_UI_BOQ; + const model = { + rows: ctx.dbfData.boq_rows, + items: ctx.state.boqItems, + deleted: (ctx.dbfData.boq_deleted = ctx.dbfData.boq_deleted || []), + }; + const by = Object.fromEntries( + (ctx.dbfData.costn_rows || []).map((r) => [String(r.CODE).trim(), r]), + ); + const used = new Set(); + let upd = 0, + add = 0; + const fresh = []; + for (const it of items) { + const hit = + it.code && + model.rows.find((r) => !used.has(r) && String(r.BDCODE1 || "").trim() === it.code); + if (hit) { + used.add(hit); + if (dest === "QTY1" && B) B.setCell(model, hit._recno, "QTY1", it.qty); + else hit[dest] = String(it.qty); + upd++; + } else if (B) { + const cr = by[it.code] || {}; + fresh.push( + B.makeRow(model, { + BDCODE1: it.code, + NAME1: it.name || cr.NAME || "", + SIZE1: it.size || cr.SIZE || "", + UNIT1: it.unit || cr.UNIT || "", + [dest]: String(it.qty), + ITNUM: it.itnum || "", + }), + ); + add++; + } + } + if (fresh.length) { + const res = B.insertRows(model, model.rows.length, fresh); + if (res && res.ok === false) alert(res.error); + } + return { upd, add }; + } + + function bind(ctx, app) { + const $ = (id) => document.getElementById(id); + const u = ctx.uiState; + const { sheet, key, qrows } = state(ctx, app); + const redraw = () => app.renderWorkspace(); + document + .querySelectorAll(".qty-tab") + .forEach((b) => (b.onclick = () => ((u.qtyTab = b.dataset.tab), redraw()))); + $("qty-file-save").onclick = () => + app.downloadBlob(S().serialize(sheet, qrows), "수량산출서.stq.json", "application/json"); + $("qty-file-load").onclick = () => $("qty-file-input").click(); + $("qty-file-input").onchange = (e) => { + const f = e.target.files && e.target.files[0]; + if (!f) return; + const rd = new FileReader(); + rd.onload = () => { + const d = S().deserialize(String(rd.result)); + if (d.error) return alert(d.error); + u.qtySheets[key] = d.sheet; + if (d.qcode.length && ctx.dbfData) ctx.dbfData.qcode_rows = d.qcode; + redraw(); + }; + rd.readAsText(f); + }; + $("qty-file-reset").onclick = () => + confirm("수량산출파일을 초기화할까요?") && ((u.qtySheets[key] = S().newSheet()), redraw()); + const tab = u.qtyTab || "list"; + if (tab === "list") bindList(ctx, app, qrows, sheet); + else if (tab === "sheet") bindSheet(ctx, app, sheet); + else { + $("qty-code-kind").onchange = (e) => ((u.qtyCodeKind = e.target.value), redraw()); + $("qty-code-field").onchange = (e) => ((u.qtyCodeField = e.target.value), redraw()); + } + } + + function bindList(ctx, app, qrows, sheet) { + const $ = (id) => document.getElementById(id); + const u = ctx.uiState; + const redraw = () => app.renderWorkspace(); + const d = ctx.dbfData; + if (!d) return; + document + .querySelectorAll(".qty-q") + .forEach( + (el) => + (el.onchange = () => ( + (qrows[Number(el.dataset.i)][el.dataset.k] = el.value.trim()), + (u.qtyCheck = null) + )), + ); + document + .querySelectorAll(".qty-q-sel") + .forEach((el) => (el.onchange = () => ((u.qtyQSel = Number(el.dataset.i)), redraw()))); + document + .querySelectorAll(".qty-q-memo") + .forEach((el) => (el.onchange = () => (qrows[u.qtyQSel][el.dataset.k] = el.value))); + $("qty-q-add").onclick = () => { + d.qcode_rows = qrows; + qrows.push({ CODE: S().nextQcode(qrows), NAME: "", BQCODE: "", IFORM: "", XCODE: "" }); + u.qtyQSel = qrows.length - 1; + redraw(); + }; + $("qty-q-del").onclick = () => { + if (!(u.qtyQSel >= 0) || !confirm("선택한 수량코드를 지울까요?")) return; + qrows.splice(u.qtyQSel, 1); + u.qtyQSel = -1; + redraw(); + }; + $("qty-q-check").onclick = () => ( + (u.qtyCheck = S().checkQcodes( + qrows, + new Set((d.costn_rows || []).map((r) => String(r.CODE).trim())), + )), + redraw() + ); + $("qty-q-assign").onclick = () => { + const res = S().assignFromSheet(qrows, sheet); + d.qcode_rows = res.rows; + const se = Object.entries(res.sheetErrors); + u.qtyCheck = res.errors + .map((e) => `${e.code}: ${e.msg}`) + .concat( + se.length + ? [`셀참조 에러가 있는 수량변수(셀별명) ${se.length}건 — ${se[0][0]} ${se[0][1]}`] + : [], + ); + redraw(); + }; + $("qty-q-field").onchange = (e) => (u.qtyQField = e.target.value); + $("qty-q-dest").onchange = (e) => (u.qtyQDest = e.target.value); + $("qty-q-trans").onclick = () => { + const fld = $("qty-q-field").value, + dest = $("qty-q-dest").value; + const items = qrows + .filter((r) => String(r.BQCODE || "").trim()) + .map((r) => ({ code: String(r.BQCODE).trim(), name: r.NAME, qty: num(r[fld]) })); + if (!items.length) return alert("내역코드(BQCODE)가 있는 수량코드가 없음"); + const r = applyToBoq(ctx, items, dest); + app.cascadeAndRender(); + alert(`기본 수량변수 전송 — 내역 ${r.upd}행 수량 바꿈 · ${r.add}행 새로 넣음`); + }; + $("qty-q-save").onclick = async () => { + const errs = S().checkQcodes( + qrows, + new Set((d.costn_rows || []).map((r) => String(r.CODE).trim())), + ); + if (errs.length) return ((u.qtyCheck = errs), redraw()); + try { + const res = await app.postJson("/api/qty/qcode-save", { + source_path: ctx.currentSourcePath, + target_folder_name: (ctx.state.projectMeta.pname || "stmate_edited").slice(0, 28), + rows: qrows, + }); + alert(`수량코드 ${res.count}건 저장 — ${res.saved_stc}`); + } catch (err) { + alert("수량코드 저장 실패: " + err.message); + } + }; + } + + function bindSheet(ctx, app, sheet) { + const $ = (id) => document.getElementById(id); + const u = ctx.uiState; + const redraw = () => app.renderWorkspace(); + const cur = u.qtyCell || "A1"; + document + .querySelectorAll(".qty-cell") + .forEach((td) => (td.onclick = () => ((u.qtyCell = td.dataset.a), redraw()))); + $("qty-cell-edit").onchange = (e) => { + const v = e.target.value; + if (v === "") delete sheet.cells[cur]; + else sheet.cells[cur] = v; + const p = S().parseAddr(cur); + if (p && p.r + 1 < sheet.rows) u.qtyCell = S().addr(p.r + 1, p.c); + redraw(); + }; + $("qty-alias-set").onclick = () => { + const n = $("qty-alias").value.trim(); + for (const [k, a] of Object.entries(sheet.aliases)) if (a === cur) delete sheet.aliases[k]; + if (n) { + if (!/^[A-Za-z가-힣_][A-Za-z0-9가-힣_]*$/.test(n) || S().parseAddr(n)) + return alert("셀별명은 글자로 시작 · 셀 주소 꼴은 안 됨"); + sheet.aliases[n] = cur; + } + redraw(); + }; + document + .querySelectorAll(".qty-goto") + .forEach((el) => (el.ondblclick = el.onclick = () => ((u.qtyCell = el.dataset.a), redraw()))); + document + .querySelectorAll(".qty-alias-del") + .forEach( + (el) => + (el.onclick = (e) => (e.stopPropagation(), delete sheet.aliases[el.dataset.n], redraw())), + ); + $("qty-row-add").onclick = () => ((sheet.rows += 10), redraw()); + $("qty-col-add").onclick = () => ((sheet.cols += 1), redraw()); + $("qty-start").onchange = (e) => ( + (sheet.start = Math.max(0, num(e.target.value) - 1)), + redraw() + ); + $("qty-end").onchange = (e) => ((sheet.end = Math.max(0, num(e.target.value) - 1)), redraw()); + document + .querySelectorAll(".qty-role") + .forEach((el) => (el.onchange = () => ((sheet.roles[el.dataset.k] = el.value), redraw()))); + $("qty-pick-kind").onchange = (e) => ((u.qtyPickKind = e.target.value), redraw()); + $("qty-pick-name").onchange = (e) => (u.qtyPickName = e.target.checked); + document.querySelectorAll(".qty-pick").forEach( + (el) => + (el.ondblclick = el.onclick = + () => { + const c = el.dataset.c; + sheet.cells[cur] = c; + const p = S().parseAddr(cur); + if (u.qtyPickName && p) { + const r = + (ctx.dbfData.costn_rows || []).find((x) => String(x.CODE).trim() === c) || {}; + [r.NAME, r.SIZE, r.UNIT].forEach( + (v, k) => v && (sheet.cells[S().addr(p.r, p.c + k + 1)] = String(v).trim()), + ); + } + if (p && p.r + 1 < sheet.rows) u.qtyCell = S().addr(p.r + 1, p.c); + redraw(); + }), + ); + const toBoq = (mode) => { + const res = S().sheetToBoq(sheet, mode); + if (res.error) return alert(res.error); + if (!res.items.length) return alert("시작줄~끝줄 안에 가져갈 행이 없음"); + const by = Object.fromEntries( + (ctx.dbfData.costn_rows || []).map((r) => [String(r.CODE).trim(), r]), + ); + const bad = mode === "code" ? res.items.filter((it) => !by[it.code]) : []; + if (bad.length) + return alert( + `단가목록에 없는 내역코드: ${bad.map((b) => `${b.row}행 ${b.code}`).join(", ")}`, + ); + const r = applyToBoq(ctx, res.items, "QTY1"); + app.cascadeAndRender(); + alert(`내역서로 가져가기 — ${r.upd}행 수량 바꿈 · ${r.add}행 새로 넣음`); + }; + $("qty-to-boq-code").onclick = () => toBoq("code"); + $("qty-to-boq-name").onclick = () => toBoq("name"); + } + + P.screens.wQtyList = { render, bind }; + P.actions.Act_Qty_List = (ctx, app) => ( + (ctx.uiState.activeScreen = "wQtyList"), + app.renderWorkspace() + ); + if (!P.menus.some((m) => m.act === "Act_Qty_List")) + P.menus.push({ menu: "m3", label: "수량산출서 (wQtyList)", act: "Act_Qty_List" }); + global.STMATE_QTY_LIST = { applyToBoq }; +})(window); diff --git a/tools/stmate-test/scr_qty_sheet.js b/tools/stmate-test/scr_qty_sheet.js index e1aeaad29..0b285dbf8 100644 --- a/tools/stmate-test/scr_qty_sheet.js +++ b/tools/stmate-test/scr_qty_sheet.js @@ -19,11 +19,15 @@ const StmateQtySheet = (() => { const SHEET_SIG = "STMATE-QTY-SHEET/1"; // 열 이름 ↔ 번호(A=0) - const colName = (i) => (i < 26 ? String.fromCharCode(65 + i) : colName(Math.floor(i / 26) - 1) + colName(i % 26)); - const colIndex = (s) => [...String(s).toUpperCase()].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1; + const colName = (i) => + i < 26 ? String.fromCharCode(65 + i) : colName(Math.floor(i / 26) - 1) + colName(i % 26); + const colIndex = (s) => + [...String(s).toUpperCase()].reduce((a, c) => a * 26 + c.charCodeAt(0) - 64, 0) - 1; const addr = (r, c) => `${colName(c)}${r + 1}`; const parseAddr = (a) => { - const m = String(a).toUpperCase().match(/^([A-Z]{1,2})(\d+)$/); + const m = String(a) + .toUpperCase() + .match(/^([A-Z]{1,2})(\d+)$/); return m ? { r: Number(m[2]) - 1, c: colIndex(m[1]) } : null; }; @@ -40,7 +44,8 @@ const StmateQtySheet = (() => { errors = {}, state = {}; const aliasOf = {}; - for (const [nm, a] of Object.entries(sheet.aliases || {})) aliasOf[nm.toUpperCase()] = String(a).toUpperCase(); + for (const [nm, a] of Object.entries(sheet.aliases || {})) + aliasOf[nm.toUpperCase()] = String(a).toUpperCase(); const cellOf = (name) => aliasOf[name] || (parseAddr(name) ? name : null); function calc(a) { if (state[a] === 2) return values[a]; @@ -53,7 +58,9 @@ const StmateQtySheet = (() => { let v; if (raw.startsWith("=")) { const vars = {}; - const ids = (raw.slice(1).match(/[A-Za-z_][A-Za-z_0-9]*/g) || []).map((s) => s.toUpperCase()); + const ids = (raw.slice(1).match(/[A-Za-z_가-힣][A-Za-z_0-9가-힣]*/g) || []).map((s) => + s.toUpperCase(), + ); for (const id of ids) { const at = cellOf(id); if (at && !(id in vars)) { @@ -99,7 +106,10 @@ const StmateQtySheet = (() => { if (!m) continue; const res = X().evaluate(m[2], { ...ev.aliasValues }); if (res.error || res.missing.length) { - errors.push({ code: r.CODE, msg: `${m[1]} — ${res.error || `없는 셀별명 「${res.missing[0]}」`}` }); + errors.push({ + code: r.CODE, + msg: `${m[1]} — ${res.error || `없는 셀별명 「${res.missing[0]}」`}`, + }); continue; } row[m[1].toUpperCase()] = String(res.value); @@ -116,7 +126,9 @@ const StmateQtySheet = (() => { const seen = new Set(); const codes = costnCodes instanceof Set ? costnCodes : new Set(costnCodes || []); qrows.forEach((r, i) => { - const c = String(r.CODE || "").trim().toUpperCase(); + const c = String(r.CODE || "") + .trim() + .toUpperCase(); const at = `${i + 1}행 ${c || "(빈 코드)"}`; if (!/^Q\d{5}$/.test(c)) errs.push(`${at}: 수량코드는 Q + 숫자 다섯 자리`); if (seen.has(c)) errs.push(`${at}: 같은 수량코드가 이미 있음`); @@ -125,19 +137,22 @@ const StmateQtySheet = (() => { if (bq && codes.size && !codes.has(bq)) errs.push(`${at}: 내역코드 ${bq} 는 단가목록에 없음`); for (let k = 0; k < 10; k++) { const v = String(r[`QTY${k}`] ?? "").trim(); - if (v && !/^[-+]?[\d,]*\.?\d+$/.test(v)) errs.push(`${at}: QTY${k} 값 「${v}」 은 숫자가 아님`); + if (v && !/^[-+]?[\d,]*\.?\d+$/.test(v)) + errs.push(`${at}: QTY${k} 값 「${v}」 은 숫자가 아님`); } String(r.XCODE || "") .split(/\r?\n/) .filter((ln) => ln.trim()) .forEach((ln) => { - if (!/^\s*[A-Za-z가-힣_][^=]*=/.test(ln)) errs.push(`${at}: 확장수량변수 줄 「${ln}」 은 이름=값 꼴이 아님`); + if (!/^\s*[A-Za-z가-힣_][^=]*=/.test(ln)) + errs.push(`${at}: 확장수량변수 줄 「${ln}」 은 이름=값 꼴이 아님`); }); String(r.IFORM || "") .split(/\r?\n/) .filter((ln) => ln.trim()) .forEach((ln) => { - if (!/^\s*QTY\d\s*=/.test(ln)) errs.push(`${at}: 연결식 줄 「${ln}」 은 QTYn=식 꼴이 아님`); + if (!/^\s*QTY\d\s*=/.test(ln)) + errs.push(`${at}: 연결식 줄 「${ln}」 은 QTYn=식 꼴이 아님`); }); }); return errs; @@ -156,7 +171,10 @@ const StmateQtySheet = (() => { function sheetToBoq(sheet, mode = "code") { const ev = evalSheet(sheet); const roles = sheet.roles || {}; - const cell = (role, r) => (roles[role] === undefined || roles[role] === "" ? "" : ev.values[addr(r, colIndex(roles[role]))]); + const cell = (role, r) => + roles[role] === undefined || roles[role] === "" + ? "" + : ev.values[addr(r, colIndex(roles[role]))]; const need = mode === "code" ? ["code", "qty"] : ["name", "size", "unit", "qty"]; const missing = need.filter((k) => roles[k] === undefined || roles[k] === ""); if (missing.length) return { items: [], error: `셀별명 설정 안 됨: ${missing.join(", ")}` }; @@ -208,7 +226,14 @@ const StmateQtySheet = (() => { for (const r of boqRows || []) { const c = String(r.BDCODE1 || "").trim(); if (!c || STRUCT.test(c) || !test(c)) continue; - const o = (by[c] = by[c] || { code: c, name: r.NAME1 || "", size: r.SIZE1 || "", unit: r.UNIT1 || "", qty: 0, rows: 0 }); + const o = (by[c] = by[c] || { + code: c, + name: r.NAME1 || "", + size: r.SIZE1 || "", + unit: r.UNIT1 || "", + qty: 0, + rows: 0, + }); o.qty += num(r[field]); o.rows += 1; } @@ -222,7 +247,8 @@ const StmateQtySheet = (() => { */ function rollup(boqRows, bdqtyRows, field = "QTY1") { const groups = {}; - for (const r of bdqtyRows || []) (groups[String(r.BDCODE || "").trim()] = groups[String(r.BDCODE || "").trim()] || []).push(r); + for (const r of bdqtyRows || []) + (groups[String(r.BDCODE || "").trim()] = groups[String(r.BDCODE || "").trim()] || []).push(r); const totals = {}, cycles = []; const qOf = (row) => { @@ -262,7 +288,10 @@ const StmateQtySheet = (() => { } if (!d || d.sig !== SHEET_SIG || !d.sheet || typeof d.sheet.cells !== "object") return { error: "호환되지 않는 파일형식 — STMATE 수량산출서가 아님" }; - return { sheet: Object.assign(newSheet(), d.sheet), qcode: Array.isArray(d.qcode) ? d.qcode : [] }; + return { + sheet: Object.assign(newSheet(), d.sheet), + qcode: Array.isArray(d.qcode) ? d.qcode : [], + }; } return { diff --git a/tools/stmate-test/scr_report.js b/tools/stmate-test/scr_report.js new file mode 100644 index 000000000..e8563a806 --- /dev/null +++ b/tools/stmate-test/scr_report.js @@ -0,0 +1,218 @@ +/* -*- coding: utf-8 -*- */ +/** + * 보고서 출력 화면 (scr_report.js) — PM_Prt 대체 · 서식 목록(SET_REP 75종) · 미리보기 · 인쇄 · 엑셀/CSV · 조달청 XML + * 근거: 지식DB STmate/old/20_분석 20 §3(SET_REP) · 30(조달청 XSD) · old/30_원자료/print_form_map.txt + * 계산 = calc.js 결과(ctx.computed) · 시트 모형 = report_*.js(엑셀 대조 시험 resources/tester/test_stmate_report_*.py) + */ +(function (global) { + "use strict"; + // 보고서 짜는 모듈 — 이 끼워 넣기 파일이 불릴 때(문서 읽는 중) 함께 실음 + if ( + typeof document !== "undefined" && + document.readyState === "loading" && + !global.STMATE_REPORT_BOOK + ) + [ + "report_core.js", + "report_unit.js", + "report_boq.js", + "report_book.js", + "report_pps.js", + ].forEach((f) => document.write(``)); + + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const esc = (s) => + String(s ?? "").replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const fmtCell = (v) => + typeof v === "number" + ? new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(v) + : esc(v).replace(/\n/g, "
"); + const colIdx = (k) => k.split("").reduce((a, ch) => a * 26 + ch.charCodeAt(0) - 64, 0); + const colName = (n) => + (n > 26 ? String.fromCharCode(64 + Math.floor((n - 1) / 26)) : "") + + String.fromCharCode(65 + ((n - 1) % 26)); + + // 차수 — 원가계산서가 저장된 차수(kanjubStages) · 2 이상이면 변경 양식 + function stages(ctx) { + const st = (ctx.state && ctx.state.kanjubStages) || {}; + const ks = Object.keys(st).map(Number); + return ks.length ? ks : [1]; + } + + function book(ctx) { + const ui = ctx.uiState; + const n = Number(ui.reportStage) || 1; + const key = `${ctx.currentSourcePath}|${n}|${ctx.computed && ctx.computed.kanjubComputed ? ctx.computed.kanjubComputed.grandTotal : 0}`; + if (ui._reportBook && ui._reportBookKey === key) return ui._reportBook; + ui._reportBook = global.STMATE_REPORT_BOOK.buildBook(ctx.dbfData, ctx.state, ctx.computed, n); + ui._reportBookKey = key; + return ui._reportBook; + } + + // 시트 모형 → 표(미리보기 · 인쇄) + function sheetHtml(sh) { + let maxC = 1; + for (const r of sh.rows) + for (const k of Object.keys(r)) if (/^[A-Z]{1,2}$/.test(k)) maxC = Math.max(maxC, colIdx(k)); + const cols = Array.from({ length: maxC }, (_, i) => colName(i + 1)); + const body = sh.rows + .map((r) => { + if (r._k === "page") return ``; + const tds = cols + .map((c) => { + const v = r[c]; + const num = typeof v === "number"; + return `${v == null ? "" : fmtCell(v)}`; + }) + .join(""); + const bold = /^(title|head|T\d|sub|total|final|hopyo|parent)$/.test(r._k || ""); + return `${tds}`; + }) + .join(""); + return `${body}
`; + } + + P.screens.PM_Prt = { + render(ctx) { + if (!ctx.dbfData) + return `
보고서 출력

공사를 먼저 불러오십시오.

`; + const ui = ctx.uiState; + const B = global.STMATE_REPORT_BOOK; + const bk = book(ctx); + const ids = bk.sheets.map((s) => s.id || s.name); + if (!ids.includes(ui.reportSheet)) + ui.reportSheet = ids.includes("공사원가계산서") ? "공사원가계산서" : ids[0]; + const cur = bk.sheets.find((s) => (s.id || s.name) === ui.reportSheet); + const forms = (ctx.dbfData.set_rep_rows || []) + .slice() + .sort((a, b) => String(a.INDX).localeCompare(String(b.INDX))) + .map((r) => { + const id = B.FORM_SHEET[String(r.INDX).trim()]; + const on = id && ids.includes(id); + return `
${esc(r.INDX)} ${esc(r.NAME)}${on ? "" : " · 미구현"}
`; + }) + .join(""); + const extra = ["〓 목 차 〓", "※※안내※※"] + .filter((id) => ids.includes(id)) + .map( + (id) => + `
${esc(id)}
`, + ) + .join(""); + const stOpts = stages(ctx) + .map( + (n) => + ``, + ) + .join(""); + return `
+
보고서 출력 · 미리보기 (SET_REP 서식 ${forms ? (ctx.dbfData.set_rep_rows || []).length : 0}종)
+
+ + + + + + + +
+
+
${extra}${forms}
+
+

${esc(cur ? cur.title : "")}

${cur ? sheetHtml(cur) : ""} +
+
`; + }, + bind(ctx, api) { + const ui = ctx.uiState; + const $ = (id) => document.getElementById(id); + const msg = (t) => $("rp-msg") && ($("rp-msg").textContent = t); + document.querySelectorAll("[data-rp-sheet]").forEach((el) => { + el.onclick = () => { + const id = el.getAttribute("data-rp-sheet"); + if (!id) return; + ui.reportSheet = id; + api.renderWorkspace(); + }; + }); + if ($("rp-stage")) + $("rp-stage").onchange = (e) => { + ui.reportStage = Number(e.target.value); + api.renderWorkspace(); + }; + const pname = () => + String((ctx.dbfData.prjlst && ctx.dbfData.prjlst.PNAME) || "STMATE").replace( + /[\\/:*?"<>|]/g, + "_", + ); + const post = async (url, sheets, filename, mime) => { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sheets, filename }), + }); + if (!res.ok) throw new Error(await res.text()); + api.downloadBlob(await res.blob(), filename, mime); + }; + const cur = () => book(ctx).sheets.find((s) => (s.id || s.name) === ui.reportSheet); + const xmime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if ($("rp-xlsx-all")) + $("rp-xlsx-all").onclick = () => + post("/api/report/xlsx", book(ctx).sheets, `${pname()}.xlsx`, xmime).then( + () => msg("엑셀 저장"), + (e) => msg(e.message), + ); + if ($("rp-xlsx-one")) + $("rp-xlsx-one").onclick = () => + post("/api/report/xlsx", [cur()], `${cur().name}.xlsx`, xmime).then( + () => msg("엑셀 저장"), + (e) => msg(e.message), + ); + if ($("rp-csv")) + $("rp-csv").onclick = () => + post("/api/report/csv", [cur()], `${cur().name}.csv`, "text/csv").then( + () => msg("CSV 저장"), + (e) => msg(e.message), + ); + if ($("rp-pps")) + $("rp-pps").onclick = async () => { + try { + const xml = global.STMATE_REPORT_PPS.buildPpsXml( + ctx.dbfData, + ctx.state, + ctx.computed, + Number(ui.reportStage) || 1, + ); + const v = await api.postJson("/api/report/validate-pps", { xml }); + api.downloadBlob(xml, `${pname()}_조달청.xml`, "application/xml;charset=utf-8"); + msg( + v.ok + ? "조달청 XML 저장 · XSD 검증 통과" + : `XSD 위반 ${v.errors.length}건: ${v.errors[0] || ""}`, + ); + } catch (e) { + msg(e.message); + } + }; + if ($("rp-print")) + $("rp-print").onclick = () => { + const w = window.open("", "_blank"); + if (!w) return msg("팝업 차단됨"); + const sh = cur(); + w.document.write( + `${esc(sh.title)}

${esc(sh.title)}

${sheetHtml(sh)}`, + ); + w.document.close(); + w.focus(); + w.print(); + }; + }, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/scr_ui_boq.js b/tools/stmate-test/scr_ui_boq.js index ac2592fd9..a1e772bf4 100644 --- a/tools/stmate-test/scr_ui_boq.js +++ b/tools/stmate-test/scr_ui_boq.js @@ -7,19 +7,29 @@ (function (global) { "use strict"; - const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); const B = global.STMATE_UI_BOQ; const LV = ["= 이상", "== 이상", "=== 이상", "==== 이상", "===== 이상", "====== 이상"]; const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + // 편집 모델 — 지운 원본 행 번호는 dbfData.boq_deleted(저장 묶음 boq_deleted 로 나감) function model(ctx) { - const u = ctx.uiState; - u.boqDeleted = u.boqDeleted || []; - return { rows: ctx.dbfData.boq_rows, items: ctx.state.boqItems, deleted: u.boqDeleted }; + const d = ctx.dbfData; + d.boq_deleted = d.boq_deleted || []; + return { rows: d.boq_rows, items: ctx.state.boqItems, deleted: d.boq_deleted }; } const costnByCode = (ctx) => - Object.fromEntries(((ctx.dbfData && ctx.dbfData.costn_rows) || []).map((r) => [String(r.CODE || "").trim(), r])); + Object.fromEntries( + ((ctx.dbfData && ctx.dbfData.costn_rows) || []).map((r) => [String(r.CODE || "").trim(), r]), + ); const sel = (ctx) => (ctx.uiState.boqSel || []).map(Number); const selIdx = (ctx) => B.indexesOf(ctx.dbfData.boq_rows, sel(ctx)); @@ -30,7 +40,8 @@ d.boq_tables = d.boq_tables || {}; d.boq_tables[d.boq_table_name] = d.boq_rows; d.boq_count = d.boq_rows.length; - if (!keepSel) ctx.uiState.boqSel = sel(ctx).filter((r) => d.boq_rows.some((x) => Number(x._recno) === r)); + if (!keepSel) + ctx.uiState.boqSel = sel(ctx).filter((r) => d.boq_rows.some((x) => Number(x._recno) === r)); api.renderWorkspace(); return res; } @@ -70,25 +81,35 @@ ctx.uiState.boqMsg = `잘라내기 ${all}행`; return after(ctx, api, res); }, - paste: (ctx, api) => after(ctx, api, B.pasteRows(model(ctx), insertAt(ctx), ctx.uiState.boqClip)), + paste: (ctx, api) => + after(ctx, api, B.pasteRows(model(ctx), insertAt(ctx), ctx.uiState.boqClip)), lvUp: (ctx, api) => shift(ctx, api, 1), lvDn: (ctx, api) => shift(ctx, api, -1), note2grp: (ctx, api) => { const r = sel(ctx)[0]; - const n = Number(prompt("계시작 레벨(1~9) — 1 소계 · 2 계 · 3 합계 · 4 총계 · 5 총합계 · 6 총대계", "2")); + const n = Number( + prompt("계시작 레벨(1~9) — 1 소계 · 2 계 · 3 합계 · 4 총계 · 5 총합계 · 6 총대계", "2"), + ); if (!(n >= 1 && n <= 9)) return; return after(ctx, api, B.noteToGroup(model(ctx), r, n, ctx.dbfData.start_ini)); }, find: (ctx, api) => { - const kw = String(ctx.uiState.boqFind || "").trim().toLowerCase(); + const kw = String(ctx.uiState.boqFind || "") + .trim() + .toLowerCase(); if (!kw) return alert("찾을 글자를 넣으세요"); const rows = ctx.dbfData.boq_rows; const from = (selIdx(ctx)[0] ?? -1) + 1; - const hay = (r) => `${r.ITNUM || ""} ${r.BDCODE1 || ""} ${r.NAME1 || ""} ${r.SIZE1 || ""}`.toLowerCase(); + const hay = (r) => + `${r.ITNUM || ""} ${r.BDCODE1 || ""} ${r.NAME1 || ""} ${r.SIZE1 || ""}`.toLowerCase(); let k = rows.findIndex((r, i) => i >= from && hay(r).includes(kw)); if (k < 0) k = rows.findIndex((r) => hay(r).includes(kw)); if (k < 0) return alert(`「${kw}」 없음`); - Object.assign(ctx.uiState, { boqSel: [Number(rows[k]._recno)], boqLevelMin: 0, boqScrollTo: rows[k]._recno }); + Object.assign(ctx.uiState, { + boqSel: [Number(rows[k]._recno)], + boqLevelMin: 0, + boqScrollTo: rows[k]._recno, + }); api.renderWorkspace(); }, pocketPut: (ctx, api) => { @@ -107,13 +128,19 @@ const c = r && B.isLeaf(r) ? B.codeOf(r) : ""; if (!c) return alert("단가코드 행을 고르세요"); if (/^[MLSW]/.test(c)) { - ctx.uiState.dan = Object.assign(ctx.uiState.dan || {}, { kind: "ALL", kw: c, mode: "name", code: c }); + ctx.uiState.dan = Object.assign(ctx.uiState.dan || {}, { + kind: "ALL", + kw: c, + mode: "name", + code: c, + }); ctx.uiState.activeScreen = "wM_JabPum"; } else Object.assign(ctx.uiState, { selectedIlwiCode: c, activeScreen: "wM_Edit_iLWi" }); api.renderWorkspace(); }, qtyList: (ctx, api) => { - if (P.screens.wQtyList) return ((ctx.uiState.activeScreen = "wQtyList"), api.renderWorkspace()); + if (P.screens.wQtyList) + return ((ctx.uiState.activeScreen = "wQtyList"), api.renderWorkspace()); api.openMappedModal("Act_QtyModal"); }, }; @@ -125,9 +152,43 @@ } const MENUS = [ - ["편집", [["insBlank", "행 삽입 (Ins)"], ["kongjong", "공종목록에서 코드 넣기"], ["del", "행 삭제 (Del)"], ["jump", "코드의 단가·대가표로 이동"], null, ["copy", "복사 (Ctrl+C)"], ["cut", "잘라내기 (Ctrl+X)"], ["paste", "붙여넣기 (Ctrl+V)"], null, ["up", "위로 이동 (Alt+↑)"], ["down", "아래로 이동 (Alt+↓)"]]], - ["계표시", [["wrap", "계표시 삽입(선택 행 묶음)"], ["lvUp", "계표시 레벨 상향"], ["lvDn", "계표시 레벨 하향"], ["note2grp", "주석 → 계시작 변환"]]], - ["도구", [["itnum", "공종번호 자동입력 (wITNum)"], ["boqRate", "할증율 적용 (wBoqRate)"], ["mRate", "단수 일괄보정 (wM_Rate)"], ["expnd", "일위대가표 풀기 (wExpnd)"], null, ["mboq", "주공종 집계표 (wMBoq)"], ["qtyList", "수량산출서 (wQtyList)"]]], + [ + "편집", + [ + ["insBlank", "행 삽입 (Ins)"], + ["kongjong", "공종목록에서 코드 넣기"], + ["del", "행 삭제 (Del)"], + ["jump", "코드의 단가·대가표로 이동"], + null, + ["copy", "복사 (Ctrl+C)"], + ["cut", "잘라내기 (Ctrl+X)"], + ["paste", "붙여넣기 (Ctrl+V)"], + null, + ["up", "위로 이동 (Alt+↑)"], + ["down", "아래로 이동 (Alt+↓)"], + ], + ], + [ + "계표시", + [ + ["wrap", "계표시 삽입(선택 행 묶음)"], + ["lvUp", "계표시 레벨 상향"], + ["lvDn", "계표시 레벨 하향"], + ["note2grp", "주석 → 계시작 변환"], + ], + ], + [ + "도구", + [ + ["itnum", "공종번호 자동입력 (wITNum)"], + ["boqRate", "할증율 적용 (wBoqRate)"], + ["mRate", "단수 일괄보정 (wM_Rate)"], + ["expnd", "일위대가표 풀기 (wExpnd)"], + null, + ["mboq", "주공종 집계표 (wMBoq)"], + ["qtyList", "수량산출서 (wQtyList)"], + ], + ], ["보관함", [["pocketPut", "선택 행 보관함에 담기"]]], ]; @@ -146,13 +207,21 @@ const k = B.rowKind(r), bd = B.codeOf(r), rec = r._recno; - const cls = [r._rowClass || "", selSet.has(Number(rec)) ? "boq-sel" : "", r._new ? "boq-new" : ""].join(" "); + const cls = [ + r._rowClass || "", + selSet.has(Number(rec)) ? "boq-sel" : "", + r._new ? "boq-new" : "", + ].join(" "); const inp = (f, v, extra) => ``; const leaf = k.kind === "LEAF"; const free = leaf || k.kind === "NOTE" || k.kind === "BLANK"; const hal = [r.RM, r.RL, r.RS].map(Number); - const halTxt = Number(r.RATE) ? `할증 ${Number(r.RATE)}%` : hal.some(Boolean) ? `할증 재${hal[0]}·노${hal[1]}·경${hal[2]}` : ""; + const halTxt = Number(r.RATE) + ? `할증 ${Number(r.RATE)}%` + : hal.some(Boolean) + ? `할증 재${hal[0]}·노${hal[1]}·경${hal[2]}` + : ""; const { fmt } = api; return ` ${r._idx + 1} @@ -182,8 +251,13 @@ ) .join("")}` : `

보관함 (${n}) 비어 있음 — 행을 고르고 [보관함 › 선택 행 보관함에 담기]

`; - return `
내역보관함 (${n})${[1, 2, 3] - .map((k) => ``) + return `
내역보관함 (${n})${[ + 1, 2, 3, + ] + .map( + (k) => + ``, + ) .join("")}
${body}
`; } @@ -193,15 +267,22 @@ const u = ctx.uiState; const all = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx); const lv = Number(u.boqLevelMin || 0); - const kw = String(u.boqSearch || "").trim().toLowerCase(); + const kw = String(u.boqSearch || "") + .trim() + .toLowerCase(); let rows = lv ? B.filterLevel(all, lv) : all; - if (kw) rows = rows.filter((r) => `${r.ITNUM || ""} ${r.BDCODE1 || ""} ${r.NAME1 || ""} ${r.SIZE1 || ""}`.toLowerCase().includes(kw)); + if (kw) + rows = rows.filter((r) => + `${r.ITNUM || ""} ${r.BDCODE1 || ""} ${r.NAME1 || ""} ${r.SIZE1 || ""}` + .toLowerCase() + .includes(kw), + ); const selSet = new Set(sel(ctx)); const ini = ctx.dbfData.start_ini || {}; const cap = (k, d) => esc(String(ini[k] || d).replace(/\s+/g, " ")); const bt = ctx.computed.boqComputed.totals; const newN = ctx.dbfData.boq_rows.filter((r) => r._new).length, - delN = (u.boqDeleted || []).length; + delN = (ctx.dbfData.boq_deleted || []).length; return `
설계내역서 [wBoq · ${esc(ctx.dbfData.boq_table_name || "")}] — 전체 ${all.length}행 · 표시 ${rows.length}행직공비 ${api.fmt(bt.directT)}원
${menuBar()} @@ -218,11 +299,14 @@
${["BMK_STR1", "BMK_STR2", "BMK_STR3", "BMK_STR4"].map((k, i) => ``).join("")} - ${"1234".split("").map(() => ``).join("")} + ${"1234" + .split("") + .map(() => ``) + .join("")}${rows.map((r) => rowHtml(r, ctx, api, selSet)).join("")}
No${cap("CAP_ITNUM", "공종")}${cap("CAP_CODE", "코드번호")}${cap("CAP_NAME", "명칭")}${cap("CAP_SIZE", "규격")}${cap("CAP_QTY", "수량")}${cap("CAP_SUNIT", "단위")}${cap(k, ["재료비", "노무비", "경비", "합계"][i])}${cap("CAP_BIGO", "비고")}
${cap("CAP_AMT", "단가")}${cap("CAP_AMOUNT", "금액")}
${cap("CAP_AMT", "단가")}${cap("CAP_AMOUNT", "금액")}
${pocketPane(ctx)}
-
선택 ${selSet.size}행· 새 행 ${newN} · 지운 원본 행 ${delN}${esc(u.boqMsg || "")}행 추가·삭제·차례는 화면·계산에 바로 반영 · 파일 저장은 저장 묶음 boq_layout(서버 반영 대기)
+
선택 ${selSet.size}행· 새 행 ${newN} · 지운 원본 행 ${delN}${esc(u.boqMsg || "")}행 추가·삭제·차례는 화면·계산에 바로 반영 · [계산 완료] 저장 때 파일에 씀
`; } @@ -234,9 +318,12 @@ u.boqMsg = ""; if (ACTS[act]) return ACTS[act](ctx, api); const D = global.STMATE_UI_BOQ_DLG; - if (D && D[act]) return D[act](ctx, api, { model, after, sel, selIdx, costnByCode, insertAt }); + if (D && D[act]) + return D[act](ctx, api, { model, after, sel, selIdx, costnByCode, insertAt }); }; - document.querySelectorAll("#boq-form [data-boq-act]").forEach((b) => (b.onclick = () => run(b.getAttribute("data-boq-act")))); + document + .querySelectorAll("#boq-form [data-boq-act]") + .forEach((b) => (b.onclick = () => run(b.getAttribute("data-boq-act")))); $("boq-lv").onchange = (e) => ((u.boqLevelMin = Number(e.target.value)), api.renderWorkspace()); $("boq-search").onchange = (e) => ((u.boqSearch = e.target.value), api.renderWorkspace()); $("boq-find").onchange = (e) => (u.boqFind = e.target.value); @@ -261,7 +348,13 @@ }); document.querySelectorAll("#boq-form .boq-cell").forEach((inp) => { inp.onchange = () => { - const res = B.setCell(model(ctx), inp.getAttribute("data-recno"), inp.getAttribute("data-f"), inp.value, costnByCode(ctx)); + const res = B.setCell( + model(ctx), + inp.getAttribute("data-recno"), + inp.getAttribute("data-f"), + inp.value, + costnByCode(ctx), + ); after(ctx, api, res, true); }; inp.onfocus = () => { @@ -269,10 +362,36 @@ if (!sel(ctx).includes(rec)) ((u.boqSel = [rec]), (u.boqAnchor = rec)); }; }); - document.querySelectorAll("#boq-form .boq-pocket-tab").forEach((b) => (b.onclick = () => ((u.activePocket = Number(b.getAttribute("data-pocket"))), api.renderWorkspace()))); + document + .querySelectorAll("#boq-form .boq-pocket-tab") + .forEach( + (b) => + (b.onclick = () => ( + (u.activePocket = Number(b.getAttribute("data-pocket"))), + api.renderWorkspace() + )), + ); const pk = () => (u.boqPockets || {})[u.activePocket || 1] || []; - document.querySelectorAll("#boq-form .boq-pocket-ins").forEach((b) => (b.onclick = () => after(ctx, api, B.pasteRows(model(ctx), insertAt(ctx), pk()[Number(b.getAttribute("data-idx"))])))); - document.querySelectorAll("#boq-form .boq-pocket-del").forEach((b) => (b.onclick = () => (pk().splice(Number(b.getAttribute("data-idx")), 1), api.renderWorkspace()))); + document + .querySelectorAll("#boq-form .boq-pocket-ins") + .forEach( + (b) => + (b.onclick = () => + after( + ctx, + api, + B.pasteRows(model(ctx), insertAt(ctx), pk()[Number(b.getAttribute("data-idx"))]), + )), + ); + document + .querySelectorAll("#boq-form .boq-pocket-del") + .forEach( + (b) => + (b.onclick = () => ( + pk().splice(Number(b.getAttribute("data-idx")), 1), + api.renderWorkspace() + )), + ); const grid = $("boq-grid"); if (u.boqGridScroll) grid.scrollTop = u.boqGridScroll; grid.onscroll = () => (u.boqGridScroll = grid.scrollTop); @@ -285,7 +404,8 @@ const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement.tagName); const key = (e.ctrlKey ? "C+" : "") + (e.altKey ? "A+" : "") + e.key; const map = { Insert: "insBlank", "A+ArrowUp": "up", "A+ArrowDown": "down" }; - if (!typing) Object.assign(map, { Delete: "del", "C+c": "copy", "C+x": "cut", "C+v": "paste" }); + if (!typing) + Object.assign(map, { Delete: "del", "C+c": "copy", "C+x": "cut", "C+v": "paste" }); if (!map[key]) return; e.preventDefault(); run(map[key]); diff --git a/tools/stmate-test/scr_ui_boq_dlg.js b/tools/stmate-test/scr_ui_boq_dlg.js index 6c11482f3..9951a2aba 100644 --- a/tools/stmate-test/scr_ui_boq_dlg.js +++ b/tools/stmate-test/scr_ui_boq_dlg.js @@ -9,12 +9,21 @@ const B = global.STMATE_UI_BOQ; const T = global.STMATE_UI_BOQ_TOOLS; const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); const $ = (id) => document.getElementById(id); const val = (id) => ($(id) ? $(id).value : ""); const opt = (list, cur) => - list.map(([v, t]) => ``).join(""); - const LEVELS = (ini) => [1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [n, `${n} 레벨 (${B.dispName(ini, n)})`]); + list + .map( + ([v, t]) => + ``, + ) + .join(""); + const LEVELS = (ini) => + [1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [n, `${n} 레벨 (${B.dispName(ini, n)})`]); const DOTS = [4, 3, 2, 1, 0].map((d) => [d, `소수 ${d}위값을 적용`]); const KINDS = [ ["BD", "일위대가/산근전체"], @@ -41,7 +50,11 @@ if (st.kind === "BD" ? !/[BD]/.test(p) : st.kind !== "ALL" && p !== st.kind) return false; return !kw || `${c} ${r.NAME || ""} ${r.SIZE || ""}`.toLowerCase().includes(kw); }); - const key = { C: (r) => String(r.CODE), N: (r) => `${r.NAME || ""} ${r.SIZE || ""}`, I: (r) => String(r.BQCODE || "") }[st.sort]; + const key = { + C: (r) => String(r.CODE), + N: (r) => `${r.NAME || ""} ${r.SIZE || ""}`, + I: (r) => String(r.BQCODE || ""), + }[st.sort]; list = list.slice().sort((a, b) => key(a).localeCompare(key(b), "ko")); $("kj-list").innerHTML = list .slice(0, 500) @@ -55,7 +68,14 @@ api.openModal( "공종 목록표 (wKongJong)", `
- +
코드명칭규격단위분류번호
@@ -75,7 +95,13 @@ let rec = B.nextRecno(m); const rows = codes.map((c) => { const cr = cbc[c]; - const r = B.makeRow(m, { BDCODE1: c, NAME1: cr.NAME || "", SIZE1: cr.SIZE || "", UNIT1: cr.UNIT || "", QTY1: String(qty) }); + const r = B.makeRow(m, { + BDCODE1: c, + NAME1: cr.NAME || "", + SIZE1: cr.SIZE || "", + UNIT1: cr.UNIT || "", + QTY1: String(qty), + }); r._recno = rec--; return r; }); @@ -107,9 +133,13 @@ title = val("wr-title"); api.closeModal(); const m = x.model(ctx); - if (ix.length) return x.after(ctx, api, B.wrapGroup(m, ix[0], ix[ix.length - 1], n, title, ini)); + if (ix.length) + return x.after(ctx, api, B.wrapGroup(m, ix[0], ix[ix.length - 1], n, title, ini)); const head = B.makeRow(m, { BDCODE1: `T${n}`, NAME1: title }); - const tail = B.makeRow({ rows: m.rows.concat(head), items: m.items }, { BDCODE1: `E${n}`, ITNUM: B.dispName(ini, n) }); + const tail = B.makeRow( + { rows: m.rows.concat(head), items: m.items }, + { BDCODE1: `E${n}`, ITNUM: B.dispName(ini, n) }, + ); x.after(ctx, api, B.insertRows(m, m.rows.length, [head, tail], [[head, tail]])); }; } @@ -120,24 +150,90 @@ api.openModal( "내역서의 공종번호 자동입력 (wITNum)", `
-
+
-
-
-
-
- -
-
+
+
+
+
+ +
+

예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 …

`, ); $("it-ok").onclick = () => { const rs = val("it-reset"); - const o = { target: val("it-target"), level: Number(val("it-level")), first: val("it-first"), firstText: val("it-first-text") }; - Object.assign(o, { last: val("it-last"), type: val("it-type"), pad: Number(val("it-pad")), margin: Number(val("it-margin")) }); - Object.assign(o, { prefix: val("it-prefix"), resetLevel: rs === "" ? undefined : Number(rs) }); + const o = { + target: val("it-target"), + level: Number(val("it-level")), + first: val("it-first"), + firstText: val("it-first-text"), + }; + Object.assign(o, { + last: val("it-last"), + type: val("it-type"), + pad: Number(val("it-pad")), + margin: Number(val("it-margin")), + }); + Object.assign(o, { + prefix: val("it-prefix"), + resetLevel: rs === "" ? undefined : Number(rs), + }); const ix = x.selIdx(ctx); if (val("it-range") === "sel") { if (!ix.length) return alert("선택된 항목 없음"); @@ -155,17 +251,34 @@ function boqRate(ctx, api, x) { api.openModal( "할증율 적용 (wBoqRate)", - `
+ `
-
+

내역 행 RATE·RM·RL·RS 칸과 비고에 기록 — 금액 반영은 계산 엔진(calc.js calcBoq)의 할증 처리 대기

`, ); $("br-ok").onclick = () => { const recs = val("br-range") === "sel" ? x.sel(ctx) : null; if (recs && !recs.length) return alert("선택된 항목 없음"); - const res = T.applyHalRate(x.model(ctx), recs, { all: val("br-all"), M: val("br-m"), L: val("br-l"), S: val("br-s") }, val("br-mode")); + const res = T.applyHalRate( + x.model(ctx), + recs, + { all: val("br-all"), M: val("br-m"), L: val("br-l"), S: val("br-s") }, + val("br-mode"), + ); api.closeModal(); ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록`; x.after(ctx, api, { ok: true }, true); @@ -176,9 +289,24 @@ function mRate(ctx, api, x) { api.openModal( "단수 일괄보정 (wM_Rate)", - `
-
-
+ `
+
+

비목별 금액 보정(wcBx_Pnt1_M·_L·_X·_W, rd_Amt1·rd_Amt2)은 내역 행 단가 고정 칸이 계산 엔진에 없어 대기

`, ); @@ -199,16 +327,41 @@ api.openModal( "일위대가표 풀기 (wExpnd)", `

${esc(B.codeOf(r))} ${esc(r.NAME1)} ${esc(r.SIZE1)} — 수량 ${B.num(r.QTY1)} ${esc(r.UNIT1)}

-
-
+
+

구성 수량 = 구성표(BDQTY) 수량 × 내역 수량 · 비율(PR)·%단위·구분 줄은 풀지 않음

`, ); $("ex-ok").onclick = () => { - const o = { level: Number(val("ex-lv")), mode: val("ex-mode"), digits: Number(val("ex-dot")) }; - const res = T.expandIlwi(x.model(ctx), r._recno, ctx.dbfData.bdqty_rows, x.costnByCode(ctx), o, ctx.dbfData.start_ini); + const o = { + level: Number(val("ex-lv")), + mode: val("ex-mode"), + digits: Number(val("ex-dot")), + }; + const res = T.expandIlwi( + x.model(ctx), + r._recno, + ctx.dbfData.bdqty_rows, + x.costnByCode(ctx), + o, + ctx.dbfData.start_ini, + ); api.closeModal(); - if (res.ok) ctx.uiState.boqMsg = `풀기 ${res.count}행${res.skipped ? ` · 풀지 않은 줄 ${res.skipped}개(비율·% 단위)` : ""}`; + if (res.ok) + ctx.uiState.boqMsg = `풀기 ${res.count}행${res.skipped ? ` · 풀지 않은 줄 ${res.skipped}개(비율·% 단위)` : ""}`; x.after(ctx, api, res); }; } @@ -237,7 +390,10 @@ }; api.openModal( "주공종 집계표 (wMBoq · 계표시 확인)", - `
줄을 두 번 누르면 내역서의 그 머리로 이동
+ `
줄을 두 번 누르면 내역서의 그 머리로 이동
레벨공종명칭재료비노무비경비합계닫는 행
`, ); draw(1); diff --git a/tools/stmate-test/scr_ui_dan.js b/tools/stmate-test/scr_ui_dan.js index 52107dfad..ca39c4bea 100644 --- a/tools/stmate-test/scr_ui_dan.js +++ b/tools/stmate-test/scr_ui_dan.js @@ -7,16 +7,29 @@ (function (global) { "use strict"; - const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); const U = global.STMATE_UI_DAN; const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); const $ = (id) => document.getElementById(id); const prefs = () => (global.STMATE_UI_APP_MENU && global.STMATE_UI_APP_MENU.prefs) || {}; function st(ctx) { const u = ctx.uiState; - u.dan = u.dan || { kind: u.danFilter && u.danFilter !== "ALL" ? u.danFilter : "ALL", kw: "", mode: "name", sort: "", code: "" }; + u.dan = u.dan || { + kind: u.danFilter && u.danFilter !== "ALL" ? u.danFilter : "ALL", + kw: "", + mode: "name", + sort: "", + code: "", + }; return u.dan; } @@ -34,7 +47,13 @@ const ini = ctx.dbfData.start_ini || {}; const slots = U.slotNames(ini); const sort = s.sort || prefs().dflIndex || "C"; - const list = U.filterRows(rows, { kind: s.kind, kw: s.kw, mode: s.mode, sort, memos: ctx.dbfData.costn_memos || {} }); + const list = U.filterRows(rows, { + kind: s.kind, + kw: s.kw, + mode: s.mode, + sort, + memos: ctx.dbfData.costn_memos || {}, + }); const cnt = U.counts(rows); const n2 = prefs().useName2; const nm = (r, k) => (n2 && String(r[`${k}2`] || "").trim() ? r[`${k}2`] : r[k]) || ""; @@ -63,7 +82,10 @@ .map(({ slot: n }) => { const v = cand[n - 1]; const on = ad === n ? "background:#fde68a;font-weight:700;" : ""; - const click = base && v > 0 ? ` class="num dan-adopt" data-code="${c}" data-slot="${n}" style="cursor:pointer;${on}" title="이 단가 채택"` : ` class="num" style="${on}"`; + const click = + base && v > 0 + ? ` class="num dan-adopt" data-code="${c}" data-slot="${n}" style="cursor:pointer;${on}" title="이 단가 채택"` + : ` class="num" style="${on}"`; return `${v ? fmt(v) : ""}${pg[n - 1] ? `
p.${esc(pg[n - 1])}` : ""}`; }) .join("")} @@ -79,7 +101,14 @@ - + @@ -105,8 +134,15 @@ $("bx-ok").onclick = () => { const how = $("bx-how").value, scope = $("bx-scope").value; - const rows = scope === "one" ? ctx.dbfData.costn_rows.filter((r) => U.codeOf(r) === s.code) : ctx.dbfData.costn_rows; - const ch = U.adoptAll(rows, how, { kinds: scope === "one" ? "MLS" : scope, skipZero: $("bx-skip0").checked, keepUser: scope !== "one" && $("bx-keep").checked }); + const rows = + scope === "one" + ? ctx.dbfData.costn_rows.filter((r) => U.codeOf(r) === s.code) + : ctx.dbfData.costn_rows; + const ch = U.adoptAll(rows, how, { + kinds: scope === "one" ? "MLS" : scope, + skipZero: $("bx-skip0").checked, + keepUser: scope !== "one" && $("bx-keep").checked, + }); ch.forEach((c) => syncRes(ctx, c.code, c.to, `slot${c.slot}`)); ctx.uiState.danMsg = `${ch.length}건 채택 바꿈`; api.closeModal(); @@ -167,7 +203,9 @@ const grid = $("dan-grid"); if (s.scroll) grid.scrollTop = s.scroll; grid.onscroll = () => (s.scroll = grid.scrollTop); - document.querySelectorAll("#dan-form .dan-pick").forEach((td) => (td.onclick = () => ((s.code = td.getAttribute("data-code")), rr()))); + document + .querySelectorAll("#dan-form .dan-pick") + .forEach((td) => (td.onclick = () => ((s.code = td.getAttribute("data-code")), rr()))); document.querySelectorAll("#dan-form .dan-price").forEach( (inp) => (inp.onchange = () => { @@ -193,9 +231,16 @@ api.cascadeAndRender(); }), ); - document.querySelectorAll("#dan-form .dan-ilwi").forEach( - (b) => (b.onclick = () => ((ctx.uiState.selectedIlwiCode = b.getAttribute("data-code")), (ctx.uiState.activeScreen = "wM_Edit_iLWi"), rr())), - ); + document + .querySelectorAll("#dan-form .dan-ilwi") + .forEach( + (b) => + (b.onclick = () => ( + (ctx.uiState.selectedIlwiCode = b.getAttribute("data-code")), + (ctx.uiState.activeScreen = "wM_Edit_iLWi"), + rr() + )), + ); } P.screens.wM_JabPum = { render, bind }; diff --git a/tools/stmate-test/scr_ui_prj.js b/tools/stmate-test/scr_ui_prj.js index 9fe77a4a2..5d94e8b8a 100644 --- a/tools/stmate-test/scr_ui_prj.js +++ b/tools/stmate-test/scr_ui_prj.js @@ -10,13 +10,24 @@ (function (global) { "use strict"; - const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); const $ = (id) => document.getElementById(id); const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0; - const go = (ctx, api, scr, extra) => (Object.assign(ctx.uiState, { activeScreen: scr }, extra || {}), api.renderWorkspace()); - const activeCode = (ctx) => String((ctx.dbfData && ctx.dbfData.boq_table_name) || "").replace(/^BOQ|\.DBF$/g, ""); + const go = (ctx, api, scr, extra) => ( + Object.assign(ctx.uiState, { activeScreen: scr }, extra || {}), + api.renderWorkspace() + ); + const activeCode = (ctx) => + String((ctx.dbfData && ctx.dbfData.boq_table_name) || "").replace(/^BOQ|\.DBF$/g, ""); // ── 프로젝트 관리 ── function renderMgr(ctx) { @@ -26,7 +37,9 @@
프로젝트 관리 [ 열기, 등록, 백업, 삭제 ]${src.length}개
- + + + 열린 프로젝트: ${esc(ctx.currentSourcePath || "(없음)")}
@@ -40,7 +53,10 @@ function bindMgr(ctx, api) { document.querySelectorAll("#mgr-form .mgr-row").forEach((tr) => { - tr.onclick = () => ((ctx.uiState.mgrSel = tr.getAttribute("data-path")), api.renderWorkspace()); + tr.onclick = () => ( + (ctx.uiState.mgrSel = tr.getAttribute("data-path")), + api.renderWorkspace() + ); tr.ondblclick = () => api.loadProjectFromServer(tr.getAttribute("data-path"), "wPrjList"); }); $("mgr-open").onclick = () => api.loadProjectFromServer(ctx.uiState.mgrSel, "wPrjList"); @@ -52,19 +68,90 @@ // ── 공사목록표 ── const DISABLED = "아직 만들지 않은 기능"; + // 파일 담당(scr_file.js) 동작 — data-file-act 단추는 문서 전체 클릭으로 그쪽이 받음 · 공사 코드 = prj-pcode 라디오 + const FILE_ACT = { ActAddCopy: "copy", ActHae_Arc: "pack" }; const PRJ_MENU = [ - ["1. 수 정", [["ActEditBoq", "내역서 수정(&S)"], ["ActSagp", "관(사)급 내역 생성/수정"], ["Act_ED_PNUM", "등록정보 수정 (공사번호/공사명/공사종류)"], ["Act_DN_Chasu", "설계변경내역서 변경차수 내리기"], null, ["Act_Ed_Jab", "제잡비 계산"], ["Act_CalBoq", "선택내역서 재계산"], ["Act_Prn", "선택된 항목의 기본내역서 인쇄"], null, ["ActHaeCost", "해당분 단가 수정작업"], ["Act_Calc_Hae", "해당분 복합단가 전체 재계산"], ["Act_Find_Jod", "조달청제출용 일위대가 내용검증(손료부분,산근포함)"], ["Act_Find_XML", "조달청제출용 대가/산근 내용검증(XML, 손료부분 or 대가수량소수점)"], null, ["Act_Del_Prj", "내역서 삭제"], ["Act_Mer_Prj", "내역서 병합"], null, ["Act_Sagp_Del", "관/사급 내역 삭제"]]], - ["2. 내역서 등록", [["ActNewBoq", "신규등록(&A)"], ["ActAddCopy", "복사등록(&C)"], null, ["ActAddTo5", "할증내역으로 등록"], ["ActMkCont00", "계약내역 등록"], ["Act_MkCont41", "실행예산 복사등록"], ["ActMkCont10", "기성내역 등록"], null, ["ActMkCont21", "설계변경 등록 (당초/변경)"], ["ActMkCont20", "차수별 설계변경 등록"], ["ActMkCont31", "굴착변경 등록"], null, ["Act_Mk_Boq10", "공량내역(기계,전기..) 등록"]]], - ["3. 해당분압축/조달청포맷 생성", [["ActHae_Arc", "선택된 해당분만 압축하기[내역, 대가, 기초단가 ... ]"], null, ["Act_St8_XML", "공사원가호환규정 포맷(XML, 조달청) 변환(해당분)"], ["Act_St8_Jodal1", "JDL 포맷(TEXT) 변환(해당분)"]]], - ["4. 보기설정", [["Act_Find", "문자열 찾기"], ["Act_View_BOQ", "내역서 미리보기/숨기기"], null, ["ActIndex", "공사목록표 내림차순 정렬"], null, ["Act_Indx_PCODE", "공사목록표 코드순 정렬"], ["Act_Indx_PNAME", "공사목록표 공사명순 정렬"], ["Act_Indx_PNUM", "공사목록표 공사번호순 정렬"], null, ["Act_List_UP", "내역목록 위로이동"], ["Act_List_Down", "내역목록 아래로 이동"]]], + [ + "1. 수 정", + [ + ["ActEditBoq", "내역서 수정(&S)"], + ["ActSagp", "관(사)급 내역 생성/수정"], + ["Act_ED_PNUM", "등록정보 수정 (공사번호/공사명/공사종류)"], + ["Act_DN_Chasu", "설계변경내역서 변경차수 내리기"], + null, + ["Act_Ed_Jab", "제잡비 계산"], + ["Act_CalBoq", "선택내역서 재계산"], + ["Act_Prn", "선택된 항목의 기본내역서 인쇄"], + null, + ["ActHaeCost", "해당분 단가 수정작업"], + ["Act_Calc_Hae", "해당분 복합단가 전체 재계산"], + ["Act_Find_Jod", "조달청제출용 일위대가 내용검증(손료부분,산근포함)"], + ["Act_Find_XML", "조달청제출용 대가/산근 내용검증(XML, 손료부분 or 대가수량소수점)"], + null, + ["Act_Del_Prj", "내역서 삭제"], + ["Act_Mer_Prj", "내역서 병합"], + null, + ["Act_Sagp_Del", "관/사급 내역 삭제"], + ], + ], + [ + "2. 내역서 등록", + [ + ["ActNewBoq", "신규등록(&A)"], + ["ActAddCopy", "복사등록(&C)"], + null, + ["ActAddTo5", "할증내역으로 등록"], + ["ActMkCont00", "계약내역 등록"], + ["Act_MkCont41", "실행예산 복사등록"], + ["ActMkCont10", "기성내역 등록"], + null, + ["ActMkCont21", "설계변경 등록 (당초/변경)"], + ["ActMkCont20", "차수별 설계변경 등록"], + ["ActMkCont31", "굴착변경 등록"], + null, + ["Act_Mk_Boq10", "공량내역(기계,전기..) 등록"], + ], + ], + [ + "3. 해당분압축/조달청포맷 생성", + [ + ["ActHae_Arc", "선택된 해당분만 압축하기[내역, 대가, 기초단가 ... ]"], + null, + ["Act_St8_XML", "공사원가호환규정 포맷(XML, 조달청) 변환(해당분)"], + ["Act_St8_Jodal1", "JDL 포맷(TEXT) 변환(해당분)"], + ], + ], + [ + "4. 보기설정", + [ + ["Act_Find", "문자열 찾기"], + ["Act_View_BOQ", "내역서 미리보기/숨기기"], + null, + ["ActIndex", "공사목록표 내림차순 정렬"], + null, + ["Act_Indx_PCODE", "공사목록표 코드순 정렬"], + ["Act_Indx_PNAME", "공사목록표 공사명순 정렬"], + ["Act_Indx_PNUM", "공사목록표 공사번호순 정렬"], + null, + ["Act_List_UP", "내역목록 위로이동"], + ["Act_List_Down", "내역목록 아래로 이동"], + ], + ], ]; const prjRows = (ctx) => (ctx.dbfData && ctx.dbfData.prjlst_all) || []; - const selRow = (ctx) => prjRows(ctx).find((r) => String(r.PCODE).trim() === ctx.uiState.prjSel) || prjRows(ctx)[0]; - const stage = (sub) => (ctx, api) => go(ctx, api, "wStage", { stageSubTab: sub }); + const selRow = (ctx) => + prjRows(ctx).find((r) => String(r.PCODE).trim() === ctx.uiState.prjSel) || prjRows(ctx)[0]; + // 계약·실행·기성·설계변경 — 단계 담당(scr_stage.js) 탭 동작으로 + const stage = (act) => (ctx, api) => + P.actions[act] ? P.actions[act](ctx, api) : go(ctx, api, "wStage"); const sortBy = (key, desc) => (ctx, api) => { const rows = prjRows(ctx); - rows.sort((a, b) => String(a[key] || "").localeCompare(String(b[key] || ""), "ko", { numeric: true }) * (desc ? -1 : 1)); + rows.sort( + (a, b) => + String(a[key] || "").localeCompare(String(b[key] || ""), "ko", { numeric: true }) * + (desc ? -1 : 1), + ); api.renderWorkspace(); }; const moveSel = (dir) => (ctx, api) => { @@ -79,7 +166,9 @@ function needActive(ctx) { const r = selRow(ctx); if (r && String(r.PCODE).trim() !== activeCode(ctx)) { - alert(`BOQ${String(r.PCODE).trim()}.DBF 내역서 열기는 서버 불러오기 대기 — 지금 열린 내역서는 BOQ${activeCode(ctx)}`); + alert( + `BOQ${String(r.PCODE).trim()}.DBF 내역서 열기는 서버 불러오기 대기 — 지금 열린 내역서는 BOQ${activeCode(ctx)}`, + ); return false; } return true; @@ -96,10 +185,19 @@
`, ); $("pm-ok").onclick = () => { - Object.assign(r, { PNUM: $("pm-pnum").value, PNAME: $("pm-pname").value, PCLASS: String(num($("pm-pclass").value)) }); + Object.assign(r, { + PNUM: $("pm-pnum").value, + PNAME: $("pm-pname").value, + PCLASS: String(num($("pm-pclass").value)), + }); if (String(r.PCODE).trim() === activeCode(ctx)) { - Object.assign(ctx.state.projectMeta, { pname: r.PNAME, pnum: r.PNUM, pclass: num(r.PCLASS) }); - if (ctx.dbfData.prjlst && ctx.dbfData.prjlst !== r) Object.assign(ctx.dbfData.prjlst, { PNUM: r.PNUM, PNAME: r.PNAME, PCLASS: r.PCLASS }); + Object.assign(ctx.state.projectMeta, { + pname: r.PNAME, + pnum: r.PNUM, + pclass: num(r.PCLASS), + }); + if (ctx.dbfData.prjlst && ctx.dbfData.prjlst !== r) + Object.assign(ctx.dbfData.prjlst, { PNUM: r.PNUM, PNAME: r.PNAME, PCLASS: r.PCLASS }); } api.closeModal(); api.renderWorkspace(); @@ -114,12 +212,11 @@ Act_CalBoq: (ctx, api) => needActive(ctx) && api.cascadeAndRender(), Act_Calc_Hae: (ctx, api) => api.cascadeAndRender(), Act_Prn: (ctx, api) => go(ctx, api, "PM_Prt"), - ActMkCont00: stage("CONTRACT"), - Act_MkCont41: stage("EXEC"), - ActMkCont10: stage("GISUNG"), - ActMkCont21: stage("REVISION"), - ActMkCont20: stage("REVISION"), - ActHae_Arc: (ctx, api) => api.saveAll(), + ActMkCont00: stage("Act_Stg_Contract"), + Act_MkCont41: stage("Act_Stg_Exec"), + ActMkCont10: stage("Act_Stg_Prog"), + ActMkCont21: stage("Act_Stg_Change"), + ActMkCont20: stage("Act_Stg_Change"), Act_St8_XML: (ctx, api) => go(ctx, api, "PM_Prt"), Act_St8_Jodal1: (ctx, api) => go(ctx, api, "PM_Prt"), Act_Find: (ctx, api) => { @@ -128,8 +225,14 @@ ctx.uiState.prjFind = kw.trim(); api.renderWorkspace(); }, - Act_View_BOQ: (ctx, api) => ((ctx.uiState.showBoqPreview = ctx.uiState.showBoqPreview === false), api.renderWorkspace()), - ActIndex: (ctx, api) => ((ctx.uiState.prjDesc = !ctx.uiState.prjDesc), sortBy("PCODE", ctx.uiState.prjDesc)(ctx, api)), + Act_View_BOQ: (ctx, api) => ( + (ctx.uiState.showBoqPreview = ctx.uiState.showBoqPreview === false), + api.renderWorkspace() + ), + ActIndex: (ctx, api) => ( + (ctx.uiState.prjDesc = !ctx.uiState.prjDesc), + sortBy("PCODE", ctx.uiState.prjDesc)(ctx, api) + ), Act_Indx_PCODE: sortBy("PCODE"), Act_Indx_PNAME: sortBy("PNAME"), Act_Indx_PNUM: sortBy("PNUM"), @@ -137,17 +240,26 @@ Act_List_Down: moveSel(1), }; const enabled = (act) => Boolean(P.actions[act] || H[act]); - const run = (act, ctx, api, btn) => (P.actions[act] ? P.actions[act](ctx, api, btn) : H[act] && H[act](ctx, api)); + const actBtn = (a, t, cls) => + FILE_ACT[a] && !P.actions[a] + ? `` + : ``; + const run = (act, ctx, api, btn) => + P.actions[act] ? P.actions[act](ctx, api, btn) : H[act] && H[act](ctx, api); function previewRows(ctx, code) { if (code === activeCode(ctx)) return global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx); - return ((ctx.dbfData.boq_tables || {})[`BOQ${code}.DBF`] || []).map((r) => Object.assign({}, r)); + return ((ctx.dbfData.boq_tables || {})[`BOQ${code}.DBF`] || []).map((r) => + Object.assign({}, r), + ); } function renderList(ctx, api) { if (!ctx.dbfData) return renderMgr(ctx); const kw = String(ctx.uiState.prjFind || "").toLowerCase(); - const rows = prjRows(ctx).filter((r) => !kw || `${r.PNUM} ${r.PNAME}`.toLowerCase().includes(kw)); + const rows = prjRows(ctx).filter( + (r) => !kw || `${r.PNUM} ${r.PNAME}`.toLowerCase().includes(kw), + ); const cur = selRow(ctx), code = cur ? String(cur.PCODE).trim() : ""; const act = activeCode(ctx); @@ -159,10 +271,10 @@ const menu = ``; - const btn = (a, t) => ``; + const btn = (a, t) => actBtn(a, t, "win-btn"); return `
공사목록표 [wPrjList · PRJLST.DBF] — ${prjRows(ctx).length}건${esc(ctx.currentSourcePath)}
${menu} @@ -173,7 +285,7 @@ .map((r) => { const c = String(r.PCODE).trim(); const live = c === act ? `${fmt(bt.directT)} / ${fmt(kj.GRAND_TOTAL)}` : ""; - return `
`; + return ``; }) .join("")}
No구분프로젝트경로
${esc(r.PNUM)}${esc(r.PNAME)}${esc(c)}${c === act ? " ●" : ""}${esc(r.PCLASS)} · 차수 ${esc(r.K_ORD)}${fmt(num(r.TCOST1))}${fmt(num(r.COSTW1))}${num(r.CCODE) ? esc(r.CCODE) : ""}${live}
${esc(r.PNUM)}${esc(r.PNAME)}${esc(c)}${c === act ? " ●" : ""}${esc(r.PCLASS)} · 차수 ${esc(r.K_ORD)}${fmt(num(r.TCOST1))}${fmt(num(r.COSTW1))}${num(r.CCODE) ? esc(r.CCODE) : ""}${live}
${ @@ -195,10 +307,21 @@ function bindList(ctx, api) { if (!ctx.dbfData) return bindMgr(ctx, api); if (!ctx.uiState.prjSel) ctx.uiState.prjSel = activeCode(ctx); - document.querySelectorAll("#prj-form [data-prj-act]").forEach((b) => (b.onclick = () => !b.disabled && run(b.getAttribute("data-prj-act"), ctx, api, b))); + ctx.uiState.filePcode = ctx.uiState.prjSel; + document + .querySelectorAll("#prj-form [data-prj-act]") + .forEach( + (b) => (b.onclick = () => !b.disabled && run(b.getAttribute("data-prj-act"), ctx, api, b)), + ); document.querySelectorAll("#prj-form .prj-pick").forEach((tr) => { - tr.onclick = () => ((ctx.uiState.prjSel = tr.getAttribute("data-code")), api.renderWorkspace()); - tr.ondblclick = () => ((ctx.uiState.prjSel = tr.getAttribute("data-code")), run("ActEditBoq", ctx, api)); + tr.onclick = () => ( + (ctx.uiState.prjSel = tr.getAttribute("data-code")), + api.renderWorkspace() + ); + tr.ondblclick = () => ( + (ctx.uiState.prjSel = tr.getAttribute("data-code")), + run("ActEditBoq", ctx, api) + ); }); } diff --git a/tools/stmate-test/scr_ui_sys.js b/tools/stmate-test/scr_ui_sys.js index c1d63ccef..5957de317 100644 --- a/tools/stmate-test/scr_ui_sys.js +++ b/tools/stmate-test/scr_ui_sys.js @@ -7,11 +7,18 @@ (function (global) { "use strict"; - const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [] }); + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); const S = global.STMATE_UI_SYS; const U = global.STMATE_UI_DAN; const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); function draft(ctx) { const u = ctx.uiState; @@ -30,14 +37,20 @@ const title = f.hint ? ` title="${esc(f.hint)}"` : ""; if (f.type === "sel") return ``; + .map( + ([o, t]) => + ``, + ) + .join( + "", + )}${f.opts.some(([o]) => o === String(v)) ? "" : ``}`; if (f.type === "codes") return ``; return ``; } - const row = (f, d, n) => `
${esc(f.cap)}${field(f, d, n)}
`; + const row = (f, d, n) => + `
${esc(f.cap)}${field(f, d, n)}
`; function render(ctx) { if (!ctx.dbfData) @@ -68,9 +81,16 @@
소 수 점 적 용 기 준${S.FIELDS.digits.map((f) => row(f, d)).join("")}
계 산 방 식${S.FIELDS.calc.map((f) => row(f, d)).join("")} -
${d.OPTNM.split("") - .map((c, i) => `${i + 1}. ${S.ORDER_NAMES[c]} `) - .join("")}
+
${d.OPTNM.split( + "", + ) + .map( + (c, i) => + `${i + 1}. ${S.ORDER_NAMES[c]} `, + ) + .join( + "", + )}
재료비 적용단가 일괄적용
@@ -94,7 +114,8 @@ ctx.uiState.activeScreen = "wPrjList"; if (recalc) api.cascadeAndRender(); else api.renderWorkspace(); - if (changed.length) ctx.uiState.boqMsg = `초기값 ${changed.length}칸 반영(${changed.join(", ")})`; + if (changed.length) + ctx.uiState.boqMsg = `초기값 ${changed.length}칸 반영(${changed.join(", ")})`; } function bind(ctx, api) { @@ -106,14 +127,26 @@ (el) => (el.onchange = () => { const k = el.getAttribute("data-k"); - d[k] = /^CON/.test(k) ? S.joinCodes(String(el.value).split(/[,\s]+/)) : String(el.value).trim(); + d[k] = /^CON/.test(k) + ? S.joinCodes(String(el.value).split(/[,\s]+/)) + : String(el.value).trim(); api.renderWorkspace(); }), ); $("sys-rx-sel").onchange = (e) => ((u.sysRx = e.target.value), api.renderWorkspace()); - document.querySelectorAll("#sys-form .sys-opt").forEach( - (b) => (b.onclick = () => ((d.OPTNM = S.moveOrder(d.OPTNM, Number(b.getAttribute("data-i")), Number(b.getAttribute("data-dir")))), api.renderWorkspace())), - ); + document + .querySelectorAll("#sys-form .sys-opt") + .forEach( + (b) => + (b.onclick = () => ( + (d.OPTNM = S.moveOrder( + d.OPTNM, + Number(b.getAttribute("data-i")), + Number(b.getAttribute("data-dir")), + )), + api.renderWorkspace() + )), + ); $("sys-opt-df").onclick = () => ((d.OPTNM = "SML"), api.renderWorkspace()); $("sys-xml").onclick = () => (S.xmlPreset(d), api.renderWorkspace()); $("sys-cancel").onclick = () => ((u.sysDraft = null), api.renderWorkspace()); @@ -122,7 +155,11 @@ $("sys-selm-ok").onclick = () => { const how = $("sys-selm").value; if (how === "none") return; - const ch = U.adoptAll(ctx.dbfData.costn_rows, how, { kinds: "M", skipZero: $("sys-skip0").checked, keepUser: $("sys-keep").checked }); + const ch = U.adoptAll(ctx.dbfData.costn_rows, how, { + kinds: "M", + skipZero: $("sys-skip0").checked, + keepUser: $("sys-keep").checked, + }); ch.forEach((c) => { const res = (ctx.state.resources || []).find((r) => r.code === c.code); if (res) Object.assign(res, { adoptMode: `slot${c.slot}`, manualPrice: c.to }); diff --git a/tools/stmate-test/server.py b/tools/stmate-test/server.py index b20c67822..254272606 100644 --- a/tools/stmate-test/server.py +++ b/tools/stmate-test/server.py @@ -389,6 +389,8 @@ class SaveDbfReq(BaseModel): smt_updates: dict = {} costn_memo_updates: dict[str, str] = {} boq_table: str = "" + start_ini: dict = {} + prjlst_rows: list = [] @app.get("/api/sources") @@ -434,6 +436,13 @@ def api_load(req: LoadReq): sim_state = convert_dbf_to_sim_state( costn_rows, boq_rows, smt_kv, prj_rows[0] if prj_rows else {}, costn_memos ) + # 노임 환산 직종·환산식 = Start8x.Ini CONLD · RXXNM + ini = parse_ini_bytes(files.get("START8X.INI") or files.get("STINI.MEM") or b"") + conld = ini.get("CONLD", "") + sim_state["sysConfig"]["condCodes"] = ", ".join( + c for c in (conld[i : i + 6].strip() for i in range(0, len(conld), 6)) if c + ) + sim_state["sysConfig"]["rxFormula"] = ini.get("RXXNM") or sim_state["sysConfig"]["rxFormula"] return { "source_path": req.source_path, "boq_table_name": boq_name, @@ -450,7 +459,7 @@ def api_load(req: LoadReq): "rate_tables": rate_tables, "set_rep_rows": set_rep_rows, "bdqty_rows": decode_bdqty(files.get("BDQTY.DBF", b"")), - "start_ini": parse_ini_bytes(files.get("START8X.INI") or files.get("STINI.MEM") or b""), + "start_ini": ini, "sim_state": sim_state, **extra_tables(files), } diff --git a/tools/stmate-test/smt_engine.js b/tools/stmate-test/smt_engine.js index ce4713ce0..0029eba55 100644 --- a/tools/stmate-test/smt_engine.js +++ b/tools/stmate-test/smt_engine.js @@ -36,7 +36,14 @@ function qtyResolver(ctx) { const rows = (ctx.dbfData && ctx.dbfData.qcode_rows) || []; const by = {}; - rows.forEach((r) => (by[String(r.CODE || "").trim().toUpperCase()] = r)); + rows.forEach( + (r) => + (by[ + String(r.CODE || "") + .trim() + .toUpperCase() + ] = r), + ); return (code, field) => { const r = by[code]; if (!r) return { error: `${code} 는 등록된 수량코드가 아님` }; @@ -73,7 +80,11 @@ const ev = SmtEval.evaluate(smtText, lookup, smtOptions(ctx, codeHint)); const linesOut = ev.lines.map((ln) => { const out = { lineNo: ln.lineNo, raw: ln.raw, assigned: (ln.assigned || []).join(", ") }; - Object.assign(out, { kind: ln.kind, errors: ln.errors || [], excluded: Boolean(ln.excluded) }); + Object.assign(out, { + kind: ln.kind, + errors: ln.errors || [], + excluded: Boolean(ln.excluded), + }); if (ln.kind !== "amount") return Object.assign(out, { refInfo: null }); const tag = "MLS".split("").find((k) => ln.amounts[k]) || "M"; const refName = (lookup(ln.ref.replace(/[LMS]$/, "")) || {}).name || ln.ref; @@ -120,7 +131,8 @@ labor: code[0] === "X" ? laborMap(ini, row.RXX, sys.rxFormula) : null, rx: ini.RXXNM || sys.rxFormula, usdRate: num(ini.EXLNM1), - shiftFactor: code[0] === "X" && shift !== "E0" ? num((sys.shiftFactors || {})[shift]) || 1 : 1, + shiftFactor: + code[0] === "X" && shift !== "E0" ? num((sys.shiftFactors || {})[shift]) || 1 : 1, }; } diff --git a/tools/stmate-test/smt_eval.js b/tools/stmate-test/smt_eval.js index e0127cc0d..65f6465aa 100644 --- a/tools/stmate-test/smt_eval.js +++ b/tools/stmate-test/smt_eval.js @@ -106,8 +106,7 @@ const StmateSmtEval = (() => { return m ? m[1] + m[2].padStart(5, "0") : c; }; // 따옴표 밖의 마지막 '='(괄호·대괄호 안 제외) - const lastEqOutside = (s) => - EXPR().lastTopEq(s.replace(/'[^']*'/g, (q) => "_".repeat(q.length))); + const lastEqOutside = (s) => EXPR().lastTopEq(s.replace(/'[^']*'/g, (q) => "_".repeat(q.length))); // 식 전체가 자리 함수 하나 — round(A/B,4)= 는 그 자리 그대로(Memo_Use (3) 예 D) function wholeRoundCall(expr) { @@ -187,7 +186,8 @@ const StmateSmtEval = (() => { }; const ln = prepLine(srcLines[lineNo - 1], opts, errs); if (!ln) { - if (errs.length) lines.push({ lineNo, raw: srcLines[lineNo - 1].trim(), kind: "text", errors: note() }); + if (errs.length) + lines.push({ lineNo, raw: srcLines[lineNo - 1].trim(), kind: "text", errors: note() }); continue; } // 계표시 (=)소계 (==)계 (===)합계 (====)총계 (=====)총합계 · (=):T01 누계번지 @@ -197,7 +197,14 @@ const StmateSmtEval = (() => { const closing = { ...sec }; flush(); setT(mk[3], closing); - lines.push({ lineNo, raw: ln, kind: "section", level: mk[2].length, label: mk[3] || "", errors: note() }); + lines.push({ + lineNo, + raw: ln, + kind: "section", + level: mk[2].length, + label: mk[3] || "", + errors: note(), + }); continue; } // (-=) 지금 구간 · (-==) 이전 전부를 합계에서 뺌 — % 밑수(seen)에는 남음 @@ -211,14 +218,21 @@ const StmateSmtEval = (() => { const body = ln.startsWith(";;") ? ln.slice(2) : ln; // 그림코드 IMG(I00001) — 계산 없음 if (/IMG\s*\(/i.test(stripQuotes(body))) { - if ((stripQuotes(body).match(/IMG\s*\(/gi) || []).length > 1) errs.push("그림코드는 한 줄에 1개만"); + if ((stripQuotes(body).match(/IMG\s*\(/gi) || []).length > 1) + errs.push("그림코드는 한 줄에 1개만"); lines.push({ lineNo, raw: ln, kind: "image", errors: note() }); continue; } const refs = [...body.matchAll(REF_RE)]; if (!refs.length) { const assigned = assignLine(body, vars, errs, assignDigits); - lines.push({ lineNo, raw: ln, kind: assigned.length ? "assign" : "text", assigned, errors: note() }); + lines.push({ + lineNo, + raw: ln, + kind: assigned.length ? "assign" : "text", + assigned, + errors: note(), + }); continue; } if (refs.length > 1) errs.push("단가코드+단가코드는 할 수 없음 — 줄을 나눠 씀"); @@ -241,7 +255,8 @@ const StmateSmtEval = (() => { let pa; if ((pa = tok.match(/^([PR])([SCT])_([LMS]?)$/))) { // S 처음부터 누계 · C 마지막 계표시 이후 · T 누계번지 이후(Memo_Use (9)⑦⑧) - const src = pa[2] === "S" ? { M: acc("M"), L: acc("L"), S: acc("S") } : pa[2] === "C" ? sec : sinceT; + const src = + pa[2] === "S" ? { M: acc("M"), L: acc("L"), S: acc("S") } : pa[2] === "C" ? sec : sinceT; if (pa[3]) parts.push([src[pa[3]], forced || pa[3]]); else if (forced) parts.push([src.M + src.L + src.S, forced]); else for (const k of "MLS") parts.push([src[k], k]); @@ -258,7 +273,8 @@ const StmateSmtEval = (() => { const cm = tok.match(/^([A-Z]\d{1,5})([LMS])?$/); const code = cm ? padCode(cm[1]) : tok; const comp = cm ? cm[2] || "" : ""; - if (opts.selfCode && code === opts.selfCode) errs.push(`${code} 자기 자신을 참조함(순환참조)`); + if (opts.selfCode && code === opts.selfCode) + errs.push(`${code} 자기 자신을 참조함(순환참조)`); const found = lookup(code); if (!found) errs.push(`${code} 는 등록된 단가코드가 아님`); const r = found || { M: 0, L: 0, S: 0, unit: "" }; diff --git a/tools/stmate-test/smt_parser.py b/tools/stmate-test/smt_parser.py index 46262cc07..638c0da21 100644 --- a/tools/stmate-test/smt_parser.py +++ b/tools/stmate-test/smt_parser.py @@ -62,7 +62,9 @@ def parse_dbf_bytes(b: bytes) -> tuple[list[dict], list[dict], dict]: cur += f["len"] if f["name"] == "IFORM" or f["type"] == "M": row[f"{f['name']}_HEX"] = val_bytes.hex() - row[f["name"]] = val_bytes.decode("cp949", "replace").strip() + text = val_bytes.decode("cp949", "replace") + # 글 칸은 앞 빈칸 보존(원본 출력 들여쓰기) · 숫자·논리 칸은 양끝 빈칸 걷음 + row[f["name"]] = text.rstrip() if f["type"] == "C" else text.strip() if not deleted: rows.append(row) diff --git a/tools/stmate-test/stc_save.py b/tools/stmate-test/stc_save.py index 890facee7..b89dd2ce9 100644 --- a/tools/stmate-test/stc_save.py +++ b/tools/stmate-test/stc_save.py @@ -19,6 +19,7 @@ from decimal import ROUND_DOWN, Decimal, InvalidOperation from dbf_memo import SixMemo, ini_update, memo_text_bytes, same_memo from dbf_rw import DbfTable, find_rows +from smt_parser import parse_ini_bytes STRUCT_RE = re.compile(r"^(T\d|E\d|C\d|:)$") SLOTS = ("1", "2", "3", "4", "5") @@ -331,4 +332,17 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str out["PRJLST.SMT"] = memo.to_bytes() if pt.dirty: out["PRJLST.DBF"] = pt.to_bytes(today) + + # 초기값(Start8x.Ini · STini.MEM) — 있는 키 중 바뀐 값만 그 줄 자리에서 고침 + ini_req = req.get("start_ini") or {} + for name in ("START8X.INI", "STINI.MEM") if ini_req else (): + if name not in files: + continue + orig = parse_ini_bytes(files[name]) + upd = {k: str(v) for k, v in ini_req.items() if k in orig and str(v) != orig[k]} + if upd: + new_text, keys = ini_update(files[name].decode("cp949", "replace"), upd) + out[name] = new_text.encode("cp949", "replace") + for k in keys: + log.add(name, 0, k, orig.get(k, ""), upd[k]) return out, log.items diff --git a/tools/stmate-test/ui_app_menu.js b/tools/stmate-test/ui_app_menu.js index d9fcdef91..973a0f7f6 100644 --- a/tools/stmate-test/ui_app_menu.js +++ b/tools/stmate-test/ui_app_menu.js @@ -92,7 +92,13 @@ } const toggle = (k) => () => ((prefs[k] = !prefs[k]), savePrefs(), render()); - const radio = (k, v) => () => ((prefs[k] = v), savePrefs(), applyView(), render(), API.renderWorkspace()); + const radio = (k, v) => () => ( + (prefs[k] = v), + savePrefs(), + applyView(), + render(), + API.renderWorkspace() + ); const H = { Act_Maneger: () => go("wPrjMgr"), @@ -103,10 +109,15 @@ Act_Prt: () => go("PM_Prt"), Act_Calc_Grass: async () => (await API.saveAll(), afterCalc()), Act_Calc_All: () => (API.cascadeAndRender(), afterCalc()), - Act_Refresh: () => API.ctx.currentSourcePath && API.loadProjectFromServer(API.ctx.currentSourcePath), + Act_Refresh: () => + API.ctx.currentSourcePath && API.loadProjectFromServer(API.ctx.currentSourcePath), Act_Arc_Project: () => API.saveAll(), Act_Close_Table: () => { - Object.assign(API.ctx, { state: API.Calc.createDefaultState(), dbfData: null, currentSourcePath: "" }); + Object.assign(API.ctx, { + state: API.Calc.createDefaultState(), + dbfData: null, + currentSourcePath: "", + }); go("wPrjMgr"); }, mi_AutoBack: toggle("autoBackDaily"), @@ -122,7 +133,12 @@ Act_UseName2: toggle("useName2"), N44: () => ((prefs.scale = 100), (prefs.screenMode = 0), savePrefs(), applyView(), render()), N55: () => { - Object.assign(API.ctx.uiState, { boqLevelFilter: "ALL", boqSearch: "", boqLevelMin: 0, boqSel: [] }); + Object.assign(API.ctx.uiState, { + boqLevelFilter: "ALL", + boqSearch: "", + boqLevelMin: 0, + boqSel: [], + }); API.renderWorkspace(); }, Act_information: () => { diff --git a/tools/stmate-test/ui_boq.js b/tools/stmate-test/ui_boq.js index b0157b512..59a614c48 100644 --- a/tools/stmate-test/ui_boq.js +++ b/tools/stmate-test/ui_boq.js @@ -9,9 +9,23 @@ "use strict"; const STRUCT = /^(T\d|E\d|C\d|:)$/; - const DISP_DEFAULT = ["소계", "계", "합계", "총계", "총합계", "총대계", "광역계", "광역총계", "광역대계"]; + const DISP_DEFAULT = [ + "소계", + "계", + "합계", + "총계", + "총합계", + "총대계", + "광역계", + "광역총계", + "광역대계", + ]; const num = (v) => { - const n = parseFloat(String(v == null ? "" : v).replace(/,/g, "").trim()); + const n = parseFloat( + String(v == null ? "" : v) + .replace(/,/g, "") + .trim(), + ); return Number.isFinite(n) ? n : 0; }; const codeOf = (r) => String((r && r.BDCODE1) || "").trim(); @@ -43,13 +57,36 @@ return links; } - // 차례 다시 매김 — SORTCODE = (i+1)×256 · 짝 행 ENDCODE 갱신 + // 차례 다시 매김 — 오름차순이 유지되는 원래 SORTCODE 는 그대로 · 새 행·옮긴 행만 앞뒤 사이 값 + // 사이 간격이 0.0001 보다 좁으면 전체를 (i+1)×256 으로 · 짝 행 ENDCODE 갱신 function renumber(rows, links) { const present = new Set(rows); - rows.forEach((r, i) => (r.SORTCODE = fmtSort((i + 1) * 256))); + const keep = rows.map(() => false); + let last = 0; + rows.forEach((r, i) => { + const v = num(r.SORTCODE); + if (!r._new && v > last) ((keep[i] = true), (last = v)); + }); + let ok = true; + for (let i = 0; i < rows.length && ok;) { + if (keep[i]) { + i += 1; + continue; + } + let j = i; + while (j < rows.length && !keep[j]) j += 1; + const lo = i > 0 ? num(rows[i - 1].SORTCODE) : 0; + const hi = j < rows.length ? num(rows[j].SORTCODE) : lo + 256 * (j - i + 1); + const step = (hi - lo) / (j - i + 1); + if (step < 0.0001) ok = false; + for (let k = i; k < j && ok; k++) rows[k].SORTCODE = (lo + step * (k - i + 1)).toFixed(4); + i = j; + } + if (!ok) rows.forEach((r, i) => (r.SORTCODE = fmtSort((i + 1) * 256))); rows.forEach((r) => { const p = links.get(r); - r.ENDCODE = p && present.has(p) && isStruct(r) ? p.SORTCODE : "0.0000"; + const end = p && present.has(p) && isStruct(r) ? p.SORTCODE : "0.0000"; + if (num(r.ENDCODE) !== num(end) || (p && r.ENDCODE !== end)) r.ENDCODE = end; }); } @@ -260,7 +297,8 @@ const tail = makeRow(model, { BDCODE1: `E${n}`, ITNUM: dispName(ini, n) }); return edit(model, (rows, links) => { const i = rows.findIndex((x) => num(x._recno) === num(recno)); - if (i < 0 || rowKind(rows[i]).kind !== "NOTE") return { error: "주석(코드 없는 글) 행을 고르세요" }; + if (i < 0 || rowKind(rows[i]).kind !== "NOTE") + return { error: "주석(코드 없는 글) 행을 고르세요" }; const idx = new Map(rows.map((r, k) => [r, k])); let j = i + 1; for (; j < rows.length; j++) { @@ -292,18 +330,26 @@ return { ok: true }; } if (field === "BDCODE1") { - const c = String(value || "").trim().toUpperCase(); + const c = String(value || "") + .trim() + .toUpperCase(); if (isStruct(r)) return { ok: false, error: "계표시 행 코드는 레벨 단추로 바꿈" }; if (STRUCT.test(c)) return { ok: false, error: "계표시는 [계표시 삽입]으로" }; if (c) { const cr = (costnByCode || {})[c]; if (!cr) return { ok: false, error: `단가목록에 없는 코드: ${c}` }; - Object.assign(r, { BDCODE1: c, NAME1: cr.NAME || "", SIZE1: cr.SIZE || "", UNIT1: cr.UNIT || "" }); + Object.assign(r, { + BDCODE1: c, + NAME1: cr.NAME || "", + SIZE1: cr.SIZE || "", + UNIT1: cr.UNIT || "", + }); } else r.BDCODE1 = ""; syncItems(model); return { ok: true }; } - if (!["ITNUM", "NAME1", "SIZE1", "UNIT1"].includes(field)) return { ok: false, error: "고칠 수 없는 칸" }; + if (!["ITNUM", "NAME1", "SIZE1", "UNIT1"].includes(field)) + return { ok: false, error: "고칠 수 없는 칸" }; r[field] = String(value == null ? "" : value); syncItems(model); return { ok: true }; @@ -336,7 +382,14 @@ sum.S += num(evalRows[q].S_AMT1); sum.T += num(evalRows[q].T_AMT1); } - out.push({ recno: r._recno, level: k.n, itnum: r.ITNUM || "", name: r.NAME1 || "", closeCode: codeOf(p), sum }); + out.push({ + recno: r._recno, + level: k.n, + itnum: r.ITNUM || "", + name: r.NAME1 || "", + closeCode: codeOf(p), + sum, + }); }); return out; } diff --git a/tools/stmate-test/ui_boq_tools.js b/tools/stmate-test/ui_boq_tools.js index 9e462370c..6d06263b5 100644 --- a/tools/stmate-test/ui_boq_tools.js +++ b/tools/stmate-test/ui_boq_tools.js @@ -11,7 +11,8 @@ (function (global) { "use strict"; - const B = global.STMATE_UI_BOQ || (typeof require !== "undefined" ? require("./ui_boq.js") : null); + const B = + global.STMATE_UI_BOQ || (typeof require !== "undefined" ? require("./ui_boq.js") : null); const { num, rowKind, codeOf, isLeaf } = B; const KO_VOWEL = [0, 4, 8, 13, 2, 6, 12, 17, 18, 20]; // ㅏ ㅓ ㅗ ㅜ ㅑ ㅕ ㅛ ㅠ ㅡ ㅣ (중성 번호) @@ -22,7 +23,9 @@ if (type === "ko") { if (i < 1 || i > 140) return ""; const k = i - 1; - return String.fromCharCode(0xac00 + (HEAD_IDX[k % 14] * 21 + KO_VOWEL[Math.floor(k / 14)]) * 28); + return String.fromCharCode( + 0xac00 + (HEAD_IDX[k % 14] * 21 + KO_VOWEL[Math.floor(k / 14)]) * 28, + ); } if (type === "en") { if (i < 1 || i > 260) return ""; @@ -56,7 +59,9 @@ wantLeaf = o.target !== "group"; // 초기화 레벨 — 기본은 대상 바로 위 레벨 머리마다(단가코드 대상은 모든 머리마다) · 0 = 이어서 const resetAt = - o.resetLevel === 0 ? 0 : Number(o.resetLevel) || (o.target === "leaf" ? 1 : Number(o.level) + 1); + o.resetLevel === 0 + ? 0 + : Number(o.resetLevel) || (o.target === "leaf" ? 1 : Number(o.level) + 1); for (let i = 0; i < rows.length; i++) { const r = rows[i], k = rowKind(r); @@ -66,7 +71,8 @@ const hit = i >= lo && i <= hi && - ((wantGroup && k.kind === "T" && k.n === Number(o.level)) || (wantLeaf && k.kind === "LEAF")); + ((wantGroup && k.kind === "T" && k.n === Number(o.level)) || + (wantLeaf && k.kind === "LEAF")); if (hit) { cnt += 1; let pre = ""; @@ -74,7 +80,8 @@ if (o.prefix === "parent") pre = o.first === "." ? pnum.replace(/\.$/, "") : pnum; else if (o.prefix === "space") pre = " ".repeat(pnum.length); const firstCh = o.first === "custom" ? String(o.firstText || "") : o.first; - r.ITNUM = " ".repeat(Number(o.margin) || 0) + pre + firstCh + seqText(cnt, o.type, o.pad) + o.last; + r.ITNUM = + " ".repeat(Number(o.margin) || 0) + pre + firstCh + seqText(cnt, o.type, o.pad) + o.last; changed += 1; } if (k.kind === "T" && links.get(r)) open.push(r); @@ -109,11 +116,21 @@ cr = costnByCode[cc]; const unit = cr ? String(cr.UNIT || "").trim() : ""; if (!cr || cc === "PR" || /^[TC]\d$/.test(cc) || /%$/.test(unit)) return void (skipped += 1); - made.push({ code: cc, name: cr.NAME || "", spec: cr.SIZE || "", unit, qty: roundTo(num(c.QTY1) * Q, o.digits, o.mode) }); + made.push({ + code: cc, + name: cr.NAME || "", + spec: cr.SIZE || "", + unit, + qty: roundTo(num(c.QTY1) * Q, o.digits, o.mode), + }); }); if (!made.length) return { ok: false, error: "풀 수 있는 구성 자원 없음" }; const at = model.rows.indexOf(row); - const head = B.makeRow(model, { BDCODE1: `T${o.level}`, NAME1: row.NAME1 || "", SIZE1: row.SIZE1 || "" }); + const head = B.makeRow(model, { + BDCODE1: `T${o.level}`, + NAME1: row.NAME1 || "", + SIZE1: row.SIZE1 || "", + }); head.ITNUM = row.ITNUM || ""; let rec = num(head._recno); const body = made.map((m) => { @@ -158,7 +175,12 @@ m = mode === "all" ? all : num(rates.M), l = mode === "all" ? all : num(rates.L), s = mode === "all" ? all : num(rates.S); - Object.assign(r, { RATE: String(mode === "all" ? all : 0), RM: String(m), RL: String(l), RS: String(s) }); + Object.assign(r, { + RATE: String(mode === "all" ? all : 0), + RM: String(m), + RL: String(l), + RS: String(s), + }); const it = byRec.get(num(r._recno)); if (it) it.halRate = { M: m, L: l, S: s }; changed += 1; diff --git a/tools/stmate-test/ui_dan.js b/tools/stmate-test/ui_dan.js index 64cf86d86..8cc658be3 100644 --- a/tools/stmate-test/ui_dan.js +++ b/tools/stmate-test/ui_dan.js @@ -9,7 +9,11 @@ "use strict"; const num = (v) => { - const n = parseFloat(String(v == null ? "" : v).replace(/,/g, "").trim()); + const n = parseFloat( + String(v == null ? "" : v) + .replace(/,/g, "") + .trim(), + ); return Number.isFinite(n) ? n : 0; }; const codeOf = (r) => String((r && r.CODE) || "").trim(); @@ -37,7 +41,10 @@ name: String(i[`JMOK${n}`] || `${n} 단가`).trim(), short: String(i[`JMK${n}0`] || "").trim(), })); - const order = String(i.JEMNM || "12345").split("").map(Number).filter((n) => n >= 1 && n <= 5); + const order = String(i.JEMNM || "12345") + .split("") + .map(Number) + .filter((n) => n >= 1 && n <= 5); const seen = new Set(); const out = order.filter((n) => !seen.has(n) && seen.add(n)).map((n) => names[n - 1]); names.forEach((x) => !seen.has(x.slot) && out.push(x)); @@ -59,16 +66,31 @@ function filterRows(rows, o) { const opt = Object.assign({ kind: "ALL", kw: "", mode: "name", sort: "C", memos: {} }, o || {}); const f = (KINDS.find((k) => k[0] === opt.kind) || KINDS[0])[2]; - const kw = String(opt.kw || "").trim().toLowerCase(); + const kw = String(opt.kw || "") + .trim() + .toLowerCase(); let list = rows.filter((r) => { const c = codeOf(r); if (!c || !f(c[0])) return false; if (!kw) return true; - if (opt.mode === "memo") return String((opt.memos[c] || {}).text || "").toLowerCase().includes(kw); - return `${c} ${r.NAME || ""} ${r.SIZE || ""} ${r.NAME2 || ""} ${r.SIZE2 || ""}`.toLowerCase().includes(kw); + if (opt.mode === "memo") + return String((opt.memos[c] || {}).text || "") + .toLowerCase() + .includes(kw); + return `${c} ${r.NAME || ""} ${r.SIZE || ""} ${r.NAME2 || ""} ${r.SIZE2 || ""}` + .toLowerCase() + .includes(kw); }); - const key = { C: codeOf, N: (r) => `${r.NAME || ""} ${r.SIZE || ""}`, I: (r) => String(r.BQCODE || ""), E: (r) => String(r.EQCODE || "") }[opt.sort] || codeOf; - list = list.slice().sort((a, b) => key(a).localeCompare(key(b), "ko") || codeOf(a).localeCompare(codeOf(b))); + const key = + { + C: codeOf, + N: (r) => `${r.NAME || ""} ${r.SIZE || ""}`, + I: (r) => String(r.BQCODE || ""), + E: (r) => String(r.EQCODE || ""), + }[opt.sort] || codeOf; + list = list + .slice() + .sort((a, b) => key(a).localeCompare(key(b), "ko") || codeOf(a).localeCompare(codeOf(b))); return list; } @@ -137,7 +159,10 @@ * 같은 비율을 두 번 적용해도 결과가 같음(기준단가 기준) */ function edtHal(rows, o) { - const opt = Object.assign({ kinds: "MLS", pct: 100, digits: 0, mode: "trunc", codes: null }, o || {}); + const opt = Object.assign( + { kinds: "MLS", pct: 100, digits: 0, mode: "trunc", codes: null }, + o || {}, + ); const only = opt.codes ? new Set(opt.codes) : null; const out = []; rows.forEach((r) => { @@ -158,17 +183,26 @@ // 사용처 찾기(ST_FindUse) — 내역서(모든 BOQ 표) · 구성표(BDQTY) · 산출근거 식(COSTN.SMT &코드&) function findUse(code, d) { - const c = String(code || "").trim().toUpperCase(); + const c = String(code || "") + .trim() + .toUpperCase(); const out = { boq: [], comp: [], memo: [] }; if (!c) return out; Object.entries((d && d.boq_tables) || {}).forEach(([t, rows]) => (rows || []).forEach((r) => { if (String(r.BDCODE1 || "").trim() === c) - out.boq.push({ table: t, recno: r._recno, itnum: r.ITNUM || "", name: r.NAME1 || "", qty: num(r.QTY1) }); + out.boq.push({ + table: t, + recno: r._recno, + itnum: r.ITNUM || "", + name: r.NAME1 || "", + qty: num(r.QTY1), + }); }), ); ((d && d.bdqty_rows) || []).forEach((r) => { - if (String(r.CODE || "").trim() === c) out.comp.push({ parent: String(r.BDCODE || "").trim(), qty: num(r.QTY1) }); + if (String(r.CODE || "").trim() === c) + out.comp.push({ parent: String(r.BDCODE || "").trim(), qty: num(r.QTY1) }); }); const re = new RegExp(`&${c}(?:[LMS])?(?::[LMS])?&`); Object.entries((d && d.costn_memos) || {}).forEach(([k, m]) => { diff --git a/tools/stmate-test/ui_menu.js b/tools/stmate-test/ui_menu.js index a6f6f4575..bc2be99ad 100644 --- a/tools/stmate-test/ui_menu.js +++ b/tools/stmate-test/ui_menu.js @@ -19,7 +19,9 @@ key: "1", kids: [ it("Act_Maneger", "프로젝트 관리", { hint: "프로젝트 관리 [ 열기, 등록, 백업, 삭제 ]" }), - it("Act_Sys", "초기값 입력및수정 (환율/소수점)", { hint: "초기값 입력 및 수정[ 환율 및 기초자료 ]" }), + it("Act_Sys", "초기값 입력및수정 (환율/소수점)", { + hint: "초기값 입력 및 수정[ 환율 및 기초자료 ]", + }), SEP, it("Act_Calc_Grass", "계 산 완 료", { sc: 120, hint: "수정분 계산" }), it("Act_Calc_All", "전체 재계산", { hint: "전체 재계산" }), @@ -32,7 +34,12 @@ it("mi_Auto_Time_Back", "30분마다 자동백업(재계산시)", { check: "autoBack30" }), it("Act_Arc_Now", "현재상태 자동백업"), SEP, - { id: "mi_Recent", cap: "최근 작업폴더", recent: true, kids: [it("mi_Del_Recent", "최근목록 지우기"), SEP] }, + { + id: "mi_Recent", + cap: "최근 작업폴더", + recent: true, + kids: [it("mi_Del_Recent", "최근목록 지우기"), SEP], + }, SEP, it("Act_Quit", "시스템종료", { sc: 32849, hint: "시스템 종료[ STmate 종료 ]" }), it("Act_Halt", "프로그램 강제종료"), @@ -43,7 +50,10 @@ cap: "2. 단가표/대가표", key: "2", kids: [ - it("Act_Dan", "단가목록표(전체분)", { sc: 16449, hint: "단가목록표[기초단가 등록 및 수정]" }), + it("Act_Dan", "단가목록표(전체분)", { + sc: 16449, + hint: "단가목록표[기초단가 등록 및 수정]", + }), it("Act_iLWi_San", "일위대가표,중기사용료,단가산출근거", { sc: 16450, hint: "대가,산근[ 일위대가, 단가산출근거, 중기사용료]", @@ -58,9 +68,13 @@ it("Act_PrjLst", "공사목록표", { sc: 16462, hint: "공사목록표[내역서 입력 및 수정)" }), it("Act_Prt", "보고서 인쇄", { sc: 24656, hint: "모든 보고서 인쇄" }), SEP, - it("Act_P_2PDF", "보고서 저장파일 ∥ SRP( 체크해제 ) ⇔ PDF【 Microsoft Print to PDF 】( √ 체크 )", { - check: "prnPdf", - }), + it( + "Act_P_2PDF", + "보고서 저장파일 ∥ SRP( 체크해제 ) ⇔ PDF【 Microsoft Print to PDF 】( √ 체크 )", + { + check: "prnPdf", + }, + ), it("Act_Un_Dir", "보고서 저장폴더 미지정(마지막 저장폴더로 설정)"), ], }, @@ -187,7 +201,11 @@ SEP, it("Act_Down_Center", "다운로드 센터(기초자료)", { out: true }), SEP, - { id: "N32", cap: "홈페이지 접속", kids: [it("Act_Home", "경영정보(주) - http://www.stma.co.kr", { out: true })] }, + { + id: "N32", + cap: "홈페이지 접속", + kids: [it("Act_Home", "경영정보(주) - http://www.stma.co.kr", { out: true })], + }, it("Act_ReqQS", "원격지원 요청", { out: true }), it("Window1", "윈도우 탐색기", { sc: 16471, out: true }), SEP, @@ -242,7 +260,13 @@ { act: "Act_PrjLst", screen: "wPrjList", cap: "공사목록표", ico: "📁" }, { act: "Act_Prt", screen: "PM_Prt", cap: "보고서인쇄", ico: "🖨️" }, SEP, - { act: "Act_Calc_Grass", id: "btn-calc-all", cap: "계 산 완 료", ico: "⚡", hint: "수정분 계산" }, + { + act: "Act_Calc_Grass", + id: "btn-calc-all", + cap: "계 산 완 료", + ico: "⚡", + hint: "수정분 계산", + }, SEP, { act: "Act_Quit", cap: "시스템종료", ico: "⏻" }, ]; @@ -287,7 +311,10 @@ } const esc = (s) => - String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); /** * 메뉴 HTML — st = { enabled(act) · checked(node) · recent: [{path,name}] } @@ -306,7 +333,10 @@ if (n.kids) { const extra = n.recent ? (st.recent || []) - .map((r) => ``) + .map( + (r) => + ``, + ) .join("") || '' : ""; out.push( diff --git a/tools/stmate-test/ui_sys.js b/tools/stmate-test/ui_sys.js index 1691eeef4..c5214e262 100644 --- a/tools/stmate-test/ui_sys.js +++ b/tools/stmate-test/ui_sys.js @@ -28,22 +28,79 @@ const FIELDS = { rate: [ - { key: "EXLNM{n}", cap: "A 미국 USD (₩/$)", type: "num", hint: "외산장비중 $장비에 적용될 미국 USD 환율(경비중 단위가『$』인 장비)" }, - { key: "EXHNM{n}", cap: " └ 10만$ 이상 장비", type: "num", hint: "외산장비『$』(10만$이상)에 적용될 환율" }, + { + key: "EXLNM{n}", + cap: "A 미국 USD (₩/$)", + type: "num", + hint: "외산장비중 $장비에 적용될 미국 USD 환율(경비중 단위가『$』인 장비)", + }, + { + key: "EXHNM{n}", + cap: " └ 10만$ 이상 장비", + type: "num", + hint: "외산장비『$』(10만$이상)에 적용될 환율", + }, { key: "EXEUR{n}", cap: "유로 EUR (₩/€)", type: "num", hint: "경비중 단위가『€』인 장비" }, - { key: "EXYEN{n}", cap: "일본 JPY100(₩/100¥)", type: "num", hint: "경비중 단위가『¥』인 장비" }, - { key: "EXRNM", cap: "B 환율 자동적용 범위", type: "sel", opts: [["0", "시간당중기사용료 단독적용"], ["1", "중기, 대가, 산근, 내역 전체"]] }, - { key: "RXXNM", cap: "C 시간당 노임산출계수", type: "text", hint: "시간당 노임산출계수(ex. 일반구간)" }, + { + key: "EXYEN{n}", + cap: "일본 JPY100(₩/100¥)", + type: "num", + hint: "경비중 단위가『¥』인 장비", + }, + { + key: "EXRNM", + cap: "B 환율 자동적용 범위", + type: "sel", + opts: [ + ["0", "시간당중기사용료 단독적용"], + ["1", "중기, 대가, 산근, 내역 전체"], + ], + }, + { + key: "RXXNM", + cap: "C 시간당 노임산출계수", + type: "text", + hint: "시간당 노임산출계수(ex. 일반구간)", + }, { key: "CONLD", cap: " └ 적용코드", type: "codes" }, ], digits: [ { key: "KCDNM", cap: "D 중기사용료 금액소수점선택", type: "sel", opts: D012 }, - { key: "JCDNM", cap: "중기 노임 단가소수점선택", type: "sel", opts: D012, hint: '"X" 코드에서 "L(중기노임)"코드 사용시 적용' }, - { key: "ECDNM", cap: "중기$장비 단가소수점선택", type: "sel", opts: D012, hint: '"X" 코드에서 "S($장비)"코드 사용시 적용' }, + { + key: "JCDNM", + cap: "중기 노임 단가소수점선택", + type: "sel", + opts: D012, + hint: '"X" 코드에서 "L(중기노임)"코드 사용시 적용', + }, + { + key: "ECDNM", + cap: "중기$장비 단가소수점선택", + type: "sel", + opts: D012, + hint: '"X" 코드에서 "S($장비)"코드 사용시 적용', + }, { key: "ACDNM", cap: "E 단가산출서 금액소수점선택", type: "sel", opts: D012 }, { key: "BPNNM", cap: "일위대가표 금액소수점선택", type: "sel", opts: D012 }, - { key: "ZCDNM", cap: "F 단가산출서 수량소수점선택", type: "sel", opts: [["2", "2 자리"], ["3", "3 자리"]] }, - { key: "CUTNM", cap: "계금액 3원미만 결과소수점", type: "sel", opts: [["1", "1 자리"], ["0", " 버림"]], hint: "대가,산근 계산결과 3원미만일 때 결과금액 자리수" }, + { + key: "ZCDNM", + cap: "F 단가산출서 수량소수점선택", + type: "sel", + opts: [ + ["2", "2 자리"], + ["3", "3 자리"], + ], + }, + { + key: "CUTNM", + cap: "계금액 3원미만 결과소수점", + type: "sel", + opts: [ + ["1", "1 자리"], + ["0", " 버림"], + ], + hint: "대가,산근 계산결과 3원미만일 때 결과금액 자리수", + }, ], calc: [ { key: "CHGNM", cap: "G 단가산출근거", type: "sel", opts: CALC }, @@ -70,7 +127,9 @@ [...FIELDS.digits, ...FIELDS.calc].forEach((f) => take(f.key)); RX_SETS.forEach((s) => (take(s.rx), take(s.con))); ["ARENM", "OPTNM"].forEach(take); - ["CHGNM", "CHBNM", "CHNNM", "CUTNM", "EXRNM"].forEach((k) => d[k] !== "" && (d[k] = bool01(d[k]))); + ["CHGNM", "CHBNM", "CHNNM", "CUTNM", "EXRNM"].forEach( + (k) => d[k] !== "" && (d[k] = bool01(d[k])), + ); if (!/^[SML]{3}$/.test(d.OPTNM) || new Set(d.OPTNM).size !== 3) d.OPTNM = "SML"; if (d.ARENM !== "2") d.ARENM = "1"; return d; @@ -78,13 +137,18 @@ // 적용코드 — 6자리 코드를 이어 붙인 글(CONLD=L00008L00009L00010) const splitCodes = (s) => String(s || "").match(/[A-Z]\d{5}/g) || []; - const joinCodes = (list) => (list || []).map((c) => String(c).trim().toUpperCase()).filter((c) => /^[A-Z]\d{5}$/.test(c)).join(""); + const joinCodes = (list) => + (list || []) + .map((c) => String(c).trim().toUpperCase()) + .filter((c) => /^[A-Z]\d{5}$/.test(c)) + .join(""); // 노임 환산식 검사 — 숫자·사칙·괄호·공백만 function checkFormula(s) { const t = String(s || "").trim(); if (!t) return { ok: true, value: null }; - if (!/^[\d.\s+\-*/()]+$/.test(t)) return { ok: false, error: "숫자와 + - * / ( ) 만 쓸 수 있음" }; + if (!/^[\d.\s+\-*/()]+$/.test(t)) + return { ok: false, error: "숫자와 + - * / ( ) 만 쓸 수 있음" }; try { const v = Function(`"use strict";return (${t});`)(); return Number.isFinite(v) ? { ok: true, value: v } : { ok: false, error: "계산 불가" }; @@ -109,7 +173,8 @@ }); ["CONLD", ...RX_SETS.map((s) => s.con)].forEach((k) => { const raw = String(d[k] || ""); - if (raw && joinCodes(splitCodes(raw)) !== raw.replace(/\s/g, "")) errs.push(`${k}: 코드는 영문 1자 + 숫자 5자리`); + if (raw && joinCodes(splitCodes(raw)) !== raw.replace(/\s/g, "")) + errs.push(`${k}: 코드는 영문 1자 + 숫자 5자리`); }); return errs; } @@ -142,7 +207,8 @@ if (ini.EXLNM2 !== undefined && ini.EXLNM2 !== "") s.usdRate2 = Number(ini.EXLNM2); s.useChangedUsd = String(ini.ARENM) === "2"; if (ini.ECDNM !== undefined && ini.ECDNM !== "") s.ecdDigits = Number(ini.ECDNM) || 0; - if (ini.CHNNM !== undefined && ini.CHNNM !== "") s.chnMode = bool01(ini.CHNNM) === "0" ? "total" : "bimok"; + if (ini.CHNNM !== undefined && ini.CHNNM !== "") + s.chnMode = bool01(ini.CHNNM) === "0" ? "total" : "bimok"; if (/^[SML]{3}$/.test(String(ini.OPTNM || ""))) s.optOrder = ini.OPTNM; return s; } diff --git a/tools/stmate-test/view_prj_boq.js b/tools/stmate-test/view_prj_boq.js index a57c9cae9..f0ff237e3 100644 --- a/tools/stmate-test/view_prj_boq.js +++ b/tools/stmate-test/view_prj_boq.js @@ -529,8 +529,9 @@ LAMT1: String(lVal), SAMT1: String(sVal), TAMT1: String(tVal), - AMT_61: String(tVal), }); + // 적용단가 칸(AMT_61)은 기초단가(M·L·S)만 — 일위대가·산근·중기는 원본도 0 + if (/^[MLS]/.test(code) || num(row.AMT_61)) row.AMT_61 = String(tVal); } } const directRes = ctx.state.resources.find((r) => r.code === code); @@ -571,7 +572,8 @@ * 보내지 않는 것: 1차 단가로 2차 칸 덮기 · 고정 낙찰율 3차 칸 · 소계·PRJLST 직접비(서버가 다시 셈) * 근거: STC_왕복검증.md §3(알려진 칸만 갱신) · 16번 §4(행 금액·계층 집계·TCOSTn) */ - const COSTN_SAVE_COL = /^(NAME|SIZE|UNIT|AMT_\d1|[TMLS]AMT[1-5])$/; + const COSTN_SAVE_COL = + /^(NAME|SIZE|UNIT|AMT_\d1|PG_1|UC|SBMOK|S_ADJ|ADJ_[MLS]|RXX|NAGCD|[TMLS]AMT[1-5])$/; const isPctRow = (r) => String(r.UNIT1 || "") .trim()