feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경
- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면 - 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면 - 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림 - 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표 - 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0) - DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인) - 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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]}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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} 없음"
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]}
|
||||
@@ -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"]))
|
||||
@@ -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()
|
||||
|
||||
@@ -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]]}
|
||||
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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, ">")
|
||||
.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}</${name}>`;
|
||||
const leaf = (name, v) => `<${name}>${esc(v)}</${name}>`;
|
||||
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 (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\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;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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]) => `<option value="${v}" ${String(cur) === String(v) ? "selected" : ""}>${l}</option>`).join("");
|
||||
const opt = (list, cur) =>
|
||||
list
|
||||
.map(
|
||||
([v, l]) =>
|
||||
`<option value="${v}" ${String(cur) === String(v) ? "selected" : ""}>${l}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const isX = code[0] === "X";
|
||||
const errs = checkDraft(ctx, code);
|
||||
return `
|
||||
@@ -73,9 +107,23 @@
|
||||
<button class="win-btn" id="comp-devd">나누기수량 → 일반수량</button>
|
||||
<button class="win-btn" id="comp-undo">당초내역 재생</button>
|
||||
<button class="win-btn primary" id="comp-apply">재계산(C) · 단가 반영</button>
|
||||
${isX ? `<label>노임산출계수 <select id="comp-rxx">${opt(RXX.map((l, i) => [i, l]), num(row.RXX))}</select></label>` : ""}
|
||||
${isX ? `<label>손료 <select id="comp-shift">${opt(SHIFT.map(([v, l]) => [v, `${v} ${l}`]), row.SHIFT || sys.shiftMode || "E0")}</select></label>` : ""}
|
||||
${isX ? `<label>계수 <input id="comp-shift-f" type="number" step="0.0001" style="width:70px" value="${esc(((sys.shiftFactors || {})[row.SHIFT || sys.shiftMode || "E0"]) || "")}" title="E1~E3 손료 계수 — 품셈 값 입력(빈칸 = 1)"></label>` : ""}
|
||||
${
|
||||
isX
|
||||
? `<label>노임산출계수 <select id="comp-rxx">${opt(
|
||||
RXX.map((l, i) => [i, l]),
|
||||
num(row.RXX),
|
||||
)}</select></label>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
isX
|
||||
? `<label>손료 <select id="comp-shift">${opt(
|
||||
SHIFT.map(([v, l]) => [v, `${v} ${l}`]),
|
||||
row.SHIFT || sys.shiftMode || "E0",
|
||||
)}</select></label>`
|
||||
: ""
|
||||
}
|
||||
${isX ? `<label>계수 <input id="comp-shift-f" type="number" step="0.0001" style="width:70px" value="${esc((sys.shiftFactors || {})[row.SHIFT || sys.shiftMode || "E0"] || "")}" title="E1~E3 손료 계수 — 품셈 값 입력(빈칸 = 1)"></label>` : ""}
|
||||
<label>결과적용 비목 <select id="comp-sbmok">${opt(SB, String(row.SBMOK || "").trim())}</select></label>
|
||||
</div>
|
||||
${errs.length ? `<div class="err-box" style="color:#b91c1c;padding:4px 8px;">${errs.map(esc).join("<br>")}</div>` : ""}
|
||||
@@ -96,7 +144,11 @@
|
||||
<td class="center">${i + 1}</td>
|
||||
<td><input class="cell-input comp-code" data-i="${i}" value="${esc(c)}" style="width:66px"></td>
|
||||
<td>${esc(v.name || "")}</td><td>${esc(v.size || "")}</td><td class="center">${esc(v.unit || "")}</td>
|
||||
<td><input class="cell-input num comp-qty" data-i="${i}" value="${esc(String(r.QTY1 ?? "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ""))}" style="width:84px"></td>
|
||||
<td><input class="cell-input num comp-qty" data-i="${i}" value="${esc(
|
||||
String(r.QTY1 ?? "")
|
||||
.replace(/(\.\d*?)0+$/, "$1")
|
||||
.replace(/\.$/, ""),
|
||||
)}" style="width:84px"></td>
|
||||
<td class="center"><input type="checkbox" class="comp-devd" data-i="${i}" ${["T", true].includes(r.DEVD1) ? "checked" : ""}></td>
|
||||
<td class="center">${pr ? `${COMP[r.PRIN] || "-"}→${COMP[r.PROUT] || "-"}` : String(r.PRPT || "0").trim() !== "0" && String(r.PRPT || "").trim() ? "밑수" : ""}</td>
|
||||
<td class="num">${amt("M")}</td><td class="num">${amt("L")}</td><td class="num">${amt("S")}</td>
|
||||
@@ -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")}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<label>적용 <select id="eq-insx"><option value="0" ${!it.ins_x ? "selected" : ""}>공 식</option><option value="1" ${it.ins_x ? "selected" : ""}>공식+코드</option></select></label></div>
|
||||
<div class="pane-box" style="flex:1;"><textarea id="eq-text" class="smt-editor-area" spellcheck="false">${esc(it.text)}</textarea></div>
|
||||
<div class="pane-box" style="max-height:120px;overflow:auto;font-size:12px;">
|
||||
<div class="pane-title"><span>문법 검사 ${ev.errors.length}건 · 변수 ${esc(Object.entries(ev.vars).map(([k, v]) => `${k}=${v}`).join(", ") || "없음")}</span></div>
|
||||
<div class="pane-title"><span>문법 검사 ${ev.errors.length}건 · 변수 ${esc(
|
||||
Object.entries(ev.vars)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ") || "없음",
|
||||
)}</span></div>
|
||||
${ev.errors.map((e) => `<div style="color:#b91c1c">${e.lineNo}번째줄: ${esc(e.msg)}</div>`).join("")}
|
||||
</div>`
|
||||
: "<div class='pane-box'>등록된 중기공식 없음 — [추가]</div>"
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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)",
|
||||
`<div style="display:flex;flex-direction:column;gap:8px;">
|
||||
@@ -61,12 +73,18 @@
|
||||
</div>`,
|
||||
);
|
||||
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 =
|
||||
`<div>바뀔 자재 ${res.changed.length}건 · 보호표시로 그대로 ${res.kept.length}건</div>` +
|
||||
res.changed.map((c) => `<div>${c.code}: ${app.fmt(c.from)} → ${app.fmt(c.to)} (${c.src}단가)</div>`).join("");
|
||||
res.changed
|
||||
.map((c) => `<div>${c.code}: ${app.fmt(c.from)} → ${app.fmt(c.to)} (${c.src}단가)</div>`)
|
||||
.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();
|
||||
|
||||
@@ -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 @@
|
||||
`<select id="${id}">${opts.map(([v, l]) => `<option value="${esc(v)}" ${String(cur) === String(v) ? "selected" : ""}>${esc(l)}</option>`).join("")}</select>`;
|
||||
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 @@
|
||||
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
|
||||
<input id="san-code" list="san-code-list" placeholder="코드(F7)" style="width:80px" value="${esc(u.sanCode || "")}">
|
||||
<datalist id="san-code-list">${((ctx.dbfData && ctx.dbfData.costn_rows) || []).map((r) => `<option value="${esc(String(r.CODE).trim())}">${esc(r.NAME)} ${esc(r.SIZE)}</option>`).join("")}</datalist>
|
||||
${sel("san-trans", L().TRANS.map((t, i) => [String(i), t]), u.sanTrans ?? "1")}
|
||||
<span>중기비 코드</span>${sel("san-op", [["/", "/"], ["*", "*"]], u.sanOp || "/")}${sel("san-qv", ["Q", "A", "T", "N"].map((v) => [v, v]), u.sanQv || "Q")}<span>(으)로 입력</span>
|
||||
${sel(
|
||||
"san-trans",
|
||||
L().TRANS.map((t, i) => [String(i), t]),
|
||||
u.sanTrans ?? "1",
|
||||
)}
|
||||
<span>중기비 코드</span>${sel(
|
||||
"san-op",
|
||||
[
|
||||
["/", "/"],
|
||||
["*", "*"],
|
||||
],
|
||||
u.sanOp || "/",
|
||||
)}${sel(
|
||||
"san-qv",
|
||||
["Q", "A", "T", "N"].map((v) => [v, v]),
|
||||
u.sanQv || "Q",
|
||||
)}<span>(으)로 입력</span>
|
||||
<label><input type="checkbox" id="san-skip0" ${u.sanSkip0 ? "checked" : ""}> 단가가 0원인 비목은 입력안함</label>
|
||||
<button class="win-btn" id="san-ins">코드 삽입</button>
|
||||
<span style="margin-left:auto;">결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())}</span>
|
||||
@@ -156,13 +194,19 @@
|
||||
${errs.map((e) => `<div class="san-err" data-line="${e.lineNo}" style="color:#b91c1c;cursor:pointer;font-size:12px;">${e.lineNo ? `${e.lineNo}번째줄: ` : ""}${esc(e.msg)}</div>`).join("") || "<div style='font-size:12px;color:#15803d;'>오류 없음</div>"}
|
||||
</div>
|
||||
<div class="pane-box" style="flex:1;">
|
||||
<div class="pane-title"><span>계산결과(Alt+F9) — 변수 ${esc(Object.entries(ev.vars).map(([k, v]) => `${k}=${v}`).join(", ") || "없음")}</span>
|
||||
<div class="pane-title"><span>계산결과(Alt+F9) — 변수 ${esc(
|
||||
Object.entries(ev.vars)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ") || "없음",
|
||||
)}</span>
|
||||
<span>재 ${fmt(ev.sumM)} + 노 ${fmt(ev.sumL)} + 경 ${fmt(ev.sumS)} = <strong>${fmt(ev.sumT)}원</strong></span></div>
|
||||
<div class="grid-scroll"><table class="dbgrid">
|
||||
<thead><tr><th style="width:36px">줄</th><th>산식</th><th style="width:140px">대입·금액변수</th><th style="width:150px">참조</th><th style="width:86px">참조단가</th><th style="width:90px">금액</th></tr></thead>
|
||||
<tbody>${lines
|
||||
.map(
|
||||
(ln) => `<tr style="${ln.errors.length ? "background:#fee2e2;" : ln.excluded ? "color:#6b7280;" : ""}"><td class="center">${ln.lineNo}</td>
|
||||
(
|
||||
ln,
|
||||
) => `<tr style="${ln.errors.length ? "background:#fee2e2;" : ln.excluded ? "color:#6b7280;" : ""}"><td class="center">${ln.lineNo}</td>
|
||||
<td style="font-family:Consolas,monospace;">${u.sanHilite === false ? esc(ln.raw) : L().highlight(ln.raw)}</td>
|
||||
<td>${esc(ln.assigned || "")}${ln.excluded ? " (제외)" : ""}</td>
|
||||
<td>${ln.refInfo ? `${esc(ln.refInfo.refCode)}[${ln.refInfo.compTag}] ${esc(ln.refInfo.refName)}` : ""}</td>
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
`<div class="kanjub-field"><label>${esc(label)}</label><select class="win-select kjx-key" data-key="${key}">${opts
|
||||
.map(
|
||||
([v, t]) =>
|
||||
`<option value="${v}" ${n0(val) === n0(v) ? "selected" : ""}>${esc(t)}</option>`,
|
||||
)
|
||||
.join("")}</select></div>`;
|
||||
const inp = (key, label, val, w = 110) =>
|
||||
`<div class="kanjub-field"><label>${esc(label)}</label><input class="kjx-key" data-key="${key}" value="${esc(val ?? 0)}" style="width:${w}px;text-align:right"></div>`;
|
||||
|
||||
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) =>
|
||||
`<button class="win-btn kjx-addtab ${g === i + 1 ? "primary" : ""}" data-g="${i + 1}">${i + 1}. ${t}</button>`,
|
||||
)
|
||||
.join("");
|
||||
const body = rows
|
||||
.map(([r, i]) => {
|
||||
const amts = [0, 1, 2, 3, 4]
|
||||
.map(
|
||||
(s) =>
|
||||
`<td><input class="kjx-add" data-i="${i}" data-f="amt${s}" value="${n0((r.amts || [])[s])}" style="width:90px;text-align:right;${s + 1 === n ? "background:#ffd" : ""}"></td>`,
|
||||
)
|
||||
.join("");
|
||||
const extra =
|
||||
g === 2
|
||||
? `<input type="checkbox" class="kjx-add" data-i="${i}" data-f="vat" ${r.vat ? "checked" : ""}> 부가세`
|
||||
: g === 1
|
||||
? `<input type="checkbox" class="kjx-add" data-i="${i}" data-f="addt" ${r.addt ? "checked" : ""}> 자동`
|
||||
: "";
|
||||
return `<tr><td><input class="kjx-add" data-i="${i}" data-f="name" value="${esc(r.name)}" style="width:150px"></td><td><input class="kjx-add" data-i="${i}" data-f="unit" value="${esc(r.unit)}" style="width:40px"></td>${amts}<td>${extra}</td><td><input class="kjx-add" data-i="${i}" data-f="bigo" value="${esc(r.bigo)}" style="width:100px"></td><td><button class="win-btn kjx-add-del" data-i="${i}">삭제</button></td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="win-subbar">${tabs}<button class="win-btn" id="kjx-add-new">행 추가</button></div>
|
||||
<table class="dbgrid"><thead><tr><th>추가비용 명칭</th><th>단위</th><th>원설계</th><th>1차</th><th>2차</th><th>3차</th><th>예비</th><th>선택</th><th>비고</th><th></th></tr></thead><tbody>${body || `<tr><td colspan="10">행 없음</td></tr>`}</tbody></table>`;
|
||||
}
|
||||
|
||||
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 `<table class="dbgrid"><thead><tr><th>항목</th><th>요율(%)</th><th>고정(D_)</th><th>저장 금액(C_)</th><th>밑수</th><th>계산 금액</th><th>식</th></tr></thead><tbody>${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 `<tr><td>${esc(it.label || c)}</td><td><input class="kjx-key" data-key="R_${k}" value="${cfg.rates[c] ?? n0(cfg.keys["R_" + k])}" style="width:70px;text-align:right"></td><td><input type="checkbox" class="kjx-fix" data-k="${k}" ${D ? "checked" : ""}></td><td><input class="kjx-key" data-key="C_${k}" value="${n0(cfg.keys["C_" + k])}" style="width:100px;text-align:right" ${D ? "" : "disabled"}></td><td style="text-align:right">${it.baseAmt != null ? fmt(Math.round(it.baseAmt)) : ""}</td><td style="text-align:right">${it.amount != null ? fmt(it.amount) : ""}</td><td style="font-size:11px">${esc(it.tpl || it.formula || "")}</td></tr>`;
|
||||
})
|
||||
.join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderSheet(kj) {
|
||||
return `<table class="dbgrid"><thead><tr><th>구분</th><th>금액</th><th>비고</th></tr></thead><tbody>${kj.lines
|
||||
.map((l) => {
|
||||
const note =
|
||||
l.rate != null && l.baseAmt != null
|
||||
? `${esc(l.formula)} x ${fmt(l.rate)}%`
|
||||
: esc(l.formula);
|
||||
return `<tr><td>${esc(l.label)}</td><td style="text-align:right">${fmt(l.amount)}</td><td style="font-size:11px">${note}</td></tr>`;
|
||||
})
|
||||
.join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
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) =>
|
||||
`<option value="${s}" ${n0(s) === n ? "selected" : ""}>${["", "원설계", "1차변경", "2차변경", "3차변경"][s]}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const axes = X().SCHEME_AXES[sch].flatMap((a) => X().AXIS[a]);
|
||||
const tabs = ["T", "S", "TA", "SA"]
|
||||
.map(
|
||||
(s, i) =>
|
||||
`<button class="win-btn kjx-sch ${s === sch ? "primary" : ""}" data-s="${s}">${["일반형식", "수공형식", "실적일반", "실적수공"][i]}</button>`,
|
||||
)
|
||||
.join("");
|
||||
const tab = u.kjTab || "sheet";
|
||||
const subtabs = [
|
||||
["sheet", "원가계산서"],
|
||||
["rates", "요율·고정"],
|
||||
["inputs", "직접입력 금액"],
|
||||
["adds", "추가금액"],
|
||||
["ord", "간접비 순서"],
|
||||
]
|
||||
.map(
|
||||
([t, l]) =>
|
||||
`<button class="win-btn kjx-tab ${t === tab ? "primary" : ""}" data-t="${t}">${l}</button>`,
|
||||
)
|
||||
.join("");
|
||||
const ordBox = [1, 2]
|
||||
.map(
|
||||
(i) =>
|
||||
`<div class="kanjub-field" style="width:100%"><label>KAN_ORD_${i} (${i === 1 ? "일반" : "수공"})</label><input class="kjx-ord" data-i="${i}" value="${esc((cfg.kanOrd && cfg.kanOrd[i]) || "")}" style="width:90%"></div>`,
|
||||
)
|
||||
.join("");
|
||||
const body =
|
||||
tab === "rates"
|
||||
? renderRates(cfg, kj, sch)
|
||||
: tab === "inputs"
|
||||
? `<div class="kanjub-16grid">${X()
|
||||
.INPUTS.map(([key, l]) => inp(key, l, k[key]))
|
||||
.join("")}</div>`
|
||||
: tab === "adds"
|
||||
? renderAdds(cfg, n)
|
||||
: tab === "ord"
|
||||
? `<div style="padding:6px">${ordBox}<div style="font-size:11px">301 간접노무비 · 302 산재 · 303 고용 · 310 건강 · 304 장기요양 · 305 연금 · 306 퇴직공제 · 312 산업안전 · 313 안전품질 별도 · 321 기타경비 · 331~338 수공 세부경비 · 351 수공 품질 · 307 이행보증 · 308 하도급 · 309 환경 · 311 건설기계 (20번 §2.2)</div></div>`
|
||||
: renderSheet(kj);
|
||||
const warn = u.kjMsg ? `<div style="color:#b00;padding:4px">${esc(u.kjMsg)}</div>` : "";
|
||||
return `<div class="win-form">
|
||||
<div class="win-form-header"><span>제잡비 계산 [TWM_KANJUB · ${n}차수 · ${sch}형식]</span>
|
||||
<span>직접공사비 ${fmt(kj.dT)} → 도급 ${fmt(kj.contractFinal)} + 관급 ${fmt(kj.govFinal)} = 총공사비 ${fmt(kj.grandTotal)}</span></div>
|
||||
<div class="win-subbar">
|
||||
<label>차수</label><select id="kjx-chasu" class="win-select">${chasu}</select>
|
||||
<label>제잡비 방식</label><select class="win-select kjx-key" data-key="CAL_ACTL"><option value="0" ${n0(k.CAL_ACTL) ? "" : "selected"}>제잡비 원가계산방식</option><option value="1" ${n0(k.CAL_ACTL) ? "selected" : ""}>제잡비 표준시장방식</option></select>
|
||||
<label>간접비 절사</label><select class="win-select kjx-key" data-key="I_KAN_JEOLSA">${[0, 1, 2, 3, 4, 5, 6].map((i) => `<option value="${i}" ${n0(k.I_KAN_JEOLSA) === i ? "selected" : ""}>${i ? `${X().UNITS[i]} 미만 절사` : "간접비 절사없음"}</option>`).join("")}</select>
|
||||
<select id="kjx-jbr" class="win-select" style="width:180px"><option value="">(공사 안 RATE_R)</option>${jbr.map((f) => `<option value="${esc(f.path)}">${esc(f.name)}</option>`).join("")}</select>
|
||||
<button class="win-btn" id="kjx-apply-rate">제비율표 요율 적용</button>
|
||||
<button class="win-btn" id="kjx-open-rate">제비율표</button>
|
||||
<button class="win-btn primary" id="kjx-save" style="margin-left:auto">결과 저장(SMT)</button>
|
||||
</div>${warn}
|
||||
<div class="kanjub-16grid">${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("")}</div>
|
||||
<div class="win-subbar">${tabs}</div>
|
||||
<div class="kanjub-16grid">${axes.map(([key, l, o]) => sel(key, l, o, k[key])).join("")}</div>
|
||||
<div class="win-subbar">${subtabs}</div>
|
||||
<div style="overflow:auto;max-height:520px">${body}</div></div>`;
|
||||
}
|
||||
|
||||
// 저장 사전(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);
|
||||
@@ -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);
|
||||
@@ -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) =>
|
||||
`<input class="kr-cell" data-r="${r}" data-c="${c}" value="${R.cell(m, r, c) || ""}" style="width:${w}px;text-align:right" title="[${r},${c}]">`;
|
||||
|
||||
function scaleGrid(m) {
|
||||
const kongs = [
|
||||
[1, "토목"],
|
||||
[2, "산설(토)"],
|
||||
[3, "조경"],
|
||||
[4, "건축"],
|
||||
[5, "산설(건)"],
|
||||
[21, "국가유산"],
|
||||
];
|
||||
const head = `<tr><th>공사규모</th><th>공사기간</th>${kongs.map(([, l]) => `<th>간노 ${l}</th>`).join("")}${kongs.map(([, l]) => `<th>기타 ${l}</th>`).join("")}<th>일반관리비(일반)</th><th>일반관리비(전문)</th><th>이윤</th></tr>`;
|
||||
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]) => `<td>${box(m, rr(c), c)}</td>`).join("") +
|
||||
kongs.map(([c]) => `<td>${box(m, rr(c), c + 5)}</td>`).join("");
|
||||
const tail =
|
||||
p === 0
|
||||
? `<td>${box(m, row, 11)}</td><td>${box(m, row, 15)}</td><td>${box(m, row, 16)}</td>`
|
||||
: "<td></td><td></td><td></td>";
|
||||
return `<tr>${p === 0 ? `<td rowspan="4">${esc(b.name)}</td>` : ""}<td>${k}</td>${cells}${tail}</tr>`;
|
||||
}),
|
||||
);
|
||||
return `<table class="dbgrid"><thead>${head}</thead><tbody>${body.join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function annexGrid(m) {
|
||||
const ins = [
|
||||
["산재보험료", 45, 1],
|
||||
["건강보험료", 46, 1],
|
||||
["연금보험료", 46, 6],
|
||||
["노인장기요양", 46, 11],
|
||||
]
|
||||
.map(([l, r, c]) => `<tr><td>${l}</td><td>${box(m, r, c)}</td></tr>`)
|
||||
.join("");
|
||||
const kobo = [1, 2, 3, 4, 5, 6, 7]
|
||||
.map(
|
||||
(g) =>
|
||||
`<tr><td>${g < 7 ? `${g} 등급` : "7 등급이하"}</td><td>${box(m, 45, 10 + g)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
const ag = [
|
||||
["건축", 0],
|
||||
["토목", 1],
|
||||
["중건설", 3],
|
||||
["특수건설", 4],
|
||||
]
|
||||
.map(([l, t]) => {
|
||||
const o = R.ANJUN_OFF[t];
|
||||
return `<tr><td>${l}</td><td>${box(m, 10 + o, 17)}</td><td>${box(m, 20 + o, 17)} + ${box(m, 20 + o, 18, 60)}천원</td><td>${box(m, 25 + o, 17)}</td><td>${box(m, R.HEALTH_ROW + o, 17)}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
const strip = (r, c0, nn, lab) =>
|
||||
Array.from({ length: nn }, (_, i) => `<td>${lab(i)}<br>${box(m, r, c0 + i, 44)}</td>`).join(
|
||||
"",
|
||||
);
|
||||
return `<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||
<table class="dbgrid"><thead><tr><th>보험</th><th>요율</th></tr></thead><tbody>${ins}</tbody></table>
|
||||
<table class="dbgrid"><thead><tr><th>고용보험 등급</th><th>요율</th></tr></thead><tbody>${kobo}</tbody></table>
|
||||
<table class="dbgrid"><thead><tr><th>안전 공종</th><th>5억 미만</th><th>5~50억 미만 + 기초액</th><th>50억 이상</th><th>보건관리자 대상</th></tr></thead><tbody>${ag}</tbody></table></div>
|
||||
<table class="dbgrid"><thead><tr><th colspan="14">환경보전비 (재료비+직접노무비+산출경비) × 율 — 공종 번호</th></tr></thead><tbody><tr>${strip(47, 1, 14, (i) => i + 1)}</tr></tbody></table>
|
||||
<table class="dbgrid"><thead><tr><th colspan="10">건설기계대여금 지급보증 — 구분 번호(0 일반공사)</th></tr></thead><tbody><tr>${strip(49, 1, 10, (i) => i)}</tr></tbody></table>
|
||||
<table class="dbgrid"><thead><tr><th colspan="4">퇴직공제부금비 직접노무비 × 율</th></tr></thead><tbody><tr>${strip(46, 16, 4, (i) => ["토목", "준설", "건축", "기타"][i])}</tr></tbody></table>`;
|
||||
}
|
||||
|
||||
function rawGrid(m) {
|
||||
const cols = m[0] ? m[0].length : 0;
|
||||
const head = Array.from({ length: cols }, (_, c) => `<th>${c + 1}</th>`).join("");
|
||||
const body = m.map(
|
||||
(row, r) =>
|
||||
`<tr><td>${r}</td>${row.map((_, c) => `<td>${box(m, r, c + 1, 46)}</td>`).join("")}</tr>`,
|
||||
);
|
||||
return `<table class="dbgrid"><thead><tr><th>행</th>${head}</tr></thead><tbody>${body.join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
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 `<tr><td>${c}</td><td>${res.cells[c] ? `[${res.cells[c]}]` : "-"}</td><td style="text-align:right">${v}</td><td style="text-align:right">${cur ?? ""}</td><td>${cur != null && cur !== v ? "다름(수동값)" : ""}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="win-subbar"><b>${n}차수 조회</b> — 규모 ${esc(res.band.name)} · 안전 ${esc(res.anjunBand.name || "보건관리자 대상")} · 기초액 ${res.amounts.C_ANJUN_G}
|
||||
<label><input type="checkbox" id="kr-tbugum"> 퇴직공제 포함</label><button class="win-btn primary" id="kr-apply">현재 차수에 적용</button></div>
|
||||
<table class="dbgrid"><thead><tr><th>항목</th><th>칸</th><th>표 값</th><th>지금 요율</th><th></th></tr></thead><tbody>${rows}</tbody></table>
|
||||
${res.notes.map((t) => `<div style="color:#a60">${esc(t)}</div>`).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) => `<option ${t === src ? "selected" : ""}>${esc(t)}</option>`)
|
||||
.join("");
|
||||
const tabs = [
|
||||
["scale", "규모·기간(Rate_R/R1)"],
|
||||
["annex", "부속표(보험·안전·환경·보증)"],
|
||||
["raw", "원본 행렬"],
|
||||
]
|
||||
.map(
|
||||
([t, l]) =>
|
||||
`<button class="win-btn kr-tab2 ${t === tab ? "primary" : ""}" data-t="${t}">${l}</button>`,
|
||||
)
|
||||
.join("");
|
||||
const body = !m.length
|
||||
? "<div>표 없음 — 공사를 불러오거나 기준 JB_R 을 엶</div>"
|
||||
: tab === "annex"
|
||||
? annexGrid(m)
|
||||
: tab === "raw"
|
||||
? rawGrid(m)
|
||||
: scaleGrid(m);
|
||||
return `<div class="win-form"><div class="win-form-header"><span>원가계산 제비율표 [TWM_KAN_RATE · ${esc(src)} ${m.length}행×${m[0] ? m[0].length : 0}열]</span></div>
|
||||
<div class="win-subbar"><label>표</label><select id="kr-src" class="win-select">${srcOpts}</select>
|
||||
<select id="kr-jbr" class="win-select" style="width:200px">${jbr.map((f) => `<option value="${esc(f.path)}">${esc(f.name)}</option>`).join("")}</select>
|
||||
<button class="win-btn" id="kr-load">기준 JB_R 열기</button><button class="win-btn" id="kr-back">제잡비 계산으로</button></div>
|
||||
${lookupPanel(ctx, m)}<div class="win-subbar">${tabs}</div><div style="overflow:auto;max-height:520px">${body}</div></div>`;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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 `
|
||||
<div class="win-form">
|
||||
<div class="win-form-header"><span>수량산출서 [wQtyList · QCODE.DBF ${qrows.length}건]</span><span>${ctx.dbfData ? "" : "프로젝트를 먼저 여세요"}</span></div>
|
||||
<div class="win-subbar">${TABS.map(([k, l]) => `<button class="win-btn qty-tab ${tab === k ? "active" : ""}" data-tab="${k}">${l}</button>`).join("")}
|
||||
<button class="win-btn" id="qty-file-save" style="margin-left:auto;">수량산출서 파일로 저장</button>
|
||||
<button class="win-btn" id="qty-file-load">수량산출파일 불러오기</button><input type="file" id="qty-file-input" accept=".json,.stq" style="display:none">
|
||||
<button class="win-btn" id="qty-file-reset">수량산출파일 초기화</button></div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 탭1 — 수량코드(기본 수량변수)
|
||||
function renderList(ctx, app, qrows) {
|
||||
const u = ctx.uiState;
|
||||
const sel = u.qtyQSel ?? -1;
|
||||
const f = (r, k) =>
|
||||
`<input class="cell-input num qty-q" data-i="${qrows.indexOf(r)}" data-k="${k}" value="${esc(String(r[k] ?? "").replace(/\.0+$/, ""))}" style="width:62px">`;
|
||||
const errs = u.qtyCheck || [];
|
||||
return `
|
||||
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
|
||||
<button class="win-btn" id="qty-q-add">수량코드 추가</button><button class="win-btn" id="qty-q-del">삭제</button>
|
||||
<button class="win-btn" id="qty-q-check">수량코드 검사</button>
|
||||
<button class="win-btn" id="qty-q-assign">확장수량변수 → 기본수량변수 할당</button>
|
||||
<span>전송</span><select id="qty-q-field">${Array.from({ length: 10 }, (_v, k) => `<option ${(u.qtyQField || "QTY1") === `QTY${k}` ? "selected" : ""}>QTY${k}</option>`).join("")}</select>
|
||||
<span>→ 내역</span><select id="qty-q-dest"><option value="QTY1">당초 수량(QTY1)</option><option value="QTY2" ${u.qtyQDest === "QTY2" ? "selected" : ""}>변경 수량(QTY2)</option></select>
|
||||
<button class="win-btn" id="qty-q-trans">기본 수량변수 전송</button>
|
||||
<button class="win-btn primary" id="qty-q-save" style="margin-left:auto;">수량코드 저장(STC)</button>
|
||||
</div>
|
||||
${errs.length ? `<div style="color:#b91c1c;font-size:12px;padding:2px 8px;max-height:80px;overflow:auto;">${errs.map(esc).join("<br>")}</div>` : u.qtyCheck ? `<div style="color:#15803d;font-size:12px;padding:2px 8px;">검사 통과</div>` : ""}
|
||||
<div class="split-horiz">
|
||||
<div class="pane-box" style="flex:1;"><div class="grid-scroll"><table class="dbgrid">
|
||||
<thead><tr><th style="width:24px"></th><th style="width:66px">수량코드</th><th>명칭</th><th style="width:80px">내역코드</th>${Array.from({ length: 10 }, (_v, k) => `<th style="width:66px">QTY${k}</th>`).join("")}</tr></thead>
|
||||
<tbody>${qrows
|
||||
.map(
|
||||
(
|
||||
r,
|
||||
i,
|
||||
) => `<tr class="${i === sel ? "selected" : ""}"><td class="center"><input type="radio" name="qty-q-sel" class="qty-q-sel" data-i="${i}" ${i === sel ? "checked" : ""}></td>
|
||||
<td><input class="cell-input qty-q" data-i="${i}" data-k="CODE" value="${esc(r.CODE)}" style="width:60px"></td>
|
||||
<td><input class="cell-input qty-q" data-i="${i}" data-k="NAME" value="${esc(r.NAME)}" style="width:100%"></td>
|
||||
<td><input class="cell-input qty-q" data-i="${i}" data-k="BQCODE" value="${esc(r.BQCODE)}" style="width:74px"></td>
|
||||
${Array.from({ length: 10 }, (_v, k) => `<td>${f(r, `QTY${k}`)}</td>`).join("")}</tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div></div>
|
||||
${
|
||||
sel >= 0 && qrows[sel]
|
||||
? `<div class="pane-box" style="width:300px;">
|
||||
<div class="pane-title"><span>${esc(qrows[sel].CODE)} 연결식(IFORM · QTYn=셀별명 식)</span></div>
|
||||
<textarea class="smt-editor-area qty-q-memo" data-k="IFORM" style="height:110px">${esc(qrows[sel].IFORM || "")}</textarea>
|
||||
<div class="pane-title"><span>확장 수량변수(XCODE · 이름=값)</span></div>
|
||||
<textarea class="smt-editor-area qty-q-memo" data-k="XCODE" style="height:150px">${esc(qrows[sel].XCODE || "")}</textarea>
|
||||
<div class="pane-title"><span>수량변수 산근에 전송 — 산출근거에 넣을 글</span></div>
|
||||
<div style="font-family:Consolas,monospace;font-size:12px;">${varTokens(qrows[sel]).map(esc).join("<br>") || "-"}</div>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
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) =>
|
||||
`<select class="qty-role" data-k="${k}"><option value="">-</option>${Array.from({ length: sheet.cols }, (_v, c) => `<option ${sheet.roles[k] === S().colName(c) ? "selected" : ""}>${S().colName(c)}</option>`).join("")}</select>`;
|
||||
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 `
|
||||
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
|
||||
<span>셀 <strong>${esc(cur)}</strong></span><input id="qty-cell-edit" value="${esc(sheet.cells[cur] ?? "")}" style="width:240px" placeholder="값 또는 =식(A1·셀별명·함수)">
|
||||
<input id="qty-alias" placeholder="셀별명" style="width:90px" value="${esc(aliasAt[cur] || "")}"><button class="win-btn" id="qty-alias-set">셀별명 설정</button>
|
||||
<button class="win-btn" id="qty-row-add">행 추가</button><button class="win-btn" id="qty-col-add">컬럼삽입</button>
|
||||
<span>시작줄</span><input id="qty-start" type="number" style="width:50px" value="${sheet.start + 1}"><span>끝줄</span><input id="qty-end" type="number" style="width:50px" value="${sheet.end + 1}">
|
||||
${ROLES.map(([k, l]) => `<label>${l} ${roleOpt(k)}</label>`).join("")}
|
||||
<button class="win-btn primary" id="qty-to-boq-code">내역서로 가져가기(코드)</button>
|
||||
<button class="win-btn primary" id="qty-to-boq-name">내역서로 가져가기(명칭)</button>
|
||||
</div>
|
||||
<div class="split-horiz">
|
||||
<div class="pane-box" style="flex:1;"><div class="grid-scroll"><table class="dbgrid qty-sheet">
|
||||
<thead><tr><th style="width:34px"></th>${Array.from(
|
||||
{ length: sheet.cols },
|
||||
(_v, c) =>
|
||||
`<th>${S().colName(c)}${Object.entries(sheet.roles)
|
||||
.filter(([, v]) => v === S().colName(c))
|
||||
.map(([k]) => ` (${(ROLES.find((x) => x[0] === k) || [])[1]})`)
|
||||
.join("")}</th>`,
|
||||
).join("")}</tr></thead>
|
||||
<tbody>${Array.from({ length: sheet.rows }, (_v, r) => {
|
||||
const inRange =
|
||||
r >= Math.min(sheet.start, sheet.end) && r <= Math.max(sheet.start, sheet.end);
|
||||
return `<tr><td class="center" style="${inRange ? "background:#e0f2fe;" : ""}">${r + 1}</td>${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 `<td class="qty-cell ${a === cur ? "selected" : ""}" data-a="${a}" title="${esc(sheet.cells[a] ?? "")}${ev.errors[a] ? " — " + esc(ev.errors[a]) : ""}" style="cursor:cell;${ev.errors[a] ? "background:#fee2e2;" : ""}">${aliasAt[a] ? `<sup style="color:#1d4ed8">${esc(aliasAt[a])}</sup> ` : ""}${esc(shown)}</td>`;
|
||||
},
|
||||
).join("")}</tr>`;
|
||||
}).join("")}</tbody></table></div></div>
|
||||
<div class="pane-box" style="width:270px;">
|
||||
<div class="pane-title"><span>변수셀 찾기(셀별명)</span></div>
|
||||
<div style="max-height:150px;overflow:auto;font-size:12px;">${
|
||||
Object.entries(sheet.aliases)
|
||||
.map(
|
||||
([n, a]) =>
|
||||
`<div class="qty-goto" data-a="${esc(a)}" style="cursor:pointer;">${esc(n)} = ${esc(a)} → ${app.fmt(ev.aliasValues[n.toUpperCase()] || 0, 3)} <a class="qty-alias-del" data-n="${esc(n)}" style="color:#b91c1c;">×</a></div>`,
|
||||
)
|
||||
.join("") || "-"
|
||||
}</div>
|
||||
<div class="pane-title"><span>내역코드란에 단가코드 입력</span></div>
|
||||
<select id="qty-pick-kind">${S()
|
||||
.KINDS.map(
|
||||
([k, l]) => `<option value="${k}" ${k === kind ? "selected" : ""}>${l}</option>`,
|
||||
)
|
||||
.join("")}</select>
|
||||
<label style="font-size:12px;"><input type="checkbox" id="qty-pick-name" ${u.qtyPickName ? "checked" : ""}> 코드우측에 명칭/규격/단위 입력</label>
|
||||
<div style="max-height:260px;overflow:auto;font-size:12px;">${picks.map((r) => `<div class="qty-pick" data-c="${esc(String(r.CODE).trim())}" style="cursor:pointer;">${esc(String(r.CODE).trim())} ${esc(r.NAME)} ${esc(r.SIZE)}</div>`).join("")}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 탭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 `
|
||||
<div class="win-subbar"><select id="qty-code-kind">${S()
|
||||
.KINDS.map(([k, l]) => `<option value="${k}" ${k === kind ? "selected" : ""}>${l}</option>`)
|
||||
.join("")}</select>
|
||||
<select id="qty-code-field"><option value="QTY1">당초 수량</option><option value="QTY2" ${field === "QTY2" ? "selected" : ""}>변경 수량</option></select>
|
||||
${roll.cycles.length ? `<span style="color:#b91c1c;">순환참조 ${roll.cycles.length}건 — ${esc(roll.cycles[0])}</span>` : ""}</div>
|
||||
<div class="split-horiz">
|
||||
<div class="pane-box" style="flex:1;"><div class="pane-title"><span>내역 코드별 수량 ${list.length}건</span></div><div class="grid-scroll"><table class="dbgrid">
|
||||
<thead><tr><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>내역 행</th><th>수량 계</th></tr></thead>
|
||||
<tbody>${list.map((o) => `<tr><td class="center">${esc(o.code)}</td><td>${esc(o.name)}</td><td>${esc(o.size)}</td><td class="center">${esc(o.unit)}</td><td class="num">${o.rows}</td><td class="num">${app.fmt(o.qty, 3)}</td></tr>`).join("")}</tbody></table></div></div>
|
||||
<div class="pane-box" style="flex:1;"><div class="pane-title"><span>내역↔BDQTY 수량 연결(자원 소요량) ${res.length}건</span></div><div class="grid-scroll"><table class="dbgrid">
|
||||
<thead><tr><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>소요 수량</th></tr></thead>
|
||||
<tbody>${res.map(([c, q]) => `<tr><td class="center">${esc(c)}</td><td>${esc((by[c] || {}).NAME)}</td><td>${esc((by[c] || {}).SIZE)}</td><td class="center">${esc((by[c] || {}).UNIT)}</td><td class="num">${app.fmt(q, 4)}</td></tr>`).join("")}</tbody></table></div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 내역 반영 — 코드가 같은 행 수량 바꿈 · 없으면 끝에 새 행(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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(`<script src="${f}"></script>`));
|
||||
|
||||
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, "<br>");
|
||||
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 `<tr class="rp-page"><td colspan="${maxC}"></td></tr>`;
|
||||
const tds = cols
|
||||
.map((c) => {
|
||||
const v = r[c];
|
||||
const num = typeof v === "number";
|
||||
return `<td style="${num ? "text-align:right" : ""}">${v == null ? "" : fmtCell(v)}</td>`;
|
||||
})
|
||||
.join("");
|
||||
const bold = /^(title|head|T\d|sub|total|final|hopyo|parent)$/.test(r._k || "");
|
||||
return `<tr style="${bold ? "font-weight:bold" : ""}${r._k === "head" ? ";background:#eef3f8" : ""}">${tds}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<table class="rp-table" style="border-collapse:collapse;font-size:12px" border="1">${body}</table>`;
|
||||
}
|
||||
|
||||
P.screens.PM_Prt = {
|
||||
render(ctx) {
|
||||
if (!ctx.dbfData)
|
||||
return `<div class="pane"><div class="pane-title">보고서 출력</div><p>공사를 먼저 불러오십시오.</p></div>`;
|
||||
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 `<div class="rp-form${on && id === ui.reportSheet ? " active" : ""}" data-rp-sheet="${on ? esc(id) : ""}" style="padding:2px 6px;cursor:${on ? "pointer" : "default"};color:${on ? "inherit" : "#999"}">${esc(r.INDX)} ${esc(r.NAME)}${on ? "" : " · 미구현"}</div>`;
|
||||
})
|
||||
.join("");
|
||||
const extra = ["〓 목 차 〓", "※※안내※※"]
|
||||
.filter((id) => ids.includes(id))
|
||||
.map(
|
||||
(id) =>
|
||||
`<div class="rp-form" data-rp-sheet="${esc(id)}" style="padding:2px 6px;cursor:pointer">${esc(id)}</div>`,
|
||||
)
|
||||
.join("");
|
||||
const stOpts = stages(ctx)
|
||||
.map(
|
||||
(n) =>
|
||||
`<option value="${n}" ${Number(ui.reportStage || 1) === n ? "selected" : ""}>${n === 1 ? "당초(1차수)" : `변경 ${n}차수`}</option>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div class="pane" id="rp-root">
|
||||
<div class="pane-title"><span>보고서 출력 · 미리보기 (SET_REP 서식 ${forms ? (ctx.dbfData.set_rep_rows || []).length : 0}종)</span></div>
|
||||
<div style="display:flex;gap:6px;align-items:center;margin:4px 0;flex-wrap:wrap">
|
||||
<label>차수 <select id="rp-stage">${stOpts}</select></label>
|
||||
<button class="win-btn" id="rp-print">인쇄</button>
|
||||
<button class="win-btn" id="rp-xlsx-all">엑셀 내보내기(전체)</button>
|
||||
<button class="win-btn" id="rp-xlsx-one">엑셀(이 시트)</button>
|
||||
<button class="win-btn" id="rp-csv">CSV(이 시트)</button>
|
||||
<button class="win-btn" id="rp-pps">조달청 XML</button>
|
||||
<span id="rp-msg"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start">
|
||||
<div style="width:240px;max-height:70vh;overflow:auto;border:1px solid #ccc">${extra}${forms}</div>
|
||||
<div id="rp-preview" style="flex:1;max-height:70vh;overflow:auto">
|
||||
<h3 style="margin:2px 0">${esc(cur ? cur.title : "")}</h3>${cur ? sheetHtml(cur) : ""}
|
||||
</div>
|
||||
</div></div>`;
|
||||
},
|
||||
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(
|
||||
`<html><head><title>${esc(sh.title)}</title><style>@page{size:A4 ${sh.rows.some((r) => r.M != null) ? "landscape" : "portrait"};margin:12mm}table{border-collapse:collapse;font-size:10pt}td{border:1px solid #888;padding:1px 4px}.rp-page{page-break-after:always}</style></head><body><h2 style="text-align:center">${esc(sh.title)}</h2>${sheetHtml(sh)}</body></html>`,
|
||||
);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
w.print();
|
||||
};
|
||||
},
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
+153
-33
@@ -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) =>
|
||||
`<input class="cell-input boq-cell ${extra || ""}" data-recno="${rec}" data-f="${f}" value="${esc(v)}" />`;
|
||||
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 `<tr class="${cls}" data-recno="${rec}">
|
||||
<td class="center rowhead" data-recno="${rec}">${r._idx + 1}</td>
|
||||
@@ -182,8 +251,13 @@
|
||||
)
|
||||
.join("")}</tbody></table>`
|
||||
: `<p class="stm-note">보관함 (${n}) 비어 있음 — 행을 고르고 [보관함 › 선택 행 보관함에 담기]</p>`;
|
||||
return `<div class="pane-box" style="width:250px;"><div class="pane-title"><span>내역보관함 (${n})</span><span>${[1, 2, 3]
|
||||
.map((k) => `<button class="win-btn boq-pocket-tab ${k === n ? "active" : ""}" data-pocket="${k}">(${k})</button>`)
|
||||
return `<div class="pane-box" style="width:250px;"><div class="pane-title"><span>내역보관함 (${n})</span><span>${[
|
||||
1, 2, 3,
|
||||
]
|
||||
.map(
|
||||
(k) =>
|
||||
`<button class="win-btn boq-pocket-tab ${k === n ? "active" : ""}" data-pocket="${k}">(${k})</button>`,
|
||||
)
|
||||
.join("")}</span></div><div class="grid-scroll" style="padding:6px;">${body}</div></div>`;
|
||||
}
|
||||
|
||||
@@ -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 `<div class="win-form" id="boq-form">
|
||||
<div class="win-form-header"><span>설계내역서 [wBoq · ${esc(ctx.dbfData.boq_table_name || "")}] — 전체 ${all.length}행 · 표시 ${rows.length}행</span><span>직공비 ${api.fmt(bt.directT)}원</span></div>
|
||||
${menuBar()}
|
||||
@@ -218,11 +299,14 @@
|
||||
<div class="pane-box" style="flex:1;"><div class="grid-scroll" id="boq-grid" tabindex="0"><table class="dbgrid"><thead>
|
||||
<tr><th rowspan="2">No</th><th rowspan="2">${cap("CAP_ITNUM", "공종")}</th><th rowspan="2">${cap("CAP_CODE", "코드번호")}</th><th rowspan="2">${cap("CAP_NAME", "명칭")}</th><th rowspan="2">${cap("CAP_SIZE", "규격")}</th><th rowspan="2">${cap("CAP_QTY", "수량")}</th><th rowspan="2">${cap("CAP_SUNIT", "단위")}</th>
|
||||
${["BMK_STR1", "BMK_STR2", "BMK_STR3", "BMK_STR4"].map((k, i) => `<th colspan="2">${cap(k, ["재료비", "노무비", "경비", "합계"][i])}</th>`).join("")}<th rowspan="2">${cap("CAP_BIGO", "비고")}</th></tr>
|
||||
<tr>${"1234".split("").map(() => `<th>${cap("CAP_AMT", "단가")}</th><th>${cap("CAP_AMOUNT", "금액")}</th>`).join("")}</tr>
|
||||
<tr>${"1234"
|
||||
.split("")
|
||||
.map(() => `<th>${cap("CAP_AMT", "단가")}</th><th>${cap("CAP_AMOUNT", "금액")}</th>`)
|
||||
.join("")}</tr>
|
||||
</thead><tbody>${rows.map((r) => rowHtml(r, ctx, api, selSet)).join("")}</tbody></table></div></div>
|
||||
${pocketPane(ctx)}
|
||||
</div>
|
||||
<div class="win-subbar"><span>선택 ${selSet.size}행</span><span>· 새 행 ${newN} · 지운 원본 행 ${delN}</span><span class="stm-note">${esc(u.boqMsg || "")}</span><span class="stm-note" style="margin-left:auto;">행 추가·삭제·차례는 화면·계산에 바로 반영 · 파일 저장은 저장 묶음 boq_layout(서버 반영 대기)</span></div>
|
||||
<div class="win-subbar"><span>선택 ${selSet.size}행</span><span>· 새 행 ${newN} · 지운 원본 행 ${delN}</span><span class="stm-note">${esc(u.boqMsg || "")}</span><span class="stm-note" style="margin-left:auto;">행 추가·삭제·차례는 화면·계산에 바로 반영 · [계산 완료] 저장 때 파일에 씀</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]) => `<option value="${esc(v)}" ${String(v) === String(cur) ? "selected" : ""}>${esc(t)}</option>`).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]) =>
|
||||
`<option value="${esc(v)}" ${String(v) === String(cur) ? "selected" : ""}>${esc(t)}</option>`,
|
||||
)
|
||||
.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)",
|
||||
`<div class="stm-form"><div class="stm-row"><select id="kj-kind" class="win-select">${opt(KINDS, st.kind)}</select>
|
||||
<select id="kj-sort" class="win-select">${opt([["C", "코드번호 순"], ["N", "명 칭 순"], ["I", "분류번호 순"]], st.sort)}</select>
|
||||
<select id="kj-sort" class="win-select">${opt(
|
||||
[
|
||||
["C", "코드번호 순"],
|
||||
["N", "명 칭 순"],
|
||||
["I", "분류번호 순"],
|
||||
],
|
||||
st.sort,
|
||||
)}</select>
|
||||
<input id="kj-kw" class="win-input" placeholder="명칭·규격·코드" value="${esc(st.kw)}" /><span id="kj-count"></span></div>
|
||||
<div class="grid-scroll" style="max-height:340px;"><table class="dbgrid"><thead><tr><th></th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th></tr></thead><tbody id="kj-list"></tbody></table></div>
|
||||
<div class="stm-row"><label>수량</label><input id="kj-qty" class="win-input" type="number" value="0" style="width:90px;" />
|
||||
@@ -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)",
|
||||
`<div class="stm-form">
|
||||
<div class="stm-row"><label>대상(cBx_OnlyIT)</label><select id="it-target" class="win-select">${opt([["group", "공종(계표시)에만 구분적용."], ["leaf", "단가코드에만 구분적용."], ["both", "공종(계표시) / 단가코드 모두적용."]], "group")}</select>
|
||||
<div class="stm-row"><label>대상(cBx_OnlyIT)</label><select id="it-target" class="win-select">${opt(
|
||||
[
|
||||
["group", "공종(계표시)에만 구분적용."],
|
||||
["leaf", "단가코드에만 구분적용."],
|
||||
["both", "공종(계표시) / 단가코드 모두적용."],
|
||||
],
|
||||
"group",
|
||||
)}</select>
|
||||
<select id="it-level" class="win-select">${opt(LEVELS(ini), 2)}</select></div>
|
||||
<div class="stm-row"><label>범위</label><select id="it-range" class="win-select">${opt([["all", "내역서 전체"], ["sel", "선택된 항목만"]], "all")}</select></div>
|
||||
<div class="stm-row"><label>첫 글자(RdoFirstChar)</label><select id="it-first" class="win-select">${opt([["", "공란처리"], [".", ". 입력"], ["(", "( 입력"], ["-", "- 입력"], ["custom", "추가입력"]], "")}</select><input id="it-first-text" class="win-input" style="width:60px;" placeholder="추가입력" /></div>
|
||||
<div class="stm-row"><label>끝 글자(RdoLastChar)</label><select id="it-last" class="win-select">${opt([["", "공란처리"], [".", ". 입력"], [")", ") 입력"]], ".")}</select></div>
|
||||
<div class="stm-row"><label>번호형식</label><select id="it-type" class="win-select">${opt([["num", "숫자"], ["ko", "가..하,거..허 ~ 그..흐,기..히 (140개)"], ["en", "a .. z,a1..z1 ~ a9..z9 (260개)"]], "num")}</select>
|
||||
<select id="it-pad" class="win-select">${opt([[1, "0"], [2, "00"], [3, "000"]], 1)}</select>
|
||||
<select id="it-margin" class="win-select">${opt([0, 1, 2, 3, 4, 5, 6, 7].map((n) => [n, `여백 ${n} Byte`]), 0)}</select></div>
|
||||
<div class="stm-row"><label>앞 번호(cBx_itNum_B)</label><select id="it-prefix" class="win-select">${opt([["none", "붙이지 않음"], ["parent", "『직전공종(계표시)의 공종번호』"], ["space", "『직전공종길이 여백으로 채움』"]], "none")}</select></div>
|
||||
<div class="stm-row"><label>범위</label><select id="it-range" class="win-select">${opt(
|
||||
[
|
||||
["all", "내역서 전체"],
|
||||
["sel", "선택된 항목만"],
|
||||
],
|
||||
"all",
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>첫 글자(RdoFirstChar)</label><select id="it-first" class="win-select">${opt(
|
||||
[
|
||||
["", "공란처리"],
|
||||
[".", ". 입력"],
|
||||
["(", "( 입력"],
|
||||
["-", "- 입력"],
|
||||
["custom", "추가입력"],
|
||||
],
|
||||
"",
|
||||
)}</select><input id="it-first-text" class="win-input" style="width:60px;" placeholder="추가입력" /></div>
|
||||
<div class="stm-row"><label>끝 글자(RdoLastChar)</label><select id="it-last" class="win-select">${opt(
|
||||
[
|
||||
["", "공란처리"],
|
||||
[".", ". 입력"],
|
||||
[")", ") 입력"],
|
||||
],
|
||||
".",
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>번호형식</label><select id="it-type" class="win-select">${opt(
|
||||
[
|
||||
["num", "숫자"],
|
||||
["ko", "가..하,거..허 ~ 그..흐,기..히 (140개)"],
|
||||
["en", "a .. z,a1..z1 ~ a9..z9 (260개)"],
|
||||
],
|
||||
"num",
|
||||
)}</select>
|
||||
<select id="it-pad" class="win-select">${opt(
|
||||
[
|
||||
[1, "0"],
|
||||
[2, "00"],
|
||||
[3, "000"],
|
||||
],
|
||||
1,
|
||||
)}</select>
|
||||
<select id="it-margin" class="win-select">${opt(
|
||||
[0, 1, 2, 3, 4, 5, 6, 7].map((n) => [n, `여백 ${n} Byte`]),
|
||||
0,
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>앞 번호(cBx_itNum_B)</label><select id="it-prefix" class="win-select">${opt(
|
||||
[
|
||||
["none", "붙이지 않음"],
|
||||
["parent", "『직전공종(계표시)의 공종번호』"],
|
||||
["space", "『직전공종길이 여백으로 채움』"],
|
||||
],
|
||||
"none",
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>번호 초기화(cBx_Bl_Lev)</label><select id="it-reset" class="win-select"><option value="">바로 윗 레벨 머리마다</option>${[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `<option value="${n}">${n} 레벨(${esc(B.dispName(ini, n))}) 이상 머리마다</option>`).join("")}<option value="0">초기화 안 함(이어서)</option></select></div>
|
||||
<p class="stm-note">예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 …</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="it-ok">공종번호 입력</button></div></div>`,
|
||||
);
|
||||
$("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)",
|
||||
`<div class="stm-form"><div class="stm-row"><label>방식(Rdo)</label><select id="br-mode" class="win-select">${opt([["all", "전체(재,노,경 같은 비율)"], ["each", "비목별(재,노,경 각각)"]], "all")}</select></div>
|
||||
`<div class="stm-form"><div class="stm-row"><label>방식(Rdo)</label><select id="br-mode" class="win-select">${opt(
|
||||
[
|
||||
["all", "전체(재,노,경 같은 비율)"],
|
||||
["each", "비목별(재,노,경 각각)"],
|
||||
],
|
||||
"all",
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>전체 할증율(%)</label><input id="br-all" type="number" class="win-input" value="0" /></div>
|
||||
<div class="stm-row"><label>재료비 · 노무비 · 경비(%)</label><input id="br-m" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-l" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-s" type="number" class="win-input" value="0" style="width:70px;" /></div>
|
||||
<div class="stm-row"><label>범위</label><select id="br-range" class="win-select">${opt([["sel", "선택된 항목만"], ["all", "내역서 전체"]], "sel")}</select></div>
|
||||
<div class="stm-row"><label>범위</label><select id="br-range" class="win-select">${opt(
|
||||
[
|
||||
["sel", "선택된 항목만"],
|
||||
["all", "내역서 전체"],
|
||||
],
|
||||
"sel",
|
||||
)}</select></div>
|
||||
<p class="stm-warn">내역 행 RATE·RM·RL·RS 칸과 비고에 기록 — 금액 반영은 계산 엔진(calc.js calcBoq)의 할증 처리 대기</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="br-ok">적용</button></div></div>`,
|
||||
);
|
||||
$("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)",
|
||||
`<div class="stm-form"><div class="stm-row"><label>수량 범위(rd_Qty1)</label><select id="mr-range" class="win-select">${opt([["all", "내역서 전체"], ["sel", "선택된 항목만"]], "all")}</select></div>
|
||||
<div class="stm-row"><label>처리(wcBx_Pnt1)</label><select id="mr-mode" class="win-select">${opt([["trunc", "0 절사하여"], ["round", "1 반올림 하여"]], "trunc")}</select>
|
||||
<select id="mr-dot" class="win-select">${opt([0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]), 2)}</select></div>
|
||||
`<div class="stm-form"><div class="stm-row"><label>수량 범위(rd_Qty1)</label><select id="mr-range" class="win-select">${opt(
|
||||
[
|
||||
["all", "내역서 전체"],
|
||||
["sel", "선택된 항목만"],
|
||||
],
|
||||
"all",
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>처리(wcBx_Pnt1)</label><select id="mr-mode" class="win-select">${opt(
|
||||
[
|
||||
["trunc", "0 절사하여"],
|
||||
["round", "1 반올림 하여"],
|
||||
],
|
||||
"trunc",
|
||||
)}</select>
|
||||
<select id="mr-dot" class="win-select">${opt(
|
||||
[0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]),
|
||||
2,
|
||||
)}</select></div>
|
||||
<p class="stm-warn">비목별 금액 보정(wcBx_Pnt1_M·_L·_X·_W, rd_Amt1·rd_Amt2)은 내역 행 단가 고정 칸이 계산 엔진에 없어 대기</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="mr-ok">수량 보정</button></div></div>`,
|
||||
);
|
||||
@@ -199,16 +327,41 @@
|
||||
api.openModal(
|
||||
"일위대가표 풀기 (wExpnd)",
|
||||
`<div class="stm-form"><p>${esc(B.codeOf(r))} ${esc(r.NAME1)} ${esc(r.SIZE1)} — 수량 ${B.num(r.QTY1)} ${esc(r.UNIT1)}</p>
|
||||
<div class="stm-row"><label>집계(RGrpToT)</label><select id="ex-lv" class="win-select">${opt([[1, "소계(=)"], [2, "계(==)"], [3, "합계(===)"]], 1)}</select></div>
|
||||
<div class="stm-row"><label>수량 끝전</label><select id="ex-mode" class="win-select">${opt([["trunc", "절삭하여"], ["round", "반올림하여"]], "trunc")}</select><select id="ex-dot" class="win-select">${opt(DOTS, 4)}</select></div>
|
||||
<div class="stm-row"><label>집계(RGrpToT)</label><select id="ex-lv" class="win-select">${opt(
|
||||
[
|
||||
[1, "소계(=)"],
|
||||
[2, "계(==)"],
|
||||
[3, "합계(===)"],
|
||||
],
|
||||
1,
|
||||
)}</select></div>
|
||||
<div class="stm-row"><label>수량 끝전</label><select id="ex-mode" class="win-select">${opt(
|
||||
[
|
||||
["trunc", "절삭하여"],
|
||||
["round", "반올림하여"],
|
||||
],
|
||||
"trunc",
|
||||
)}</select><select id="ex-dot" class="win-select">${opt(DOTS, 4)}</select></div>
|
||||
<p class="stm-note">구성 수량 = 구성표(BDQTY) 수량 × 내역 수량 · 비율(PR)·%단위·구분 줄은 풀지 않음</p>
|
||||
<div class="stm-row"><button class="win-btn primary" id="ex-ok">풀기</button></div></div>`,
|
||||
);
|
||||
$("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 · 계표시 확인)",
|
||||
`<div class="stm-form"><div class="stm-row"><select id="mb-lv" class="win-select">${opt([1, 2, 3, 4, 5, 6].map((n) => [n, `${n}레벨(${B.dispName(ini, n)}) 이상`]), 1)}</select><span class="stm-note">줄을 두 번 누르면 내역서의 그 머리로 이동</span></div>
|
||||
`<div class="stm-form"><div class="stm-row"><select id="mb-lv" class="win-select">${opt(
|
||||
[1, 2, 3, 4, 5, 6].map((n) => [n, `${n}레벨(${B.dispName(ini, n)}) 이상`]),
|
||||
1,
|
||||
)}</select><span class="stm-note">줄을 두 번 누르면 내역서의 그 머리로 이동</span></div>
|
||||
<div class="grid-scroll" style="max-height:420px;"><table class="dbgrid"><thead><tr><th>레벨</th><th>공종</th><th>명칭</th><th>재료비</th><th>노무비</th><th>경비</th><th>합계</th><th>닫는 행</th></tr></thead><tbody id="mb-list"></tbody></table></div></div>`,
|
||||
);
|
||||
draw(1);
|
||||
|
||||
@@ -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 `<td${click}>${v ? fmt(v) : ""}${pg[n - 1] ? `<br/><small>p.${esc(pg[n - 1])}</small>` : ""}</td>`;
|
||||
})
|
||||
.join("")}
|
||||
@@ -79,7 +101,14 @@
|
||||
<label>코드종류(cBoxCode)</label><select id="dan-kind" class="win-select">${U.KINDS.map(([k, t]) => `<option value="${k}" ${s.kind === k ? "selected" : ""}>${t} (${cnt[k]})</option>`).join("")}</select>
|
||||
<select id="dan-mode" class="win-select"><option value="name">명칭,규격 으로검색</option><option value="memo" ${s.mode === "memo" ? "selected" : ""}>산근내용 으로검색</option></select>
|
||||
<input id="dan-kw" class="win-input" value="${esc(s.kw)}" placeholder="찾을 글자" style="width:150px;" />
|
||||
<select id="dan-sort" class="win-select">${[["C", "코드번호순"], ["N", "명 칭 순"], ["I", "분류번호순"], ["E", "시설코드순"]].map(([k, t]) => `<option value="${k}" ${sort === k ? "selected" : ""}>${t}</option>`).join("")}</select>
|
||||
<select id="dan-sort" class="win-select">${[
|
||||
["C", "코드번호순"],
|
||||
["N", "명 칭 순"],
|
||||
["I", "분류번호순"],
|
||||
["E", "시설코드순"],
|
||||
]
|
||||
.map(([k, t]) => `<option value="${k}" ${sort === k ? "selected" : ""}>${t}</option>`)
|
||||
.join("")}</select>
|
||||
<button class="win-btn" id="dan-boxa">가격지 비교·채택 (wM_Boxa)</button>
|
||||
<button class="win-btn" id="dan-edthal">기준단가 수정 (wM_Edt_Hal)</button>
|
||||
<button class="win-btn" id="dan-use" ${s.code ? "" : "disabled"}>사용처 찾기${s.code ? ` (${esc(s.code)})` : ""}</button>
|
||||
@@ -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 };
|
||||
|
||||
+157
-34
@@ -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 @@
|
||||
<div class="win-form-header"><span>프로젝트 관리 [ 열기, 등록, 백업, 삭제 ]</span><span>${src.length}개</span></div>
|
||||
<div class="win-subbar"><button class="win-btn primary" id="mgr-open" ${ctx.uiState.mgrSel ? "" : "disabled"}>열기</button>
|
||||
<button class="win-btn" id="mgr-back" ${ctx.dbfData ? "" : "disabled"}>현재 프로젝트 백업</button>
|
||||
<button class="win-btn" disabled title="새 프로젝트 폴더 만들기는 서버(파일) 기능 대기">등록</button>
|
||||
<button class="win-btn" data-file-act="new" ${ctx.dbfData ? "" : "disabled"} title="지금 프로젝트의 요율·서식·설정을 틀로 빈 공사 등록">등록(새 공사)</button>
|
||||
<button class="win-btn" data-file-act="backups" ${ctx.dbfData ? "" : "disabled"}>백업·복원</button>
|
||||
<button class="win-btn" data-file-act="check" ${ctx.dbfData ? "" : "disabled"}>STC 검사</button>
|
||||
<button class="win-btn" disabled title="프로젝트 지우기는 서버(파일) 기능 대기">삭제</button>
|
||||
<span class="stm-note">열린 프로젝트: ${esc(ctx.currentSourcePath || "(없음)")}</span></div>
|
||||
<div class="split-vert"><div class="pane-box" style="flex:1;"><div class="grid-scroll"><table class="dbgrid"><thead><tr><th>No</th><th>구분</th><th>프로젝트</th><th>경로</th></tr></thead><tbody>
|
||||
@@ -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 @@
|
||||
<div class="stm-row"><button class="win-btn primary" id="pm-ok">확인</button></div></div>`,
|
||||
);
|
||||
$("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]
|
||||
? `<button class="${cls}" data-file-act="${FILE_ACT[a]}">${t}</button>`
|
||||
: `<button class="${cls}" data-prj-act="${a}" ${enabled(a) ? "" : `disabled title="${DISABLED}"`}>${t}</button>`;
|
||||
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 = `<div class="sub-menubar">${PRJ_MENU.map(
|
||||
([cap, items]) =>
|
||||
`<div class="menu-item"><span>${cap}</span><div class="menu-dropdown">${items
|
||||
.map((x) => (x ? `<button data-prj-act="${x[0]}" ${enabled(x[0]) ? "" : `disabled title="${DISABLED}"`}>${esc(x[1])}</button>` : "<hr />"))
|
||||
.map((x) => (x ? actBtn(x[0], esc(x[1]), "") : "<hr />"))
|
||||
.join("")}</div></div>`,
|
||||
).join("")}</div>`;
|
||||
const btn = (a, t) => `<button class="win-btn" data-prj-act="${a}" ${enabled(a) ? "" : `disabled title="${DISABLED}"`}>${t}</button>`;
|
||||
const btn = (a, t) => actBtn(a, t, "win-btn");
|
||||
return `<div class="win-form" id="prj-form">
|
||||
<div class="win-form-header"><span>공사목록표 [wPrjList · PRJLST.DBF] — ${prjRows(ctx).length}건</span><span>${esc(ctx.currentSourcePath)}</span></div>
|
||||
${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 `<tr class="prj-pick ${c === code ? "boq-sel" : ""}" data-code="${esc(c)}"><td>${esc(r.PNUM)}</td><td><strong>${esc(r.PNAME)}</strong></td><td class="center">${esc(c)}${c === act ? " ●" : ""}</td><td class="center">${esc(r.PCLASS)} · 차수 ${esc(r.K_ORD)}</td><td class="num">${fmt(num(r.TCOST1))}</td><td class="num">${fmt(num(r.COSTW1))}</td><td class="center">${num(r.CCODE) ? esc(r.CCODE) : ""}</td><td class="num">${live}</td></tr>`;
|
||||
return `<tr class="prj-pick ${c === code ? "boq-sel" : ""}" data-code="${esc(c)}"><td><input type="radio" name="prj-pcode" value="${esc(c)}" ${c === code ? "checked" : ""} /> ${esc(r.PNUM)}</td><td><strong>${esc(r.PNAME)}</strong></td><td class="center">${esc(c)}${c === act ? " ●" : ""}</td><td class="center">${esc(r.PCLASS)} · 차수 ${esc(r.K_ORD)}</td><td class="num">${fmt(num(r.TCOST1))}</td><td class="num">${fmt(num(r.COSTW1))}</td><td class="center">${num(r.CCODE) ? esc(r.CCODE) : ""}</td><td class="num">${live}</td></tr>`;
|
||||
})
|
||||
.join("")}</tbody></table></div></div>
|
||||
${
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<select class="win-select sys-f" data-k="${k}"${title}>${f.opts
|
||||
.map(([o, t]) => `<option value="${o}" ${String(v) === o ? "selected" : ""}>${esc(t)}</option>`)
|
||||
.join("")}${f.opts.some(([o]) => o === String(v)) ? "" : `<option value="${esc(v)}" selected>(원본 값 ${esc(v) || "없음"})</option>`}</select>`;
|
||||
.map(
|
||||
([o, t]) =>
|
||||
`<option value="${o}" ${String(v) === o ? "selected" : ""}>${esc(t)}</option>`,
|
||||
)
|
||||
.join(
|
||||
"",
|
||||
)}${f.opts.some(([o]) => o === String(v)) ? "" : `<option value="${esc(v)}" selected>(원본 값 ${esc(v) || "없음"})</option>`}</select>`;
|
||||
if (f.type === "codes")
|
||||
return `<input class="win-input sys-f" data-k="${k}" value="${esc(S.splitCodes(v).join(", "))}" style="width:240px;"${title} placeholder="L00008, L00009" />`;
|
||||
return `<input class="win-input sys-f" data-k="${k}" value="${esc(v)}" style="width:${f.type === "num" ? 110 : 240}px;"${title} />`;
|
||||
}
|
||||
|
||||
const row = (f, d, n) => `<div class="stm-row"><label${f.hint ? ` title="${esc(f.hint)}"` : ""}>${esc(f.cap)}</label>${field(f, d, n)}</div>`;
|
||||
const row = (f, d, n) =>
|
||||
`<div class="stm-row"><label${f.hint ? ` title="${esc(f.hint)}"` : ""}>${esc(f.cap)}</label>${field(f, d, n)}</div>`;
|
||||
|
||||
function render(ctx) {
|
||||
if (!ctx.dbfData)
|
||||
@@ -68,9 +81,16 @@
|
||||
</fieldset>
|
||||
<fieldset class="stm-group"><legend>소 수 점 적 용 기 준</legend>${S.FIELDS.digits.map((f) => row(f, d)).join("")}</fieldset>
|
||||
<fieldset class="stm-group"><legend>계 산 방 식</legend>${S.FIELDS.calc.map((f) => row(f, d)).join("")}
|
||||
<div class="stm-row"><label title="계산방식이 【총액 먼저 계산】일 때 총액과 비목별 합계의 차액을 보정순서대로 보정">L 금액 보정순서</label><span class="stm-order">${d.OPTNM.split("")
|
||||
.map((c, i) => `<span>${i + 1}. ${S.ORDER_NAMES[c]} <button class="win-btn sys-opt" data-i="${i}" data-dir="-1">◀</button><button class="win-btn sys-opt" data-i="${i}" data-dir="1">▶</button></span>`)
|
||||
.join("")}</span><button class="win-btn" id="sys-opt-df" title="경비, 재료비, 노무비 순">기 본</button></div>
|
||||
<div class="stm-row"><label title="계산방식이 【총액 먼저 계산】일 때 총액과 비목별 합계의 차액을 보정순서대로 보정">L 금액 보정순서</label><span class="stm-order">${d.OPTNM.split(
|
||||
"",
|
||||
)
|
||||
.map(
|
||||
(c, i) =>
|
||||
`<span>${i + 1}. ${S.ORDER_NAMES[c]} <button class="win-btn sys-opt" data-i="${i}" data-dir="-1">◀</button><button class="win-btn sys-opt" data-i="${i}" data-dir="1">▶</button></span>`,
|
||||
)
|
||||
.join(
|
||||
"",
|
||||
)}</span><button class="win-btn" id="sys-opt-df" title="경비, 재료비, 노무비 순">기 본</button></div>
|
||||
</fieldset>
|
||||
<fieldset class="stm-group"><legend>재료비 적용단가 일괄적용</legend>
|
||||
<div class="stm-row"><label>M. 적용할 물가지</label><select class="win-select" id="sys-selm"><option value="none">적용 안함</option>${slots.list.map((s) => `<option value="${s.slot}">${esc(s.name)}</option>`).join("")}<option value="min">최소 단가</option></select></div>
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "" };
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
+63
-10
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+45
-11
@@ -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]) => {
|
||||
|
||||
@@ -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) => `<button data-act="Act_Open_Recent" data-path="${esc(r.path)}">${esc(r.name)}</button>`)
|
||||
.map(
|
||||
(r) =>
|
||||
`<button data-act="Act_Open_Recent" data-path="${esc(r.path)}">${esc(r.name)}</button>`,
|
||||
)
|
||||
.join("") || '<button disabled title="최근 연 프로젝트 없음">(없음)</button>'
|
||||
: "";
|
||||
out.push(
|
||||
|
||||
+80
-14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user