diff --git a/resources/tester/helper_stmate_ui_code.cjs b/resources/tester/helper_stmate_ui_code.cjs
new file mode 100644
index 000000000..ca6e6d4ec
--- /dev/null
+++ b/resources/tester/helper_stmate_ui_code.cjs
@@ -0,0 +1,349 @@
+// STMATE 재현 앱 코드 관리(ui_code.js)·단가 일괄 손질(ui_code_amt.js) 순수 계산 — stdin = { load, mode }
+// mode "ops" = 신규등록·코드변경·+α·항목교체·삭제를 차례로 한 뒤 저장 요청(buildSavePayload + structPayload)과 사실값
+// mode "direct" = 불러온 자료 그대로 직공비(다시 불러온 작업본 대조용)
+// mode "amt" = 일괄변경·할증·초기화·손료계수·특수문자 수치
+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 UC = require(D + "ui_code.js");
+const UA = require(D + "ui_code_amt.js");
+const UM = require(D + "ui_merge.js");
+const Calc = window.STMATE_CALC;
+
+let buf = "";
+process.stdin.setEncoding("utf8");
+process.stdin.on("data", (x) => (buf += x));
+process.stdin.on("end", () => {
+ const { load, mode } = JSON.parse(buf);
+ const fresh = () => JSON.parse(JSON.stringify(load));
+ const direct = (d) => Calc.computeAll(d.sim_state).boqComputed.totals.directT;
+ const txt = (v) => String(v == null ? "" : v).trim();
+ if (mode === "direct") return process.stdout.write(JSON.stringify({ direct: direct(load) }));
+ if (mode === "amt") return process.stdout.write(JSON.stringify(amt(load)));
+ if (mode === "expand") return process.stdout.write(JSON.stringify(expand(load)));
+ if (mode === "units") return process.stdout.write(JSON.stringify(units(load)));
+ if (mode === "merge") return process.stdout.write(JSON.stringify(merge(JSON.parse(buf))));
+
+ const d = fresh();
+ const env = { d, state: d.sim_state, memo: {} };
+ const out = { t0: direct(d) };
+ const boqCodes = new Set(d.boq_rows.map((r) => txt(r.BDCODE1)));
+ const comps = (c) => d.bdqty_rows.filter((r) => txt(r.BDCODE) === c).length;
+
+ // 1. 신규등록(복사) — 내역에 쓰인 구성 있는 일위대가
+ const bsrc = d.costn_rows
+ .map(UC.codeOf)
+ .find((c) => c[0] === "B" && boqCodes.has(c) && comps(c) > 0);
+ const add = UC.addCode(env, { from: bsrc });
+ out.add = {
+ ok: add.ok,
+ src: bsrc,
+ code: add.code,
+ comps: [comps(bsrc), comps(add.code)],
+ t: direct(d),
+ };
+
+ // 2. 코드변경 — 쓰이는 재료비 하나 + 식 있는 산근 하나를 빈 번호로
+ const used = UC.usedSet(env);
+ const all = new Set(d.costn_rows.map(UC.codeOf));
+ const pickFree = (k) => UC.nextCode(d.costn_rows, k, all);
+ const mOld = d.costn_rows.map(UC.codeOf).find((c) => c[0] === "M" && used.has(c));
+ const dOld = d.costn_rows
+ .map(UC.codeOf)
+ .find(
+ (c) =>
+ c[0] === "D" &&
+ UC.memoText(env, c) &&
+ used.has(c) &&
+ UC.memoRefs(UC.memoText(env, c)).length,
+ );
+ const map = { [mOld]: pickFree("M") };
+ if (dOld) map[dOld] = pickFree("D");
+ const refCount = (c) => ({
+ boq: Object.values(d.boq_tables).reduce(
+ (n, rows) => n + rows.filter((r) => txt(r.BDCODE1) === c).length,
+ 0,
+ ),
+ comp: d.bdqty_rows.filter((r) => txt(r.CODE) === c).length,
+ memo: d.costn_rows.filter((r) => UC.memoRefs(UC.memoText(env, UC.codeOf(r))).includes(c))
+ .length,
+ });
+ const before = Object.fromEntries(Object.keys(map).map((c) => [c, refCount(c)]));
+ const bad = UC.checkChange(d.costn_rows, { [mOld]: bsrc }).errs.length > 0; // 다른 종류·있는 코드 거부
+ const ch = UC.changeCodes(env, map);
+ out.change = {
+ ok: ch.ok,
+ map,
+ before,
+ after: Object.fromEntries(Object.entries(map).map(([a, b]) => [b, refCount(b)])),
+ gone: Object.keys(map).map(refCount),
+ bad,
+ t: direct(d),
+ };
+
+ // 3. 항목교체 — 원래 일위대가 → 복사본(값 같음) · 원래 것은 안 쓰이게 됨 → 지움
+ const rp = UC.replaceCodes(env, { [bsrc]: add.code });
+ const refused = UC.deleteCodes(env, [add.code]); // 쓰이는 코드
+ const del = UC.deleteCodes(env, [bsrc]);
+ out.replace = {
+ ok: rp.ok,
+ t: direct(d),
+ refused: !refused.ok,
+ del: del.ok && del.deleted[0] === bsrc,
+ };
+ out.final = Object.fromEntries(Object.values(map).map((c) => [c, refCount(c)])); // 모든 손질 뒤 참조 개수
+ out.finalComps = comps(add.code);
+
+ // 4. +α — 견적·노무 아닌 종류 하나를 통째로 밀어 냄(겹침 없이)
+ const d2 = fresh(),
+ env2 = { d: d2, state: d2.sim_state, memo: {} };
+ const t2 = direct(d2);
+ const pm = UC.plusMap(d2.costn_rows, { X: 1000 });
+ const pch = UC.changeCodes(env2, pm);
+ out.plus = {
+ n: Object.keys(pm).length,
+ ok: pch.ok || !Object.keys(pm).length,
+ same: direct(d2) === t2,
+ };
+ out.plus.compOk = d2.bdqty_rows.every((r) => !/^X/.test(txt(r.BDCODE)) || !pm[txt(r.BDCODE)]);
+
+ // 5. 동일항목 · 일괄삭제 대상
+ out.same = UC.sameItems(d.costn_rows).length;
+ out.unused = { minus: UC.unusedCodes(env, false).length, plus: UC.unusedCodes(env, true).length };
+
+ const ctx = {
+ state: d.sim_state,
+ dbfData: d,
+ currentSourcePath: load.source_path,
+ uiState: { editedSmtMap: env.memo },
+ };
+ ctx.computed = Calc.calculateAll(ctx.state);
+ const p = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx);
+ out.payload = Object.assign(p, UC.structPayload(d));
+ out.needs = UC.needsUiSave(d);
+ process.stdout.write(JSON.stringify(out));
+});
+
+function amt(load) {
+ const txt = (v) => String(v == null ? "" : v).trim();
+ const num = (v) => Number(txt(v)) || 0;
+ const rows = JSON.parse(JSON.stringify(load.costn_rows));
+ const out = {};
+ // 일괄변경 — 1차 × 110% → 2차(낙찰률 행 제외) · 다시 100% 로 1차 → 1차 무변화
+ const nag = new Set(rows.filter((r) => num(r.NAGCD) > 0).map((r) => txt(r.CODE)));
+ const ch = UA.chgAmt(rows, { kinds: "MLS", from: 1, to: 2, rate: 110, digits: 0, mode: "trunc" });
+ const r0 = load.costn_rows.find((r) => txt(r.CODE) === (ch[0] || {}).code) || {};
+ out.chg = {
+ n: ch.length,
+ nagSkipped: ch.every((c) => !nag.has(c.code)),
+ first: ch[0]
+ ? [
+ ch[0].to,
+ Math.trunc(num(r0.MAMT1) * 1.1) +
+ Math.trunc(num(r0.LAMT1) * 1.1) +
+ Math.trunc(num(r0.SAMT1) * 1.1),
+ ]
+ : null,
+ idem: UA.chgAmt(rows, { kinds: "MLS", from: 1, to: 1, rate: 100, digits: 4 }).length,
+ };
+ // 할증 — 율 두 번 = 한 번 · 금액 갈래
+ const h1 = UA.halAll(rows, { kinds: "M", how: "rate", value: 10, digits: 4 });
+ const h2 = UA.halAll(rows, { kinds: "M", how: "rate", value: 10, digits: 4 });
+ const h3 = UA.halAll(rows, { kinds: "M", how: "amt", value: 0, digits: 4 });
+ out.hal = { n: h1.length, twice: h2.length, back: h3.length };
+ // 초기화 — 호환규정코드(재료비)
+ const filled = rows.filter((r) => txt(r.CODE)[0] === "M" && txt(r.JDCODE)).length;
+ out.init = { filled, cleared: UA.initCols(rows, { JD: "M" }).JDCODE || 0 };
+ out.jdLeft = rows.filter((r) => txt(r.CODE)[0] === "M" && txt(r.JDCODE)).length;
+ // 호환규정코드 찾기 — 원본 행에서 지운 값을 같은 명칭·규격 자료에서 다시 찾음
+ const src = load.costn_rows.filter((r) => txt(r.JDCODE));
+ out.jd = src.slice(0, 40).map((r) => {
+ const c = UA.jdCandidates({ NAME: r.NAME, SIZE: r.SIZE }, src, "NS");
+ return c.length > 0 && c.some((x) => x.jd === txt(r.JDCODE));
+ });
+ out.jdDup = UA.jdDup(load.costn_rows).length;
+ // 자료파일 업데이트 — 호환규정코드 지운 사본 ← 원본(명칭/규격/단위 매칭 · 복사처 우선) · 명칭·규격·단위가 하나뿐인 행은 원래 코드로
+ const blank = JSON.parse(JSON.stringify(load.costn_rows));
+ blank.forEach((r) => (r.JDCODE = ""));
+ const up = UM.mrgBsc(blank, load.costn_rows, {
+ keys: ["NSU"],
+ name: 0,
+ amt: 0,
+ jd: 2,
+ kinds: "LMSW",
+ });
+ const key = (r) => `${txt(r.CODE)[0]}|${txt(r.NAME)}|${txt(r.SIZE)}|${txt(r.UNIT)}`;
+ const cnt = {};
+ load.costn_rows.forEach((r) => (cnt[key(r)] = (cnt[key(r)] || 0) + 1));
+ const uniq = load.costn_rows.filter(
+ (r) => "LMSW".includes(txt(r.CODE)[0]) && txt(r.NAME) && txt(r.JDCODE) && cnt[key(r)] === 1,
+ );
+ out.mrg = {
+ n: up.length,
+ uniq: uniq.length,
+ same: uniq.every(
+ (r) => txt(blank.find((b) => txt(b.CODE) === txt(r.CODE)).JDCODE) === txt(r.JDCODE),
+ ),
+ };
+ // 손료계수 — 중기 구성의 천원 단위 기계가격 줄 수량 = 계 × 10^-7 × 1000
+ const by = Object.fromEntries(load.costn_rows.map((r) => [txt(r.CODE), r]));
+ const lossRows = (load.bdqty_rows || []).filter(
+ (r) =>
+ /^X/.test(txt(r.BDCODE)) &&
+ by[txt(r.CODE)] &&
+ txt(by[txt(r.CODE)].UNIT) === "천원" &&
+ num(r.QTY1) > 0,
+ );
+ out.loss = lossRows.map((r) => {
+ const s = UA.lossSum(num(r.QTY1), 100);
+ return [num(r.QTY1), s, UA.lossQty(s, 100)];
+ });
+ const dec = new TextDecoder("euc-kr");
+ out.special = UA.SPECIAL.map((_x, i) => UA.specialChars(i, (b) => dec.decode(b)).length);
+ out.special0 = UA.specialChars(6, (b) => dec.decode(b))
+ .slice(0, 6)
+ .join("");
+ return out;
+}
+
+// 코드병합 — 복사원 = 다른 프로젝트 · 내역에 쓰인 구성 있는 일위대가 하나를 모든 하위 포함으로 가져옴
+function merge({ load, src }) {
+ const txt = (v) => String(v == null ? "" : v).trim();
+ const d = JSON.parse(JSON.stringify(load));
+ const env = { d, state: d.sim_state, memo: {} };
+ const t0 = Calc.computeAll(d.sim_state).boqComputed.totals.directT;
+ const used = new Set(src.boq_rows.map((r) => txt(r.BDCODE1)));
+ const pick = src.costn_rows
+ .map((r) => txt(r.CODE))
+ .find((c) => c[0] === "B" && used.has(c) && src.bdqty_rows.some((q) => txt(q.BDCODE) === c));
+ const want = UM.closure(src, [pick], 2);
+ const steps = UM.plan(env, src, { codes: [pick], mode: 2, conn: 2, policy: {} });
+ const res = UM.apply(env, src, steps);
+ const top = steps.find((s) => s.src === pick);
+ const comp = (rows, c) => rows.filter((q) => txt(q.BDCODE) === c).map((q) => txt(q.CODE));
+ const by = Object.fromEntries(d.costn_rows.map((r) => [txt(r.CODE), r]));
+ const srcBy = Object.fromEntries(src.costn_rows.map((r) => [txt(r.CODE), r]));
+ // 가져온 구성 자식 = 복사원 자식을 계획대로 바꾼 것 · 자식 명칭이 복사원과 같음
+ const kidsOk = comp(src.bdqty_rows, pick).every((c, i) => {
+ const dc = comp(d.bdqty_rows, top.dest)[i];
+ return (
+ dc === (res.map[c] || c) && (!srcBy[c] || !by[dc] || txt(by[dc].NAME) === txt(srcBy[c].NAME))
+ );
+ });
+ const ctx = {
+ state: d.sim_state,
+ dbfData: d,
+ currentSourcePath: load.source_path,
+ uiState: { editedSmtMap: env.memo },
+ };
+ if (window.STMATE_ENGINE) window.STMATE_ENGINE.cascadeRecalculateAll(ctx);
+ const tNew = Calc.num((by[top.dest] || {}).TAMT1);
+ const p = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(
+ Object.assign(ctx, { computed: Calc.calculateAll(ctx.state) }),
+ );
+ return {
+ pick,
+ n: want.length,
+ steps: steps.length,
+ acts: res.count,
+ unresolved: res.unresolved,
+ top,
+ kidsOk,
+ t0,
+ t1: Calc.computeAll(d.sim_state).boqComputed.totals.directT,
+ price: [Calc.num(srcBy[pick].TAMT1), tNew],
+ payload: Object.assign(p, UC.structPayload(d)),
+ };
+}
+
+// 일위대가 풀기(wM_Expnd10) — 할증·비목별 끝전·손료 추가입력(PR 비율 줄)
+function expand(load) {
+ const B = require(D + "ui_boq.js");
+ const T = require(D + "ui_boq_tools.js");
+ const txt = (v) => String(v == null ? "" : v).trim();
+ const d = JSON.parse(JSON.stringify(load));
+ d.boq_deleted = [];
+ const m = { rows: d.boq_rows, items: d.sim_state.boqItems, deleted: d.boq_deleted };
+ const by = Object.fromEntries(d.costn_rows.map((r) => [txt(r.CODE), r]));
+ const comps = (c) => d.bdqty_rows.filter((q) => txt(q.BDCODE) === c);
+ const ok = (q) =>
+ by[txt(q.CODE)] && !/%$/.test(txt(by[txt(q.CODE)].UNIT)) && txt(q.CODE) !== "PR";
+ const row = d.boq_rows.find(
+ (r) => /^B/.test(txt(r.BDCODE1)) && Number(r.QTY1) > 0 && comps(txt(r.BDCODE1)).some(ok),
+ );
+ if (!row) return { skip: true };
+ const Q = Number(row.QTY1);
+ const o = {
+ level: 0,
+ hal: { L: 1.2, M: 1 },
+ per: {
+ L: { mode: "round", digits: 2 },
+ M: { mode: "trunc", digits: 3 },
+ X: { mode: "trunc", digits: 4 },
+ W: { mode: "trunc", digits: 4 },
+ },
+ adds: [{ name: "공구손료", pct: 3, prin: 4, prout: 3 }],
+ };
+ const res = T.expandIlwi(m, row._recno, d.bdqty_rows, by, o, d.start_ini);
+ const want = comps(txt(row.BDCODE1))
+ .filter(ok)
+ .map((q) => {
+ const c = txt(q.CODE),
+ k = "MLX".includes(c[0]) ? c[0] : "W";
+ return [
+ c,
+ T.roundTo(Number(q.QTY1) * (c[0] === "L" ? 1.2 : 1) * Q, o.per[k].digits, o.per[k].mode),
+ ];
+ });
+ const comp = Calc.computeAll(d.sim_state).boqComputed.rows;
+ const pr = comp.find((r) => r.code === "PR" && r.name === "공구손료");
+ const at = d.boq_rows.findIndex((r) => txt(r.BDCODE1) === "PR" && r.NAME1 === "공구손료");
+ const body = d.boq_rows.slice(at - want.length, at).map((r) => [txt(r.BDCODE1), Number(r.QTY1)]);
+ const bodyL = comp
+ .filter((r) => body.some((b, i) => r._recno === d.boq_rows[at - want.length + i]._recno))
+ .reduce((a, r) => a + r.mAmt + r.lAmt + r.sAmt, 0);
+ return {
+ ok: res.ok,
+ want,
+ body,
+ prS: pr ? pr.sAmt : null,
+ prWant: Math.floor((bodyL * 3) / 100),
+ };
+}
+
+// 비목별 단가 끝전(wM_Rate) — 노무비 10원 미만 절사 · 재료비 그대로
+function units(load) {
+ const B = require(D + "ui_boq.js");
+ const T = require(D + "ui_boq_tools.js");
+ const d = JSON.parse(JSON.stringify(load));
+ const m = { rows: d.boq_rows, items: d.sim_state.boqItems, deleted: [] };
+ const before = Calc.computeAll(d.sim_state).boqComputed.rows.filter(
+ (r) => r._recno !== undefined && r.code !== "PR",
+ );
+ const u = Object.fromEntries(
+ before.map((r) => [r._recno, { M: r.mUnit, L: r.lUnit, S: r.sUnit }]),
+ );
+ const res = T.roundUnits(m, null, u, { L: { mode: "trunc", digits: -1 } });
+ const after = new Map(Calc.computeAll(d.sim_state).boqComputed.rows.map((r) => [r._recno, r]));
+ const bad = before.filter((r) => {
+ const a = after.get(r._recno);
+ return (
+ a &&
+ !(a.lUnit === Math.trunc(r.lUnit / 10) * 10 && a.mUnit === r.mUnit && a.sUnit === r.sUnit)
+ );
+ }).length;
+ return {
+ changed: res.changed,
+ want: before.filter(
+ (r) => r.lUnit % 10 !== 0 && B.isLeaf(d.boq_rows.find((x) => x._recno === r._recno) || {}),
+ ).length,
+ bad,
+ };
+}
diff --git a/resources/tester/test_stmate_file_pct.py b/resources/tester/test_stmate_file_pct.py
new file mode 100644
index 000000000..95af21097
--- /dev/null
+++ b/resources/tester/test_stmate_file_pct.py
@@ -0,0 +1,47 @@
+"""STMATE 재현 앱 저장 — % 줄(조달수수료 M%·T% · 비율 줄 PR) 다시 셈.
+
+밑수 줄 수량을 고치면 % 줄 단가 칸(밑수)과 금액(밑수 × 율 원 미만 절사)이 따라 바뀜.
+손대지 않은 % 줄 재계산 = 원본 저장값(실무 STC 7건) 그대로.
+"""
+
+import sys
+from decimal import ROUND_DOWN, Decimal
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+from dbf_rw import DbfTable # noqa: E402
+from stc_archive import files_of, read_entries # noqa: E402
+from stc_save import SaveLog, _pct_rows # noqa: E402
+
+DOCS = ROOT / "resources/knowledge/original/실무문서"
+STCS = sorted(DOCS.rglob("*.STC"))
+
+
+def _boq(p):
+ f = files_of(read_entries(p.read_bytes()))
+ return DbfTable(f["BOQ11.DBF"])
+
+
+@pytest.mark.parametrize("stc", STCS, ids=[p.stem[:20] for p in STCS])
+def test_pct_rows_recompute_equals_stored(stc):
+ bt = _boq(stc)
+ log = SaveLog()
+ _pct_rows(bt, "BOQ11.DBF", "1", log)
+ assert not log.items, f"{stc.stem} % 줄 재계산이 저장값과 다름 {log.items[:4]}"
+
+
+def test_pct_row_follows_base_edit():
+ stc = next(p for p in STCS if "현동" in p.stem)
+ bt = _boq(stc)
+ i = next(k for k in range(len(bt)) if bt.get(k, "UNIT1").strip() == "M%")
+ j = next(k for k in range(i - 1, -1, -1) if float(bt.get(k, "M_AMT1") or 0) > 0)
+ base0 = Decimal(bt.get(i, "MAMT1"))
+ bt.set(j, "M_AMT1", Decimal(bt.get(j, "M_AMT1")) + 1000)
+ _pct_rows(bt, "BOQ11.DBF", "1", SaveLog())
+ assert Decimal(bt.get(i, "MAMT1")) == base0 + 1000
+ q = Decimal(bt.get(i, "QTY1"))
+ want = ((base0 + 1000) * q / 100).quantize(Decimal(1), rounding=ROUND_DOWN)
+ assert Decimal(bt.get(i, "M_AMT1")) == want
diff --git a/resources/tester/test_stmate_ui_code.py b/resources/tester/test_stmate_ui_code.py
new file mode 100644
index 000000000..c82af03c0
--- /dev/null
+++ b/resources/tester/test_stmate_ui_code.py
@@ -0,0 +1,194 @@
+"""STMATE 재현 앱 코드 관리(신규등록·코드변경·+α·항목교체·삭제) · 단가 일괄 손질 지킴 시험.
+
+계산 = tools/stmate-test/ui_code.js · ui_code_amt.js · ui_merge.js(node)
+저장 = api_ui_code.py(/api/ui/save) → 작업본 다시 불러 대조.
+자료 = 실무 STC 7건(server.api_load) — 정답은 원본 저장값(참조 개수·구성행·직공비)에서 읽음.
+"""
+
+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 api_ui_code # noqa: E402
+import server # noqa: E402
+
+HELPER = Path(__file__).with_name("helper_stmate_ui_code.cjs")
+STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
+IDS = [p.stem[:16] for p in STCS]
+_LOAD: dict = {}
+
+
+def load(stc):
+ if stc not in _LOAD:
+ _LOAD[stc] = server.api_load(server.LoadReq(source_path=str(stc)))
+ return _LOAD[stc]
+
+
+def node(data, mode, **extra):
+ raw = json.dumps(dict({"load": data, "mode": mode}, **extra), ensure_ascii=False, default=str)
+ out = subprocess.check_output(["node", str(HELPER)], input=raw, text=True, encoding="utf-8")
+ return json.loads(out)
+
+
+@pytest.fixture
+def work(tmp_path, monkeypatch):
+ monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
+ return tmp_path
+
+
+@pytest.mark.parametrize("stc", STCS, ids=IDS)
+def test_code_ops_keep_direct_cost_and_refs(stc):
+ r = node(load(stc), "ops")
+ a, c, rp = r["add"], r["change"], r["replace"]
+ assert a["ok"] and a["comps"][0] == a["comps"][1] > 0 and a["t"] == r["t0"]
+ assert c["ok"] and c["bad"] and c["t"] == r["t0"]
+ for (old, new), b in zip(c["map"].items(), c["before"].values()):
+ assert c["after"][new] == b and sum(b.values()) > 0 # 참조 개수 그대로 옮김
+ assert all(sum(g.values()) == 0 for g in c["gone"]) # 옛 코드는 어디에도 안 남음
+ assert rp["ok"] and rp["t"] == r["t0"] and rp["refused"] and rp["del"]
+ assert r["plus"]["ok"] and r["plus"]["same"] and r["plus"]["compOk"]
+ assert r["unused"]["plus"] >= r["unused"]["minus"] and r["needs"]
+
+
+@pytest.mark.parametrize("stc", STCS, ids=IDS)
+def test_ui_save_writes_structure_and_reloads(stc, work):
+ data = load(stc)
+ r = node(data, "ops")
+ p = r["payload"]
+ res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**p))
+ back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
+ codes = {x["CODE"].strip() for x in back["costn_rows"]}
+ new_b = r["add"]["code"]
+ assert new_b in codes and r["add"]["src"] not in codes # 복사본 남고 원래 것 지움
+ for old, new in r["change"]["map"].items():
+ assert new in codes and old not in codes
+ boq = sum(1 for x in back["boq_rows"] if x["BDCODE1"].strip() == new)
+ comp = sum(1 for x in back["bdqty_rows"] if x.get("CODE", "").strip() == new)
+ b = r["final"][new] # 항목교체·삭제 뒤 화면 자료의 참조 개수
+ assert (boq, comp) == (b["boq"], b["comp"])
+ memo = [k for k, m in back["costn_memos"].items() if f"&{new}" in m["text"]]
+ assert len(memo) == b["memo"]
+ assert (
+ sum(1 for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == new_b)
+ == r["finalComps"]
+ )
+ assert node(back, "direct")["direct"] == r["t0"] # 다시 불러도 직공비 그대로
+
+
+@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3])
+def test_ui_save_without_struct_changes_nothing(stc, work):
+ data = load(stc)
+ files = server.read_project_files(str(stc))
+ struct = [{"_recno": r["_recno"], "CODE": r["CODE"]} for r in data["costn_rows"]]
+ p = {"source_path": str(stc), "costn_struct": struct, "costn_deleted": [], "boq_code_rows": []}
+ out, log = api_ui_code.apply_struct(files, p)
+ assert log == [] and all(out[k] is files[k] for k in files) # 코드·칸 그대로면 원래 바이트
+
+
+@pytest.mark.parametrize("stc", STCS, ids=IDS)
+def test_amount_tools(stc):
+ r = node(load(stc), "amt")
+ ch = r["chg"]
+ assert ch["nagSkipped"] and ch["idem"] == 0
+ if ch["first"]:
+ assert ch["first"][0] == ch["first"][1]
+ assert (
+ r["hal"]["twice"] == 0 and r["hal"]["back"] == r["hal"]["n"]
+ ) # 기준단가 기준 — 같은 할증 두 번 = 한 번
+ assert r["init"]["cleared"] == r["init"]["filled"] and r["jdLeft"] == 0
+ assert all(r["jd"]) # 같은 명칭·규격 자료에서 원래 호환규정코드를 다시 찾음
+ assert (
+ r["mrg"]["same"] and r["mrg"]["n"] >= r["mrg"]["uniq"]
+ ) # 자료파일 업데이트 — 조달코드 되살림
+ for q, s, q2 in r["loss"]:
+ assert abs(s - round(s)) < 1e-6 and abs(q2 - q) < 1e-9 # 계(10^-7) 정수 · 왕복 같음
+ assert all(n > 0 for n in r["special"]) and len(r["special"]) == 14
+
+
+PAIRS = [(0, 1), (1, 4), (4, 0)] # 복사처 ← 복사원(다른 원설계 프로젝트)
+
+
+@pytest.mark.parametrize("a,b", PAIRS)
+def test_merge_code_from_other_project(a, b, work):
+ dst, src = load(STCS[a]), load(STCS[b])
+ r = node(dst, "merge", src=src)
+ assert (
+ r["steps"] == r["n"] > 0 and r["kidsOk"] and r["t1"] == r["t0"]
+ ) # 가져와도 내역 직공비 그대로
+ assert r["acts"]["new"] + r["acts"]["map"] == r["steps"]
+ res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**r["payload"]))
+ back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
+ top = r["top"]
+ row = next(x for x in back["costn_rows"] if x["CODE"].strip() == top["dest"])
+ assert row["NAME"].strip() and abs(float(row["TAMT1"] or 0) - r["price"][1]) < 1
+ kids = [x for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == top["dest"]]
+ assert len(kids) == sum(
+ 1 for x in src["bdqty_rows"] if x.get("BDCODE", "").strip() == r["pick"]
+ )
+
+
+@pytest.mark.parametrize("stc", STCS, ids=IDS)
+def test_expand_with_surcharge_rounding_and_loss_row(stc):
+ r = node(load(stc), "expand")
+ if r.get("skip"):
+ pytest.skip("구성 있는 일위대가 내역 행 없음")
+ assert r["ok"] and r["body"] == r["want"] # 비목별 끝전·노무 할증 1.2
+ assert r["prS"] == r["prWant"] > 0 # 공구손료 = 풀린 줄 전체 금액 × 3% → 경비(원 미만 절사)
+
+
+def test_project_data_search_finds_names_across_projects(monkeypatch):
+ paths = [str(p) for p in STCS[:3]]
+ projs = [{"name": p.stem, "path": str(p)} for p in STCS[:3]]
+ monkeypatch.setattr(server, "discover_sources", lambda: {"projects": projs})
+ a = load(STCS[0])
+ row = next(r for r in a["costn_rows"] if r["CODE"].startswith("L") and r["NAME"].strip())
+ words = row["NAME"].split()
+ res = api_ui_code.ui_search(api_ui_code.SearchReq(kind="LMS", kw=" ".join(words), paths=paths))
+ hits = {(r["project"], r["CODE"]) for r in res["rows"]}
+ assert (STCS[0].stem, row["CODE"].strip()) in hits and not res["errors"]
+ assert all(
+ all(w.lower() in f"{r['NAME']} {r['SIZE']}".lower() for w in words) for r in res["rows"]
+ )
+ boq = api_ui_code.ui_search(api_ui_code.SearchReq(kind="BOQ", kw=words[0], paths=paths))
+ assert all(words[0].lower() in f"{r['NAME1']} {r['SIZE1']}".lower() for r in boq["rows"])
+ jd = next(r for r in a["costn_rows"] if r["JDCODE"].strip())
+ res = api_ui_code.ui_search(
+ api_ui_code.SearchReq(
+ kind=jd["CODE"][0] in "LMS" and "LMS" or "XBD",
+ kw=jd["JDCODE"].strip(),
+ field="jdcode",
+ paths=paths[:1],
+ )
+ )
+ assert jd["CODE"].strip() in {r["CODE"] for r in res["rows"]}
+
+
+OTHER_BOQ = [
+ (stc, str(r["PCODE"]).strip())
+ for stc in STCS
+ for r in server.api_load(server.LoadReq(source_path=str(stc)))["prjlst_all"]
+ if str(r["PCODE"]).strip() != "11"
+ and f"BOQ{str(r['PCODE']).strip()}.DBF" in server.read_project_files(str(stc))
+]
+
+
+@pytest.mark.parametrize("stc,pcode", OTHER_BOQ, ids=[f"{s.stem[:10]}-{p}" for s, p in OTHER_BOQ])
+def test_open_other_boq_table(stc, pcode):
+ d = api_ui_code.ui_load_boq(api_ui_code.LoadBoqReq(source_path=str(stc), pcode=pcode))
+ assert d["boq_table_name"] == f"BOQ{pcode}.DBF" and d["prjlst"]["PCODE"].strip() == pcode
+ assert d["sim_state"]["projectMeta"]["pcode"].strip() == pcode
+ t = node(d, "direct")["direct"]
+ assert abs(t - float(d["prjlst"]["TCOST1"] or 0)) <= 1 # 그 공사 저장 직접비(TCOST1)
+
+
+@pytest.mark.parametrize("stc", STCS, ids=IDS)
+def test_unit_rounding_per_kind(stc):
+ r = node(load(stc), "units")
+ assert (
+ r["bad"] == 0 and r["changed"] == r["want"]
+ ) # 노무비만 10원 미만 절사 · 재료비·경비 그대로
diff --git a/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md
index 340e6b726..d1dc0d65b 100644
--- a/tools/stmate-test/PLAN.md
+++ b/tools/stmate-test/PLAN.md
@@ -19,6 +19,7 @@
- `test_stmate_kanjub_opts.py` · `test_stmate_kanjub_rate.py` — 제잡비 선택지 손셈 대조 · 요율표 조회로 저장 요율 재현
- `test_stmate_smt_lang.py` · `test_stmate_qty_sheet.py` — 식 함수·변수·문법 검사 · 수량산출서·QCODE
- `test_stmate_ui.py` — 메뉴·내역서 편집·도구 창 순수 계산
+- `test_stmate_conv_*.py` · `test_stmate_file_pct.py` · `test_stmate_ui_code.py` — 엑셀·XML 가져오기 왕복 · 분리형 변경 STC · % 줄 저장 재계산 · 코드 관리 왕복
- `test_stmate_file_*.py` — 실무 STC 7건 무손실 왕복(손대지 않은 저장 = 원본 바이트) · 고친 칸만 바뀜 · 새 공사·복사등록·해당분 압축
- `test_stmate_stage_*.py` — 설계변경(영덕·소광 변경 BOQ·엑셀 대조) · 계약 변환(울진 BOQ12 계약내역·총공사비 1원) · 기성·실행(앞뒤 맞춤만)
- `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로
@@ -27,16 +28,16 @@
## 진행률
-- 합계: 전체 246 · 구현 149 · 부분 78 · 없음 19 → 진행률 76.4%((149 + 78×0.5) / 246) · 구현만 세면 60.6%
+- 합계: 전체 246 · 구현 167 · 부분 74 · 없음 5 → 진행률 82.9%((167 + 74×0.5) / 246) · 구현만 세면 67.9%
-### G1 화면·메뉴 (구현 16 · 부분 23 · 없음 15) — 50.9%
+### G1 화면·메뉴 (구현 34 · 부분 19 · 없음 1) — 80.6%
- [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui))
-- [ ] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 부분(공사목록표·등록정보 수정 · 다른 내역서 열기 없음)
+- [x] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 구현(공사목록표 · 다른 내역서 열기(저장 직접비 1원 일치))
- [ ] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 부분(stc_project 새 공사(빈 상태) · 삭제 화면 없음)
-- [ ] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 부분(공사목록표·등록정보 수정 · 다른 내역서 열기 없음)
+- [x] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 구현(등록정보 수정)
- [ ] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 부분(stc_project 복사등록 차수 인계(K_ORD·직공비·원가 설정) 실물 대조 · 계약 변환 실물 대조 없음)
-- [ ] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 없음
+- [x] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 구현(등록 프로젝트 전수 검색·찾아가기·가져오기)
- [x] F1-07 공종목록표 계층 내역 그리드(T/E/C 짝 집계) — wBoq · 현재: 구현(view_prj_boq 37~145)
- [x] F1-08 내역 칸 편집 — wBoq · 현재: 구현(내역서 칸 편집·행 넣기·지우기·이동·복사·붙여넣기 · 직공비 증감 정확(test_stmate_ui))
- [x] F1-09 내역 행 추가·끼워넣기 — wBoq · 현재: 구현(내역서 칸 편집·행 넣기·지우기·이동·복사·붙여넣기 · 직공비 증감 정확(test_stmate_ui))
@@ -45,46 +46,46 @@
- [x] F1-12 계표시 레벨 상향·하향, 주석 → 계시작 변환 — wBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
- [x] F1-13 내역 찾기 — wM_FindBoq · 현재: 구현(app.js 344)
- [x] F1-14 호표 코드 → 대가표·단가표 이동 — wBoq · 현재: 구현(코드 → 단가목록표·대가표 이동)
-- [ ] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 부분(공종목록에서 코드 넣기)
+- [x] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 구현(공종목록 코드 넣기 · 대공종 옵션)
- [x] F1-16 주공종 집계표(레벨별 계표시 확인) — wMBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
- [ ] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 부분(공종번호 자동입력 · 원본 번호 빈 자리 앞까지 재현(봉화·영월))
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(내역 할증율 계산 반영(calcBoq) · 근거 등급 C)
-- [ ] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 부분(수량 단수 보정만)
+- [x] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 구현(비목별 단가 끝전 · 줄 고정 단가(server fixedUnit)로 다시 불러도 유지)
- [x] F1-20 일위대가표 풀기 — wExpnd · 현재: 구현(일위대가 풀기 = 구성표 × 내역 수량)
-- [ ] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 없음
-- [ ] F1-22 내역 동일코드 처리 — wSameCode · 현재: 없음
+- [x] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 구현(풀어넣기 비목별 끝전·할증·손료 비율 줄 · 손료 줄 금액 7건 대조)
+- [x] F1-22 내역 동일코드 처리 — wSameCode · 현재: 구현(동일코드 행 모아보기 · 행 이동)
- [x] F1-23 내역보관함 (1)(2)(3) — wBoq · 현재: 구현(보관함 (1)(2)(3))
-- [ ] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 부분(코드 종류 10종 · 그림코드 앞 글자 근거 없음)
+- [x] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 구현(코드 종류 10종 · 그림코드 = I 코드(원본 화면 탭))
- [x] F1-25 단가 찾기(명칭·규격 / 산근내용) — wM_JabPum cBox_IFORM · 현재: 구현(단가 찾기·사용처 찾기)
- [ ] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
-- [ ] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 없음
-- [ ] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 없음
+- [x] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 구현(ui_code 신규등록(복사)·삭제 · 저장·다시 불러 참조 개수·직공비 그대로 7건(test_stmate_ui_code))
+- [x] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 구현(코드변경 선택·일괄·+α · 항목교체 · 7건 왕복)
- [ ] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
-- [ ] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 없음
+- [x] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 구현(할증 율·금액(기준단가 기준 · 두 번 해도 같은 값))
- [ ] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
-- [ ] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 없음
+- [x] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 구현(속성·명칭·불러간 목록 필터)
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~)
-- [ ] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 부분(일위대가·중기 구성행 편집 화면 · BDQTY 저장 없음)
+- [x] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 구현(구성행 편집 · 저장 = BDQTY 바뀐 호표만 암호로 다시 씀(stc_save_extra))
- [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음)
-- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료 계산기 규칙(REA 30) · 반올림 방향 미확정)
-- [ ] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 없음
-- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 비목율 정리(REA 27) · 금액 비목 나눔 근거 없음)
-- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 없음
+- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료계수(수량 = 계 × 10⁻⁴ 정수 확인) · calc_xrate · 반올림 방향 미확정)
+- [x] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 구현(사용자문자 편집 목록)
+- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 실적→일식 · 7건 실적단가 자료 없음)
+- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 부분(조달청 코드 목록 암호 파일 · 다른 프로젝트 값 후보)
- [x] F1-40 사용처 찾기 — ST_FindUse wM_FindUse · 현재: 구현(단가 찾기·사용처 찾기)
-- [ ] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 없음
+- [x] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 구현(ui_merge 코드병합 4택 · 데이타병합(복사처/복사원/신규 · 동일항목 3택 · W코드) · 저장·다시 불러 대조)
- [ ] F1-42 자료파일 업데이트(매칭 5단계·명칭/단가/조달코드) — wM_Mrg_BSC · 현재: 없음(기준자료(BSC) 암호 ZIP·난독 DBF 로 못 읽음)
-- [ ] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 없음
+- [x] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 구현(ui_code_amt 일괄변경·물가변동 · 당초⇔변경 단가 옮기기)
- [x] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 구현(초기값 화면 전체 항목 · 저장 = Start8x.Ini 바뀐 키)
-- [ ] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 없음
+- [x] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 구현(프로젝트 계산 초기값 5칸 × 8종)
- [ ] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 부분(조건 직종 코드 입력만)
- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(차수·방식·공사종류·기간·고용등급·퇴직 6칸 처리 없음)
- [ ] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 부분(보기만, 고칠 칸 없음 view_kanjub_prt 152)
- [ ] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 부분(RATE_R 탭만 바뀜, 원가 요율 미연결 app.js 510)
-- [ ] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 없음
+- [x] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 구현(잡비 금액 불러오기 → 제잡비 직접입력(C_ 칸))
- [ ] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 부분(scr_report_opts 인쇄 메뉴 옵션 화면)
- [ ] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 부분(인쇄순서·집계수준 화면)
- [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 부분(인쇄영역(당초/변경/분할·레벨·차수) 화면)
-- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음
+- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모 · 그림 넣기 없음)
### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0%
@@ -108,7 +109,7 @@
- [x] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 구현(CHNNM 비목우선 = 실무 7건 차수별 내역 줄 금액 1원 · 총액우선 = OPTIMIZ 끝전 배분(손셈 대조) · 비율 줄(test_stmate_kanjub_boq))
- [x] F2-19 계층 합산(SUM_TOT·T/E 짝) — 16번 §4.1 · 현재: 구현(같은 코드 여러 줄이면 짝 오류 위험 view_prj_boq 66)
- [x] F2-20 제외금액 C* 구역 — 16번 §4.2 · 현재: 구현
-- [x] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts))
+- [x] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 구현(관급·조달수수료 · 코드 붙은 % 줄 = 같은 상위 공종 앞 줄 합 × 율 계산·저장 재계산(test_stmate_file_pct · kanjub_stc))
- [x] F2-22 요율표 규모·기간 구간 조회 — 12번 §8~10 · 06번 §2 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts))
- [x] F2-23 요율 기본값 처리 — calc.js · 현재: 구현(num 기본값 · 4건 시험)
- [ ] F2-24 공사종류·적용기준(행자부 RateM 포함)·공사기간 축 — 12번 §10.1 · 현재: 부분(공사종류·기간 축 요율 조회 7건 재현 · 행자부 적용기준 근거 부족)
diff --git a/tools/stmate-test/api_ui_code.py b/tools/stmate-test/api_ui_code.py
new file mode 100644
index 000000000..edd4b03f2
--- /dev/null
+++ b/tools/stmate-test/api_ui_code.py
@@ -0,0 +1,297 @@
+# -*- coding: utf-8 -*-
+"""코드 관리·자료 병합 저장 끼워 넣기 API.
+
+코드 신규·삭제·변경·항목교체·병합 뒤 저장 · 프로젝트 DATA 검색 · 다른 내역서 열기.
+근거: ST_U_ChgCode.CPM(uM_ChgCode · uM_Chg_Plus · uM_ReplCode) · ST_U_DelCode.CPM(uM_DelCode)
+ · ST_U_Merge.CPM(uM_Merge) · ST_Merge_Code.CPM(wM_Stmate_Merge_Code · wSrch_PL)
+ — DFM 원문(C:/경영정보(주)/System)
+저장 = 구조 손질(COSTN 행 끼움·지움·코드 · 다른 내역 표 코드)
+ → stc_save.apply_save(바뀐 칸만 · 구성표 bdqty_rows 포함)
+요청 덧붙임 키:
+ costn_struct [{_recno | _new+_from|_vals, CODE, 덧붙임 칸…}] — 있는 행은 코드·덧붙임 칸만,
+ 새 행은 _from 행 복사 뒤 코드 · _vals(다른 프로젝트에서 가져온 행의 칸 값)
+ costn_deleted [_recno…] — 지움 표시(*) · 원래 자리 유지
+ boq_code_rows [{table, _recno, BDCODE1~4}] — 열린 표 밖 내역 표의 코드 바꿈
+"""
+
+from __future__ import annotations
+
+import pathlib
+
+from dbf_rw import DbfTable
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel, ConfigDict
+from smt_parser import parse_dbf_bytes
+from stc_project import safe_name, write_output
+from stc_save import apply_save, pick_boq_table
+
+router = APIRouter()
+
+# 덧붙임 칸 — 기본 저장(COSTN_SAVE_COL) 밖이나 코드 관리 화면이 고치는 칸
+EXTRA_COL = ("BQCODE", "EQCODE", "JDCODE", "WQCODE", "EDT_DATE", "NAME2", "SIZE2", "PP", "CONTCODE")
+
+
+def _txt(v) -> str:
+ return "" if v is None else str(v).strip()
+
+
+def _same(tbl: DbfTable, i: int, name: str, value) -> bool:
+ """뜻으로 같은가 — 숫자 칸이 깨진 값이어도 글자로 먼저 견줌."""
+ if _txt(tbl.get(i, name)) == _txt(value):
+ return True
+ try:
+ return tbl.same_value(i, name, value)
+ except ValueError:
+ return False
+
+
+def _put(tbl: DbfTable, i: int, name: str, value, log: list, table: str) -> None:
+ if name not in tbl.fields or tbl.fields[name].type == "M" or _same(tbl, i, name, value):
+ return
+ old = tbl.get(i, name)
+ tbl.set_raw(i, name, tbl.encode_value(name, value))
+ log.append({"table": table, "recno": i, "field": name, "old": old, "new": _txt(value)})
+
+
+def _costn(files: dict, req: dict, log: list) -> None:
+ struct = req.get("costn_struct") or []
+ dele = {int(x) for x in (req.get("costn_deleted") or [])}
+ if not (struct or dele) or "COSTN.DBF" not in files:
+ return
+ ct = DbfTable(files["COSTN.DBF"])
+ n0 = len(ct)
+ for i in sorted(dele):
+ if 0 <= i < n0 and not ct.deleted(i):
+ ct.recs[i][0:1] = b"*"
+ ct.dirty = True
+ log.append(
+ {
+ "table": "COSTN.DBF",
+ "recno": i,
+ "field": "_delete",
+ "old": ct.get(i, "CODE"),
+ "new": "",
+ }
+ )
+ codes = {ct.get(i, "CODE") for i in range(n0) if not ct.deleted(i)}
+ for u in struct:
+ code = _txt(u.get("CODE"))
+ if not code:
+ continue
+ r = u.get("_recno")
+ if not u.get("_new") and r is not None and 0 <= int(r) < n0:
+ i = int(r)
+ if i in dele:
+ continue
+ else:
+ if code in codes:
+ raise ValueError(f"COSTN 에 이미 있는 코드: {code}")
+ src = u.get("_from")
+ tmpl = int(src) if src is not None and 0 <= int(src) < n0 else None
+ i = ct.insert(len(ct), {}, template=tmpl)
+ if "IFORM" in ct.fields: # 산출근거 메모는 새 자리로(복사원 메모 덮어쓰기 방지)
+ ct.set_raw(i, "IFORM", b" " * ct.fields["IFORM"].len)
+ log.append(
+ {"table": "COSTN.DBF", "recno": i, "field": "_insert", "old": "", "new": code}
+ )
+ if tmpl is None:
+ for k in ("NAME", "SIZE", "UNIT"):
+ _put(ct, i, k, u.get(k, ""), log, "COSTN.DBF")
+ _put(ct, i, "CODE", code, log, "COSTN.DBF")
+ codes.add(code)
+ for k, v in (
+ u.get("_vals") or {}
+ ).items(): # 다른 프로젝트에서 가져온 새 행 — 원래 칸 값 그대로
+ if k in ct.fields and k not in ("CODE", "IFORM") and not k.endswith("_HEX"):
+ try:
+ _put(ct, i, k, v, log, "COSTN.DBF")
+ except ValueError: # 칸 폭·꼴이 다른 값은 건너뜀(원래 틀 값 유지)
+ continue
+ for k in EXTRA_COL:
+ if k in u:
+ _put(ct, i, k, u[k], log, "COSTN.DBF")
+ if ct.dirty:
+ files["COSTN.DBF"] = ct.to_bytes()
+
+
+def _boq_codes(files: dict, req: dict, log: list) -> None:
+ by: dict[str, list] = {}
+ for u in req.get("boq_code_rows") or []:
+ by.setdefault(str(u.get("table", "")).upper(), []).append(u)
+ for name, ups in by.items():
+ if name not in files:
+ continue
+ bt = DbfTable(files[name])
+ for u in ups:
+ i = int(u.get("_recno", -1))
+ if not (0 <= i < len(bt)):
+ continue
+ for k in ("BDCODE1", "BDCODE2", "BDCODE3", "BDCODE4"):
+ if k in u:
+ _put(bt, i, k, u[k], log, name)
+ if bt.dirty:
+ files[name] = bt.to_bytes()
+
+
+def apply_struct(files: dict[str, bytes], req: dict) -> tuple[dict[str, bytes], list]:
+ """구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움)."""
+ out = dict(files)
+ log: list = []
+ _costn(out, req, log)
+ _boq_codes(out, req, log)
+ return out, log
+
+
+class UiSaveReq(BaseModel):
+ """/api/save 요청 그대로(덧붙은 키는 apply_save 로 넘김) + 구조 덧붙임 키."""
+
+ model_config = ConfigDict(extra="allow")
+ source_path: str
+ target_folder_name: str = "stmate_edited"
+ costn_struct: list[dict] = []
+ costn_deleted: list[int] = []
+ boq_code_rows: list[dict] = []
+
+
+def _server():
+ import server # 실행 중 바뀔 수 있는 작업 폴더·원천 열기(시험은 임시 폴더로 바꿈)
+
+ return server
+
+
+@router.post("/api/ui/save")
+def ui_save(req: UiSaveReq):
+ """구조 손질 → 바뀐 칸 저장 → work_dbf/<이름>/ · <이름>.STC (원본은 손대지 않음)."""
+ srv = _server()
+ src = srv.open_source(req.source_path)
+ body = req.model_dump()
+ try:
+ files, struct_log = apply_struct(src.files, body)
+ files, changes = apply_save(files, body)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ res = write_output(
+ srv.WORK_DBF_ROOT, safe_name(req.target_folder_name), files, src.names, src.entries
+ )
+ res["changed_files"] = sorted(k for k in files if files[k] is not src.files.get(k))
+ allc = struct_log + changes
+ res["changes"] = allc[:500]
+ res["change_count"] = len(allc)
+ return res
+
+
+# ── 프로젝트 DATA 검색(wSrch_PL) — 등록된 모든 프로젝트의 단가·내역에서 명칭·규격 찾기 ──
+_CACHE: dict[str, tuple[float, dict]] = {}
+COSTN_COLS = (
+ "CODE",
+ "NAME",
+ "SIZE",
+ "UNIT",
+ "BQCODE",
+ "EQCODE",
+ "TAMT1",
+ "LAMT1",
+ "MAMT1",
+ "SAMT1",
+)
+COSTN_COLS += ("JDCODE", "EDT_DATE")
+BOQ_COLS = ("ITNUM", "NAME1", "SIZE1", "UNIT1", "QTY1", "T_AMT1", "BDCODE1", "BQCODE")
+
+
+def _project_rows(path: str) -> dict:
+ p = pathlib.Path(path)
+ key = str(p)
+ mt = p.stat().st_mtime if p.exists() else 0.0
+ hit = _CACHE.get(key)
+ if hit and hit[0] == mt:
+ return hit[1]
+ files = _server().open_source(path).files
+ costn = parse_dbf_bytes(files["COSTN.DBF"])[1] if "COSTN.DBF" in files else []
+ boq_name = pick_boq_table(files)
+ boq = parse_dbf_bytes(files[boq_name])[1] if boq_name else []
+ data = {
+ "costn": [{k: _txt(r.get(k)) for k in COSTN_COLS} for r in costn if _txt(r.get("CODE"))],
+ "boq": [dict({k: _txt(r.get(k)) for k in BOQ_COLS}, table=boq_name) for r in boq],
+ }
+ _CACHE[key] = (mt, data)
+ return data
+
+
+SRCH_KINDS = {"LMS": "LMS", "XBD": "XBD", "W": "W", "BOQ": ""}
+
+
+class SearchReq(BaseModel):
+ kind: str = "LMS" # LMS · XBD · W · BOQ (wcBx_Type 4택)
+ kw: str = "" # 띄어 쓴 낱말 모두 든 것
+ field: str = "name" # name(명칭·규격) · jdcode(호환규정코드) · code(코드번호)
+ paths: list[str] = [] # 비면 등록된 모든 프로젝트
+ limit: int = 500
+
+
+def match_rows(rows: list[dict], kind: str, kw: str, field: str) -> list[dict]:
+ words = [w.lower() for w in kw.split() if w]
+ pre = SRCH_KINDS.get(kind, "")
+ out = []
+ for r in rows:
+ if kind == "BOQ":
+ if not r.get("BDCODE1") or len(r["BDCODE1"]) < 3:
+ continue
+ hay = f"{r['NAME1']} {r['SIZE1']}"
+ else:
+ if r["CODE"][:1] not in pre:
+ continue
+ hay = {"jdcode": r["JDCODE"], "code": r["CODE"]}.get(field, f"{r['NAME']} {r['SIZE']}")
+ if all(w in hay.lower() for w in words):
+ out.append(r)
+ return out
+
+
+@router.post("/api/ui/search")
+def ui_search(req: SearchReq):
+ srv = _server()
+ projs = srv.discover_sources()["projects"]
+ if req.paths:
+ want = set(req.paths)
+ projs = [p for p in projs if p["path"] in want]
+ found, errs = [], []
+ for p in projs:
+ try:
+ data = _project_rows(p["path"])
+ except Exception as e: # 열 수 없는 원천은 건너뛰고 알림
+ errs.append({"project": p["name"], "error": str(e)[:120]})
+ continue
+ rows = data["boq"] if req.kind == "BOQ" else data["costn"]
+ for r in match_rows(rows, req.kind, req.kw, req.field):
+ found.append(dict(r, project=p["name"], path=p["path"]))
+ if len(found) >= req.limit:
+ return {"rows": found, "truncated": True, "errors": errs}
+ return {"rows": found, "truncated": False, "errors": errs}
+
+
+# ── 다른 내역서 열기(wPrjList — 공사 행마다 BOQ{PCODE}.DBF) ──
+class LoadBoqReq(BaseModel):
+ source_path: str
+ pcode: str
+
+
+@router.post("/api/ui/load-boq")
+def ui_load_boq(req: LoadBoqReq):
+ """불러오기 결과를 고른 공사(PRJLST 행)의 내역 표·제잡비 설정으로 바꿔 돌려줌."""
+ srv = _server()
+ data = srv.api_load(srv.LoadReq(source_path=req.source_path))
+ pc = _txt(req.pcode)
+ name = f"BOQ{pc}.DBF"
+ if name not in data["boq_tables"]:
+ raise HTTPException(status_code=404, detail=f"내역 표 없음: {name}")
+ prj = next((r for r in data["prjlst_all"] if _txt(r.get("PCODE")) == pc), None)
+ if prj is None:
+ raise HTTPException(status_code=404, detail=f"PRJLST 에 PCODE {pc} 공사 없음")
+ files = srv.read_project_files(req.source_path)
+ smt_kv = srv.parse_prjlst_smt(files.get("PRJLST.SMT", b""), prj.get("PRJ_MEMO_HEX", ""))
+ rows = data["boq_tables"][name]
+ sim = srv.convert_dbf_to_sim_state(data["costn_rows"], rows, smt_kv, prj, data["costn_memos"])
+ for k in ("condCodes", "rxFormula"):
+ sim["sysConfig"][k] = data["sim_state"]["sysConfig"].get(k, sim["sysConfig"].get(k))
+ data.update(boq_table_name=name, boq_rows=rows, boq_count=len(rows), prjlst=prj)
+ data.update(prjlst_smt=smt_kv, sim_state=sim)
+ return data
diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js
index 02f9c9757..357d91ebf 100644
--- a/tools/stmate-test/app.js
+++ b/tools/stmate-test/app.js
@@ -161,10 +161,7 @@
const kj = ctx.computed.kanjubComputed,
bt = ctx.computed.boqComputed.totals;
try {
- const saved = await postJson(
- "/api/save",
- decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)),
- );
+ const saved = await postSave(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)}원`,
@@ -174,6 +171,14 @@
}
}
+ // 저장 길 — 코드 관리(지움·코드 바꿈·다른 표 코드)가 있으면 /api/ui/save(구조 덧붙임 · api_ui_code.py) · 아니면 /api/save
+ function postSave(p) {
+ const UC = global.STMATE_UI_CODE;
+ if (UC && UC.needsUiSave(ctx.dbfData))
+ return postJson("/api/ui/save", Object.assign(p, UC.structPayload(ctx.dbfData)));
+ return postJson("/api/save", p);
+ }
+
// 저장 묶음 덧붙임 — 초기값(start_ini) · 공사목록 전체 행(prjlst_rows) · 서버 반영은 파일 담당
function decorateSavePayload(p) {
const d = ctx.dbfData;
@@ -437,7 +442,12 @@
Object.assign(API, { renderWorkspace, recompute, openModal, postJson, downloadBlob });
Object.assign(API, { cascadeAndRender, loadProjectFromServer, insertBoqRow });
- Object.assign(API, { saveAll: saveAllToDbfServer, decorateSavePayload, openMappedModal });
+ Object.assign(API, {
+ saveAll: saveAllToDbfServer,
+ decorateSavePayload,
+ openMappedModal,
+ postSave,
+ });
async function initStmateApp() {
onClick("modal-close-btn", closeModal);
diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js
index 8d9d08da8..c76e5a68d 100644
--- a/tools/stmate-test/calc.js
+++ b/tools/stmate-test/calc.js
@@ -358,6 +358,7 @@ const StmateCalc = (() => {
govFeeTotal = 0;
// 내역 비율 줄(PR) 밑수 — 표시(PRPT) 줄 금액 누적 · PR 줄을 지나면 비움(영월 조달수수료 4줄 대조)
let prBase = { M: 0, L: 0, S: 0 };
+ const doneById = {}; // 코드 붙은 % 줄 밑수용 — 앞 줄 금액
const rows = (data.boqItems || []).map((row) => {
const pr = prRow(row, slot, prBase);
if (pr) {
@@ -397,9 +398,21 @@ const StmateCalc = (() => {
L = roundDown(rawL, 0),
S = roundDown(rawS, 0),
T = M + L + S;
+ // 코드 붙은 % 줄(조달수수료 M%·T%) = 같은 상위 공종 안 앞 말단 줄 비목 합 × 율 → 제 비목(server pct_row_base)
+ if (row.pctBase) {
+ const k = row.pctComp,
+ base = row.pctBase.reduce((a, id) => a + ((doneById[id] || {})[k] || 0), 0);
+ const own = /^L/.test(row.code) ? "L" : /^S/.test(row.code) ? "S" : "M";
+ ((M = 0), (L = 0), (S = 0));
+ const amt = roundDown((base * num((row.prQtys || [])[slot - 1])) / 100, 0);
+ if (own === "L") L = amt;
+ else if (own === "S") S = amt;
+ else M = amt;
+ T = M + L + S;
+ }
let optimizDiff = 0,
optimizTarget = "";
- if (chnMode === "total") {
+ if (chnMode === "total" && !row.pctBase) {
// 총액우선(CHNNM=0) — REA 15 OPTIMIZ: 총액 절사 후 남는 1원을 소수부 큰 성분부터 하나씩
// (동률은 OPTNM 순서 앞쪽) · 산근·일위대가(smt_eval finish)와 같은 규칙
const got = optimize({ M: rawM, L: rawL, S: rawS }, rawT, optOrder);
@@ -413,6 +426,7 @@ const StmateCalc = (() => {
((M = got.M), (L = got.L), (S = got.S), (T = M + L + S));
}
}
+ doneById[row.id] = { M, L, S, T };
const aggM = roundHalfUp(rawM, 0),
aggL = roundHalfUp(rawL, 0),
aggS = roundHalfUp(rawS, 0);
diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html
index 5b1c9023c..c6e89af3f 100644
--- a/tools/stmate-test/index.html
+++ b/tools/stmate-test/index.html
@@ -53,6 +53,8 @@
+
+
@@ -63,6 +65,9 @@
+
+
+