diff --git a/resources/tester/helper_stmate_edit_bid.cjs b/resources/tester/helper_stmate_edit_bid.cjs new file mode 100644 index 000000000..c04ac2929 --- /dev/null +++ b/resources/tester/helper_stmate_edit_bid.cjs @@ -0,0 +1,26 @@ +// STMATE 재현 앱 산근 편집기 낙찰율 입력(scr_edit_san.setBidRate) → 계산완료(연쇄 재계산) — stdin {load, codes, rate} +globalThis.window = globalThis; +require("../../tools/stmate-test/calc.js"); +require("../../tools/stmate-test/smt_eval.js"); +require("../../tools/stmate-test/smt_engine.js"); +require("../../tools/stmate-test/view_prj_boq.js"); +const San = require("../../tools/stmate-test/scr_edit_san.js"); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const { load, codes, rate } = JSON.parse(buf); + const by = new Map(load.costn_rows.map((r) => [String(r.CODE).trim(), r])); + codes.forEach((c) => San.setBidRate(by.get(c), rate, true)); + window.STMATE_ENGINE.cascadeRecalculateAll({ dbfData: load, state: load.sim_state, uiState: {} }); + const cols = ["TAMT1", "MAMT1", "LAMT1", "SAMT1", "TAMT2", "MAMT2", "LAMT2", "SAMT2"]; + const out = {}; + codes.forEach( + (c) => + (out[c] = Object.assign( + { UC: by.get(c).UC }, + ...cols.map((k) => ({ [k]: Number(by.get(c)[k]) })), + )), + ); + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/helper_stmate_stage.cjs b/resources/tester/helper_stmate_stage.cjs index e7a3b51e7..a9b10578a 100644 --- a/resources/tester/helper_stmate_stage.cjs +++ b/resources/tester/helper_stmate_stage.cjs @@ -73,6 +73,19 @@ const MODES = { }); } if (args.target) out.found = Contract.findRate(tree, args.opts, args.target, args.stage || 1); + if (args.register) { + out.register = Contract.registerRows( + conv, + load.costn_rows, + load.costn_memos, + load.bdqty_rows, + ); + const cfg = Contract.defaultContractCfg( + state.kanjubStages[1] || state.kanjubConfig, + comp.stages[1].kanjub, + ); + out.register.costw1 = Contract.contractKanjub(conv, args.kanjubCfg || cfg).grandTotal; + } return out; }, progress({ load, args }) { diff --git a/resources/tester/helper_stmate_stage_save.cjs b/resources/tester/helper_stmate_stage_save.cjs new file mode 100644 index 000000000..44a326790 --- /dev/null +++ b/resources/tester/helper_stmate_stage_save.cjs @@ -0,0 +1,21 @@ +// STMATE 재현 앱 차수 칸(2·3·4) 고침 → 저장 요청(buildSavePayload) — stdin {load, ops:[{recno, stage, qty?, mls?}]} +globalThis.window = globalThis; +const Calc = require("../../tools/stmate-test/calc.js"); +require("../../tools/stmate-test/view_prj_boq.js"); +const Change = require("../../tools/stmate-test/change_calc.js"); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const { load, ops } = JSON.parse(buf); + const state = load.sim_state; + for (const op of ops || []) { + const it = state.boqItems.find((b) => b._recno === op.recno); + if (op.qty != null) Change.setQty(state, it.id, op.stage, op.qty); + if (op.mls) Change.setUnit(state, it.id, op.stage, op.mls); + } + const ctx = { state, dbfData: load, currentSourcePath: load.source_path }; + ctx.uiState = { editedSmtMap: {} }; + ctx.computed = Calc.calculateAll(state); + process.stdout.write(JSON.stringify(window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx))); +}); diff --git a/resources/tester/helper_stmate_ui.cjs b/resources/tester/helper_stmate_ui.cjs index 11eac218e..fd91daa3d 100644 --- a/resources/tester/helper_stmate_ui.cjs +++ b/resources/tester/helper_stmate_ui.cjs @@ -229,11 +229,41 @@ process.stdin.on("end", () => { 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"); + const hal = T.applyHalRate(m, [leaf._recno], { M: 103, L: 0, S: 0 }, "each"); + const halOut = [hal.changed, leaf.RM, it.halRate.M]; + // 0% = 100%(그대로) · 보호된 견적단가(W · UC=T) 건너뜀 + const zero = T.applyHalRate(m, [leaf._recno], { all: 0 }, "all"); + const halZero = Object.assign({}, it.halRate); + const w = { BDCODE1: "W00001", UC: "T", _recno: -77, SORTCODE: "1", ENDCODE: "0" }; + const wm = { rows: [w], items: [] }; + const kept = T.applyHalRate(wm, null, { all: 110 }, "all"); + const free = T.applyHalRate(wm, null, { all: 110 }, "all", { notUC: false }); + // 기준단가 × 할증율 → 행 1칸 단가(저장) = 계산 원천 단가(calc.js halRate) + const d2 = fresh(), + m2 = modelOf(d2); + const by = cbc(d2); + const lf = d2.boq_rows.find( + (r) => + B.isLeaf(r) && + by[B.codeOf(r)] && + Calc.num(by[B.codeOf(r)].MAMT1) > 0 && + !/%$/.test(String(r.UNIT1 || "").trim()), + ); + const base = (c) => + by[c] && { M: Calc.num(by[c].MAMT1), L: Calc.num(by[c].LAMT1), S: Calc.num(by[c].SAMT1) }; + T.applyHalRate(m2, [lf._recno], { all: 107.5 }, "all", { base }); + const cr = Calc.computeAll(d2.sim_state).boqComputed.rows.find((x) => x._recno === lf._recno); + out.halBase = { + row: [Calc.num(lf.MAMT1), Calc.num(lf.LAMT1), Calc.num(lf.SAMT1)], + want: ["M", "L", "S"].map((c) => Math.trunc((base(B.codeOf(lf))[c] * 107.5) / 100 + 1e-9)), + calc: cr ? [cr.mUnit, cr.lUnit, cr.sUnit] : null, + }; out.qty = { first: [r1.changed, ...q1], second: [r2.changed, Calc.num(leaf.QTY1)], - hal: [hal.changed, leaf.RM, it.halRate.M], + hal: halOut, + zero: [zero.changed, halZero.M, halZero.L, halZero.S], + w: [kept.changed, kept.kept, free.changed, w.RATE], }; } diff --git a/resources/tester/helper_stmate_ui_protect.cjs b/resources/tester/helper_stmate_ui_protect.cjs new file mode 100644 index 000000000..dba741116 --- /dev/null +++ b/resources/tester/helper_stmate_ui_protect.cjs @@ -0,0 +1,77 @@ +// STMATE 재현 앱 보호표시(UC) 일괄 손질 제외 · 코드종류별 성분 검사 — stdin = /api/load 결과 + {prot: [코드]} +// 결과 = 손질별 바뀐 코드(보호 없음 · 보호 있음) · 보호해제 자동할증 · 성분 검사 · 저장 요청 COSTN 칸 +globalThis.window = globalThis; +const D = "../../tools/stmate-test/"; +const Calc = require(D + "calc.js"); +require(D + "view_prj_boq.js"); +const UA = require(D + "ui_code_amt.js"); +const U = require(D + "ui_dan.js"); +const PA = require(D + "scr_edit_price.js"); +const CK = require(D + "ui_code_check.js"); + +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (x) => (buf += x)); +process.stdin.on("end", () => { + const src = JSON.parse(buf); + const prot = new Set(src.prot || []); + const rowsOf = (mark) => { + const rows = JSON.parse(JSON.stringify(src.costn_rows)); + if (mark) rows.forEach((r) => prot.has(U.codeOf(r)) && (r.UC = "T")); + return rows; + }; + const codes = (list) => list.map((x) => x.code).sort(); + const ops = { + adoptAll: (rows) => U.adoptAll(rows, "min", { kinds: "MLS", keepUser: false }), + edtHal: (rows) => U.edtHal(rows, { kinds: "MLS", pct: 110 }), + halAll: (rows) => UA.halAll(rows, { kinds: "MLSW", how: "rate", value: 5 }), + chgAmt: (rows) => UA.chgAmt(rows, { kinds: "MLSW", from: 1, to: 1, rate: 103 }), + adoptCand: (rows) => PA.adoptCandidates(rows, { mode: "min", slot: 1 }).changed, + }; + const out = { ops: {} }; + for (const [k, fn] of Object.entries(ops)) { + const a = fn(rowsOf(false)), + rows = rowsOf(true), + b = fn(rows); + const untouched = [...prot].every((c) => { + const r0 = src.costn_rows.find((r) => U.codeOf(r) === c), + r1 = rows.find((r) => U.codeOf(r) === c); + return ["MAMT1", "LAMT1", "SAMT1", "TAMT1"].every((f) => r0[f] === r1[f]); + }); + out.ops[k] = { plain: codes(a), marked: codes(b), untouched }; + } + // 보호해제 자동할증 — 그 행만 일괄할증과 같은 값 + { + const rows = rowsOf(true); + const c = [...prot][0]; + const r = rows.find((x) => U.codeOf(x) === c); + const hal = { how: "rate", value: 5, digits: 0, mode: "trunc" }; + const res = U.setProtect(r, false, { autoHal: hal }); + const ref = UA.halAll(rowsOf(false), Object.assign({ kinds: c[0], codes: [c] }, hal)); + out.autoHal = { uc: r.UC, got: res.hal && res.hal.to, want: ref[0] && ref[0].to }; + } + out.bdProtected = src.costn_rows.filter( + (r) => /^[BD]/.test(U.codeOf(r)) && U.isProtected(r), + ).length; + // 성분 검사 — 원본 그대로 · 일부러 어긋낸 행 + { + out.check = CK.checkComponents(src.costn_rows); + out.checkBoq = Object.values(src.boq_tables || {}).flatMap((rows) => CK.checkBoq(rows)); + const rows = rowsOf(false); + const m = rows.find((r) => /^M/.test(U.codeOf(r)) && Number(r.MAMT1) > 0); + const b = rows.find((r) => /^B/.test(U.codeOf(r)) && Number(r.TAMT1) > 0); + m.LAMT1 = "5"; + b.TAMT1 = String(Number(b.TAMT1) + 1); + out.broken = { m: U.codeOf(m), b: U.codeOf(b), res: CK.checkComponents(rows) }; + } + // 저장 요청 — 보호 켠 행만 COSTN UC 가 바뀜 + { + const load = JSON.parse(JSON.stringify(src)); + load.costn_rows.forEach((r) => prot.has(U.codeOf(r)) && U.setProtect(r, true)); + const ctx = { state: load.sim_state, dbfData: load, currentSourcePath: load.source_path }; + ctx.uiState = { editedSmtMap: {} }; + ctx.computed = Calc.calculateAll(ctx.state); + out.payload = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx); + } + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/helper_stmate_ui_r3.cjs b/resources/tester/helper_stmate_ui_r3.cjs new file mode 100644 index 000000000..e87b76b7a --- /dev/null +++ b/resources/tester/helper_stmate_ui_r3.cjs @@ -0,0 +1,79 @@ +// STMATE 재현 앱 화면 순수 계산 3차(직종·노임 적용코드 wM_Set_JLab · 공종번호 자동입력 wITNum) — stdin {mode, load, ...} +globalThis.window = globalThis; +const D = "../../tools/stmate-test/"; +["kanjub_parts.js", "kanjub_calc.js", "calc.js", "smt_eval.js"].forEach((f) => require(D + f)); +const S = require(D + "ui_sys.js"); +const B = require(D + "ui_boq.js"); +const T = require(D + "ui_boq_tools.js"); + +const MODES = { + // 제잡비 요율 축 콤보 — 화면과 같은 길(setKey → 제비율표 다시 조회 → 다시 셈) · 축마다 총공사비가 바뀐 선택지 수 + rateAxes({ load, axes }) { + ["kanjub_parts.js", "kanjub_calc.js", "scr_kanjub_axes.js", "scr_kanjub_rate.js"].forEach((f) => + require(D + f), + ); + require(D + "scr_kanjub.js"); + const K = window.STMATE_KANJUB_SCREEN, + R = window.StmateRate, + X = window.STMATE_KANJUB_AXES; + const Calc = window.STMATE_CALC; + const m = R.matrixFromRows(load.rate_tables["RATE_R.DBF"] || []); + const all = [...X.COMMON, ...X.OPTN]; + const grand = (st) => Calc.calculateAll(st).kanjubOrig.grandTotal; + const g0 = grand(JSON.parse(JSON.stringify(load.sim_state))); + const out = {}; + for (const key of axes) { + const ax = all.find((a) => a[0] === key); + out[key] = ax[2].map(([v]) => { + const st = JSON.parse(JSON.stringify(load.sim_state)); + const cfg = st.kanjubConfig; + cfg.keys = cfg.keys || {}; + K.setKey(cfg, key, v); + if (st.kanjubStages && st.kanjubStages[1]) st.kanjubStages[1] = cfg; + R.applyWithLoopCheck(cfg, m, () => Calc.calculateAll(st).kanjubOrig, { withTbugum: true }); + return grand(st) - g0; + }); + } + return out; + }, + jlab({ load }) { + const ini = load.start_ini || {}; + const hw = window.STMATE_SMT_EVAL.hourlyWage; + const j = S.jlab(load.costn_rows, ini, "CONLD", hw); + const add = j.source.length ? j.source[0].code : null; + const added = add ? S.jlabEdit(ini.CONLD, [add], []) : ini.CONLD; + return { + j, + add, + added, + back: S.jlabEdit(added, [], add ? [add] : []), + dup: S.jlabEdit(ini.CONLD, S.splitCodes(ini.CONLD), []), + jcd: Number(String(ini.JCDNM || "0").trim()) || 0, + }; + }, + itnum({ load, plans }) { + const out = {}; + for (const [t, plan] of Object.entries(plans)) { + const rows = load.boq_tables[t].map((r) => Object.assign({}, r)); + const keep = rows.map((r) => r.ITNUM); + rows.forEach((r) => (r.ITNUM = "")); + plan.forEach((o) => T.autoItnum(rows, o)); + out[t] = rows.map((r, i) => ({ + i, + kind: B.rowKind(r).kind, + n: B.rowKind(r).n, + got: rows[i].ITNUM, + want: keep[i], + })); + } + return out; + }, +}; + +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (x) => (buf += x)); +process.stdin.on("end", () => { + const inp = JSON.parse(buf); + process.stdout.write(JSON.stringify(MODES[inp.mode](inp))); +}); diff --git a/resources/tester/test_stmate_stage_register.py b/resources/tester/test_stmate_stage_register.py new file mode 100644 index 000000000..97ad943bc --- /dev/null +++ b/resources/tester/test_stmate_stage_register.py @@ -0,0 +1,287 @@ +"""STMATE 재현 앱 설계 → 계약자료 변환등록 끝까지(화면 계산 → 저장 → 다시 읽기) — 소광 원설계 STC → 울진 2025 계약 STC. + +같은 공사의 설계 원본(22-…소광.STC, PCODE 11 만)을 계약 변환·등록하면 실제 계약 STC(울진 2025)의 +PCODE 12 행 · BOQ12 · COSTN 새 코드 행(NAGCD 낙찰율 · 1칸 계약단가 · 2칸 원단가 · 산근 글 · 구성표)과 같아야 함. +입력(사용자 선택) = 적용율(계약 STC COSTN NAGCD 최빈값) · 적용제외 머리(계약에서도 코드가 그대로인 공종의 머리). +근거: 27 §2 · 26 §4 · 16 §4.5 · ui_form_catalog wM_Conv_Mode. +""" + +import json +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +sys.path.insert(0, str(Path(__file__).parent)) +import server # noqa: E402 +from api_ui_contract import ContractRegReq, contract_register # noqa: E402 +from bdqty import decode_bdqty # noqa: E402 +from kanjub_cfg import build_kanjub_config # noqa: E402 +from smt_parser import extract_costn_memos, parse_dbf_bytes, parse_prjlst_smt # noqa: E402 +from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_stage.cjs") +STRUCT = re.compile(r"^(T\d|E\d|C\d|:)$") +DESIGN = str(DOCS / STCS["소광원설계"]) +TRUTH = str(DOCS / STCS["울진2025"]) + + +def _f(v): + return float(v or 0) + + +def _leaves(rows): + return [r for r in rows if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"])] + + +@pytest.fixture(scope="module") +def case(): + load = server.api_load(server.LoadReq(source_path=DESIGN)) + truth = server.api_load(server.LoadReq(source_path=TRUTH)) + t12 = truth["boq_tables"]["BOQ12.DBF"] + rate = float( + Counter(r["NAGCD"] for r in truth["costn_rows"] if _f(r["NAGCD"])).most_common(1)[0][0] + ) + base = {} + for r in load["costn_rows"]: + m = re.match(r"^([A-Z])(\d+)$", r["CODE"].strip()) + if m: + base[m[1]] = max(base.get(m[1], 0), int(m[2])) + kept = {r["BDCODE1"] for r in _leaves(t12)} & {r["BDCODE1"] for r in load["boq_rows"]} + heads, cur = [], None + for r in load["boq_rows"]: + bd = r.get("BDCODE1") or "" + if bd.startswith("T"): + cur = r + elif bd.startswith("E") or bd.startswith("C"): + cur = None + elif cur is not None and bd in kept and cur["_recno"] not in heads: + heads.append(cur["_recno"]) + opts = {"rateL": rate, "rateM": rate, "rateS": rate, "excludeHeads": heads} + # 계약 원가설정 = 사용자가 계약 공사에 넣은 설정(계약 행 PRJLST.SMT) — 총공사비 대조용 + p12 = next(p for p in truth["prjlst_all"] if p["PCODE"] == "12") + smt = parse_prjlst_smt(server.read_project_files(TRUTH)["PRJLST.SMT"], p12["PRJ_MEMO_HEX"]) + args = {"opts": opts, "codeBase": base, "register": True} + args["kanjubCfg"] = build_kanjub_config(smt, 1) + raw = json.dumps({"load": load, "mode": "contract", "args": args}, ensure_ascii=False) + out = json.loads(subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8"))) + reg = out["register"] + req = ContractRegReq( + source_path=DESIGN, + src_pcode="11", + pname="계약", + boq_rows=reg["boq"], + costn_rows=reg["costn"], + costn_memos=reg["memos"], + bdqty_rows=reg["bdqty"], + costw1=reg["costw1"], + ) + files, pcode, _log = contract_register(server.read_project_files(DESIGN), req) + design = {r["CODE"].strip() for r in load["costn_rows"]} + return { + "files": files, + "pcode": pcode, + "truth": truth, + "t12": t12, + "reg": reg, + "design": design, + } + + +def test_new_contract_table_matches_boq12(case): + assert case["pcode"] == "12" + rows = parse_dbf_bytes(case["files"]["BOQ12.DBF"])[1] + got = [r for r in rows if r.get("BDCODE1") and r["BDCODE1"] != ":"] + exp = [r for r in case["t12"] if r.get("BDCODE1") and r["BDCODE1"] != ":"] + assert len(got) == len(exp) + cols = ("BDCODE1", "QTY1", "MAMT1", "LAMT1", "SAMT1", "T_AMT1", "M_AMT1", "L_AMT1", "S_AMT1") + for g, e in zip(got, exp): + for k in cols: + a, b = g.get(k), e.get(k) + assert (a == b) if k == "BDCODE1" else _f(a) == _f(b), (k, e.get("NAME1"), a, b) + + +def test_new_prjlst_row_matches_contract_row(case): + prj = parse_dbf_bytes(case["files"]["PRJLST.DBF"])[1] + got = next(p for p in prj if p["PCODE"] == "12") + exp = next(p for p in case["truth"]["prjlst_all"] if p["PCODE"] == "12") + for k in ("CCODE", "PCLASS", "MCOST1", "LCOST1", "SCOST1", "TCOST1", "COSTW1"): + assert _f(got[k]) == _f(exp[k]), k + assert _f(got["COSTW1"]) == case["reg"]["costw1"] > 0 + + +def test_new_costn_rows_nagcd_and_prices(case): + files = case["files"] + rows = {r["CODE"].strip(): r for r in parse_dbf_bytes(files["COSTN.DBF"])[1]} + truth = {r["CODE"].strip(): r for r in case["truth"]["costn_rows"]} + assert sorted(c for c in truth if c not in rows) == [] # 계약 STC 의 코드가 모두 생김 + made = [c["CODE"] for c in case["reg"]["costn"]] + assert sorted(made) == sorted(set(truth) - case["design"]) # 새 코드 = 계약 STC 에만 있는 코드 + cols = [f"{c}AMT{s}" for s in "12" for c in "TMLS"] + ["NAGCD"] + for c in made: + g, e = rows[c], truth[c] + for k in ("NAME", "SIZE", "UNIT"): + assert g[k] == e[k], (c, k) + for k in cols: + assert _f(g[k]) == _f(e[k]), (c, k, g[k], e[k]) + for k in ("UC", "BCONT"): + assert (str(g[k]).strip() == "T") == (str(e[k]).strip() == "T"), (c, k) + memos = extract_costn_memos(files["COSTN.DBF"], files["COSTN.SMT"]) + tm = case["truth"]["costn_memos"] + assert {c for c in made if c in memos} == {c for c in made if c in tm} + for c in made: + if c in tm: + assert memos[c]["text"] == tm[c]["text"], c + bq = decode_bdqty(files["BDQTY.DBF"]) + tq = case["truth"]["bdqty_rows"] + for c in made: + g = [(q["CODE"], _f(q["QTY1"])) for q in bq if q["BDCODE"].strip() == c] + e = [(q["CODE"], _f(q["QTY1"])) for q in tq if q["BDCODE"].strip() == c] + assert g == e, c + + +def _conv(path, opts, stage=1): + load = server.api_load(server.LoadReq(source_path=path)) + base = {} + for r in load["costn_rows"]: + m = re.match(r"^([A-Z])(\d+)$", r["CODE"].strip()) + if m: + base[m[1]] = max(base.get(m[1], 0), int(m[2])) + args = {"opts": opts, "codeBase": base, "register": True, "stage": stage} + raw = json.dumps({"load": load, "mode": "contract", "args": args}, ensure_ascii=False) + return load, json.loads( + subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8")) + ) + + +def test_unchecked_options_keep_base_and_make_w_codes(): + """기초단가 적용율 끔 = M·L·S 코드·단가 그대로 · 일위/산근 생성 끔 = B·D·X → 계약단가 W코드(낙찰율 열 없음).""" + r = 87.745 + _load, out = _conv( + DESIGN, {"rateL": r, "rateM": r, "rateS": r, "baseRate": False, "makeIlwi": False} + ) + leaves = [x for x in out["rows"] if x["kind"] == "leaf" and not x["excluded"]] + base = [x for x in leaves if x["code"][0] in "MLS"] + comp = [x for x in leaves if x["code"][0] in "BDX" and x["dUnit"]] + assert base and comp + assert all(x["newCode"] == x["code"] and x["tUnit"] == x["dUnit"] for x in base) + assert all(x["newCode"][0] == "W" for x in comp) + assert any(x["tUnit"] < x["dUnit"] for x in comp) + rows = {c["CODE"]: c for c in out["register"]["costn"]} + assert {x["newCode"] for x in comp} <= set(rows) + for c in rows.values(): + assert c["CODE"][0] == "W" and "NAGCD" not in c + assert [c[f"{k}AMT1"] for k in "TMLS"] == [c[f"{k}AMT2"] for k in "TMLS"] + assert out["register"]["memos"] == {} and out["register"]["bdqty"] == [] + + +def test_change_stage_quantities_registered(): + """변경 수량/단가 적용(cBx_Sel_QTY) — 영덕 변경 2차수 수량·단가로 계약 공사 · 새 표 직접비 = 계약 직접비.""" + path = str(DOCS / STCS["영덕변경"]) + load, out = _conv(path, {"rateL": 90, "rateM": 90, "rateS": 90}, stage=2) + reg = out["register"] + req = ContractRegReq( + source_path=path, + boq_rows=reg["boq"], + costn_rows=reg["costn"], + costn_memos=reg["memos"], + bdqty_rows=reg["bdqty"], + ) + files, pcode, _log = contract_register(server.read_project_files(path), req) + rows = {r["_recno"]: r for r in parse_dbf_bytes(files[f"BOQ{pcode}.DBF"])[1]} + leaves = [x for x in out["rows"] if x["kind"] == "leaf" and x["recno"] is not None] + src = {r["_recno"]: r for r in load["boq_rows"]} + moved = 0 + for x in leaves: + g = rows[x["recno"]] + if not x["excluded"]: + assert g["BDCODE1"] == x["newCode"] and _f(g["QTY1"]) == x["qty"], x["name"] + moved += _f(src[x["recno"]]["QTY1"]) != _f(src[x["recno"]]["QTY2"]) + assert moved > 0 # 당초와 다른 변경 수량이 실제로 1칸에 들어감 + prj = next(p for p in parse_dbf_bytes(files["PRJLST.DBF"])[1] if p["PCODE"] == pcode) + assert _f(prj["TCOST1"]) == out["totals"]["directTotal"] + + +def test_editor_bid_rate_input_matches_contract_codes(): + """산근·대가 편집기 낙찰율 입력(Ed_NAGCD · Ck_UC) → 계산완료 — 설계 코드 1칸·2칸 = 계약 STC 새 코드(NAGCD) 1칸·2칸.""" + design = server.api_load(server.LoadReq(source_path=DESIGN)) + truth = server.api_load(server.LoadReq(source_path=TRUTH)) + t11, t12 = truth["boq_tables"]["BOQ11.DBF"], truth["boq_tables"]["BOQ12.DBF"] + tc = {r["CODE"].strip(): r for r in truth["costn_rows"]} + pair = { + a["BDCODE1"]: b["BDCODE1"] + for a, b in zip(_leaves(t11), _leaves(t12)) + if a["BDCODE1"] != b["BDCODE1"] and _f(tc[b["BDCODE1"]]["NAGCD"]) > 0 + } + (rate,) = {_f(tc[c]["NAGCD"]) for c in pair.values()} + raw = json.dumps({"load": design, "codes": list(pair), "rate": rate}, ensure_ascii=False) + helper = Path(__file__).with_name("helper_stmate_edit_bid.cjs") + got = json.loads(subprocess.check_output(["node", str(helper)], input=raw.encode("utf-8"))) + assert len(pair) >= 20 + for d, c in pair.items(): + for k in ("TAMT1", "MAMT1", "LAMT1", "SAMT1", "TAMT2", "MAMT2", "LAMT2", "SAMT2"): + assert got[d][k] == _f(tc[c][k]), (d, c, k) + assert got[d]["UC"] == "T" + + +def _prj(files): + return parse_dbf_bytes(files["PRJLST.DBF"])[1] + + +def test_delete_project_drops_row_and_table_only(): + """내역서 삭제(Act_Del_Prj) — 고른 공사 PRJLST 행·BOQ1n 만 빠지고 나머지는 그대로 · 마지막 공사는 거부.""" + from api_ui_project import delete_project + + files = server.read_project_files(TRUTH) + out, log = delete_project(files, "12") + assert "BOQ12.DBF" not in out and [p["PCODE"] for p in _prj(out)] == ["11"] + before = next(p for p in _prj(files) if p["PCODE"] == "11") + after = _prj(out)[0] + assert {k: v for k, v in after.items() if k != "_recno"} == { + k: v for k, v in before.items() if k != "_recno" + } + assert all(out[k] is files[k] for k in files if k not in ("PRJLST.DBF", "BOQ12.DBF")) + assert {x["table"] for x in log} == {"PRJLST.DBF", "BOQ12.DBF"} + with pytest.raises(ValueError): + delete_project(out, "11") + + +def test_stage_register_k_ord_matrix(): + """단계 등록 K_ORD — 원본 PCLASS·등록(6 기성 · 7 설계변경)·플래그(0 차수별 · 1 당초/변경) 표(REA 31) 그대로.""" + from api_ui_project import new_k_ord, stage_register + + files = server.read_project_files(TRUTH) # PCODE 12 = 계약(PCLASS 4 · K_ORD 0) + a, pa, ka = stage_register(files, "12", 7, 0) + ra = next(p for p in _prj(a) if p["PCODE"] == pa) + assert (ka, _f(ra["K_ORD"]), _f(ra["PCLASS"])) == (1, 1, 7) + b12 = parse_dbf_bytes(files["BOQ12.DBF"])[1] + ba = parse_dbf_bytes(a[f"BOQ{pa}.DBF"])[1] + assert [r["T_AMT1"] for r in ba] == [r["T_AMT1"] for r in b12] # 계약 내역이 당초 + # 설계변경(PCLASS 7 · K 1) → 차수별 = K+1 · 당초/변경 = 1(마지막 차수 2칸 → 새 1·2칸) + b, pb, kb = stage_register(a, pa, 7, 0) + assert kb == 2 and _f(next(p for p in _prj(b) if p["PCODE"] == pb)["K_ORD"]) == 2 + src = parse_dbf_bytes(a[f"BOQ{pa}.DBF"])[1] + c, pc, kc = stage_register(a, pa, 7, 1) + rows = parse_dbf_bytes(c[f"BOQ{pc}.DBF"])[1] + assert kc == 1 + for s, n in zip(src, rows): + assert _f(n["QTY1"]) == _f(n["QTY2"]) == _f(s["QTY2"]) + rc = next(p for p in _prj(c) if p["PCODE"] == pc) + from dbf_rw import DbfTable + from stc_save import _leaf, boq_spans + + _sp, excl = boq_spans(DbfTable(a[f"BOQ{pa}.DBF"])) + leaf2 = sum(_f(r["T_AMT2"]) for i, r in enumerate(src) if _leaf(r["BDCODE1"]) and i not in excl) + assert ( + _f(rc["TCOST1"]) == _f(rc["TCOST2"]) == leaf2 + ) # 새 당초·변경 직접비 = 원본 변경 칸 직접비 + # 기성 — 계약에서 1 · 기성에서 K+1 · 설계변경에서 1 + assert [new_k_ord(4, 6, 0, 3), new_k_ord(6, 6, 0, 3), new_k_ord(7, 6, 1, 3)] == [1, 4, 1] + assert [new_k_ord(4, 7, 0, 3), new_k_ord(6, 7, 0, 3), new_k_ord(7, 7, 0, 3)] == [1, 1, 4] + d, pd, kd = stage_register(files, "12", 6, 0) + assert kd == 1 and _f(next(p for p in _prj(d) if p["PCODE"] == pd)["PCLASS"]) == 6 diff --git a/resources/tester/test_stmate_stage_slots.py b/resources/tester/test_stmate_stage_slots.py new file mode 100644 index 000000000..053bee593 --- /dev/null +++ b/resources/tester/test_stmate_stage_slots.py @@ -0,0 +1,75 @@ +"""STMATE 재현 앱 차수 4벌 칸(QTY·단가 2~4) 고침 → 저장 → 다시 읽기 — 고친 칸만 바뀌고 금액·소계·직접비가 따라감. + +설계변경 화면의 차수 수량·변경 단가(change_calc setQty·setUnit) → buildSavePayload → stc_save.apply_save. +근거: 08 §2 · 24 §7(차수 4벌) · 16 §4(행 금액 = ROUNDDOWN(수량×단가) · 계층 집계 · TCOSTn = 직접비). +""" + +import json +import subprocess +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")) +import server # noqa: E402 +from dbf_rw import DbfTable # noqa: E402 +from stc_save import _leaf, apply_save, boq_spans # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_stage_save.cjs") +STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC")) +IDS = [p.stem[:16] for p in STCS] + + +def _d(v): + return Decimal(str(v or "0").strip() or "0") + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_slot_2_3_4_edits_persist(stc): + load = server.api_load(server.LoadReq(source_path=str(stc))) + bt_name = load["boq_table_name"] + _spans, excl = boq_spans(DbfTable(server.read_project_files(str(stc))[bt_name])) + leaf = next( + r + for r in load["boq_rows"] + if _leaf(r["BDCODE1"].strip()) + and _d(r["TAMT1"]) > 0 + and not r["UNIT1"].strip().endswith("%") + and r["_recno"] not in excl + ) + rec = leaf["_recno"] + q = {n: float(_d(leaf["QTY1"])) + n for n in (2, 3, 4)} + mls = [float(_d(leaf["MAMT1"])) + 7, float(_d(leaf["LAMT1"])) + 11, float(_d(leaf["SAMT1"]))] + ops = [{"recno": rec, "stage": n, "qty": q[n]} for n in (2, 3, 4)] + ops.append({"recno": rec, "stage": 3, "mls": mls}) + raw = json.dumps({"load": load, "ops": ops}, default=str, ensure_ascii=False) + p = json.loads(subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8"))) + files = server.read_project_files(str(stc)) + out, log = apply_save(files, p) + bt = DbfTable(out[bt_name]) + for n in (2, 3, 4): + assert _d(bt.get(rec, f"QTY{n}")) == Decimal(str(q[n])) + assert [_d(bt.get(rec, f"{c}AMT3")) for c in "MLS"] == [Decimal(str(v)) for v in mls] + for c, v in zip("MLS", mls): # 행 금액 = ROUNDDOWN(수량 × 단가) + want = (Decimal(str(q[3])) * Decimal(str(v))).quantize(Decimal(1), rounding=ROUND_DOWN) + assert _d(bt.get(rec, f"{c}_AMT3")) == want + # 다른 행 원래 칸 그대로 — 고친 행 · 소계(머리·계) 행만 바뀜 + spans, _ = boq_spans(bt) + heads = {i for i, *_ in spans} + assert {x["recno"] for x in log if x["table"] == bt_name} <= {rec} | heads + assert not any(x["field"].endswith("1") for x in log if x["table"] == bt_name) + # PRJLST 차수 직접비 = 그 차수 말단 합 + prj = DbfTable(out["PRJLST.DBF"]) + pi = next(i for i in range(len(prj)) if prj.get(i, "PCODE") == bt_name[3:5]) + for n in (3, 4): + if f"TCOST{n}" not in prj.fields: + continue + tot = sum( + _d(bt.get(i, f"T_AMT{n}")) + for i in range(len(bt)) + if _leaf(bt.get(i, "BDCODE1")) and i not in excl + ) + assert _d(prj.get(pi, f"TCOST{n}")) == tot diff --git a/resources/tester/test_stmate_ui.py b/resources/tester/test_stmate_ui.py index b0d74db20..efca99f08 100644 --- a/resources/tester/test_stmate_ui.py +++ b/resources/tester/test_stmate_ui.py @@ -108,7 +108,12 @@ 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] + # 할증율 = 기준단가에 곱할 백분율(TwBoqRate · 0% = 100%) — 103% → 계산 원천 가산 3% + assert r["qty"]["hal"] == [1, "103", 3] + assert r["qty"]["zero"] == [1, 0, 0, 0] + assert r["qty"]["w"] == [0, 1, 1, "110"] # 보호된 견적단가 수정안함(CBoxNotUC) + hb = r["halBase"] + assert hb["row"] == hb["want"] and hb["calc"] == hb["want"] # 저장 단가 = 화면 계산 단가 @pytest.mark.parametrize("name", list(PROJECTS)) diff --git a/resources/tester/test_stmate_ui_protect.py b/resources/tester/test_stmate_ui_protect.py new file mode 100644 index 000000000..2da1696da --- /dev/null +++ b/resources/tester/test_stmate_ui_protect.py @@ -0,0 +1,90 @@ +"""STMATE 재현 앱 보호표시(UC) 일괄 손질 제외 · 단가 성분 검사(TAMT = 재+노+경 · 코드종류별 비목). + +보호 = wM_Boxa 단가 격자 「보호」 열(√/공란 = True/False) → 기초·견적단가(M·L·S·W) 행 COSTN UC. +일괄 손질(가격지 채택 · 기준단가 수정 · 일괄할증 · 일괄변경 · 후보 채택)은 보호 행을 건너뜀(22 §3.1 안내문). +B·D 행 UC 는 산근 편집기 「결과값에 낙찰율적용」(Ck_UC) 자리 — 실무 7건 UC=T ⇔ NAGCD>0. +성분 검사 = 10 §3 검증 조건식 — 실무 7건 COSTN·내역 어긋남 0 · 일부러 어긋낸 칸만 잡힘. +""" + +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 +from stc_save import apply_save # noqa: E402 + +HERE = Path(__file__).parent +STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC")) +IDS = [p.stem[:16] for p in STCS] +_CACHE: dict = {} + + +def _f(v): + return float(v or 0) + + +def run(stc): + if stc not in _CACHE: + d = server.api_load(server.LoadReq(source_path=str(stc))) + # 보호 대상 = 바뀔 수 있는 기초단가 몇 건(후보 있는 재료 · 노무 · 경비) — 자료에서 고름 + mls = [r for r in d["costn_rows"] if r["CODE"][:1] in "MLS" and _f(r["TAMT1"]) > 0] + cand = [r for r in mls if any(_f(r.get(f"AMT_{i}1")) > 0 for i in range(1, 6))] + prot = [r["CODE"].strip() for r in (cand[:3] + [r for r in mls if r not in cand][:2])] + d["prot"] = prot + out = subprocess.check_output( + ["node", str(HERE / "helper_stmate_ui_protect.cjs")], + input=json.dumps(d, ensure_ascii=False, default=str), + text=True, + encoding="utf-8", + ) + _CACHE[stc] = (d, json.loads(out)) + return _CACHE[stc] + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_batch_edits_skip_protected_rows(stc): + d, r = run(stc) + prot = set(d["prot"]) + assert prot + moved = 0 + for name, o in r["ops"].items(): + assert o["untouched"], name # 보호 행 단가 그대로 + assert o["marked"] == sorted(set(o["plain"]) - prot), name # 나머지는 보호 없을 때와 같음 + moved += len(set(o["plain"]) & prot) + assert moved > 0 # 보호가 없었으면 바뀌었을 행이 실제로 있었음 + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_unprotect_auto_hal_and_bd_uc_is_bid_rate_flag(stc): + d, r = run(stc) + a = r["autoHal"] + assert a["uc"] == "F" and a["got"] is not None and a["got"] == a["want"] + assert r["bdProtected"] == 0 # 산근·일위 UC=T 는 보호로 보지 않음 + for row in d["costn_rows"]: # 근거 — UC=T ⇔ 낙찰율 열(NAGCD) > 0 + if row["CODE"][:1] in "BD": + assert (str(row.get("UC") or "").strip() == "T") == (_f(row.get("NAGCD")) > 0) + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_protect_mark_saves_only_uc(stc): + d, r = run(stc) + files = server.read_project_files(str(stc)) + _out, log = apply_save(files, r["payload"]) + got = {(x["table"], x["field"]) for x in log} + assert got == {("COSTN.DBF", "UC")} + assert len(log) == len(d["prot"]) + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_component_check_clean_on_real_and_catches_broken(stc): + _d, r = run(stc) + assert r["check"] == [] + assert r["checkBoq"] == [], r["checkBoq"][:3] + b = r["broken"] + hits = {(x["code"], x["slot"], x["rule"]) for x in b["res"]} + assert hits == {(b["m"], 1, "own"), (b["m"], 1, "sum"), (b["b"], 1, "sum")} diff --git a/resources/tester/test_stmate_ui_round3.py b/resources/tester/test_stmate_ui_round3.py new file mode 100644 index 000000000..72eab0d3b --- /dev/null +++ b/resources/tester/test_stmate_ui_round3.py @@ -0,0 +1,250 @@ +"""STMATE 재현 앱 화면 3차 — 직종·노임 적용코드(wM_Set_JLab) · 공종번호 자동입력(wITNum 하위 전체공종 반영). + +JLab: 적용코드 = Start8x.Ini CONLD(6자리 코드 이어 붙임 · REA 14 TWM_SET_SYS/TWM_SET_JLAB) · 적용단가 = 일 노임 × 환산식 + · 추가·삭제 왕복 = 원래 글 · 저장 = Start8x.Ini 그 키만. +wITNum: cBx_Bl_Lev 「하위 전체공종 반영」 = 묶음 안 공종(계표시)·단가코드를 한 차례로 · 직전공종 번호 + 첫 글자 + 번호. +""" + +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 +from smt_parser import parse_ini_bytes # noqa: E402 +from stc_save import apply_save # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_ui_r3.cjs") +STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC")) +IDS = [p.stem[:16] for p in STCS] + + +def node(payload): + raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") + return json.loads(subprocess.check_output(["node", str(HELPER)], input=raw)) + + +def _f(v): + return float(v or 0) + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_jlab_codes_hourly_and_save(stc): + load = server.api_load(server.LoadReq(source_path=str(stc))) + ini = load["start_ini"] + r = node({"mode": "jlab", "load": load}) + conld = ini.get("CONLD", "") + assert [t["code"] for t in r["j"]["target"]] == [ + conld[i : i + 6] for i in range(0, len(conld), 6) + ] + assert r["j"]["formula"] == ini.get("RXXNM", "").strip() + costn = {x["CODE"].strip(): x for x in load["costn_rows"]} + for t in r["j"]["target"]: # 적용단가 = 일 노임 × 환산식(왼쪽부터) 버림 + assert t["price"] == _f(costn.get(t["code"], {}).get("LAMT1")) + assert t["hourly"] <= t["price"] + assert all(s["code"][0] == "L" and s["code"] not in conld for s in r["j"]["source"]) + assert r["back"] == conld and r["dup"] == conld + if not r["add"]: + pytest.skip("고를 노무비 코드 없음") + assert r["added"] == conld + r["add"] + files = server.read_project_files(str(stc)) + out, log = apply_save(files, {"source_path": str(stc), "start_ini": {"CONLD": r["added"]}}) + names = [n for n in ("START8X.INI", "STINI.MEM") if n in files] + assert names and {(x["table"], x["field"]) for x in log} == {(n, "CONLD") for n in names} + for n in names: + assert parse_ini_bytes(out[n])["CONLD"] == r["added"] + + +# 공종번호 계획 = 사용자가 wITNum 에서 고를 선택지(뿌리 레벨 · 끝 글자 · 대상 · 마디 한도)와 선택 범위(그 머리 묶음) +# under = [레벨, n번째] — 그 레벨 n번째 머리 묶음만 선택 범위로 +def _tree(level, last, target="group", **k): + return {"tree": True, "level": level, "last": last, "target": target, **k} + + +ITNUM_PLANS = { + "13-2024년 산불진화임도 실시": {"BOQ11.DBF": [_tree(4, ".")]}, + "2024년 임도시설사업2차(산불": { + "BOQ11.DBF": [ + _tree(3, "", "both", maxDepth=3, under=[4, 1]), + _tree(3, "", "both", maxDepth=3, under=[4, 2]), + _tree(3, ".", "both", maxDepth=3, under=[4, 3]), + _tree(3, ".", "both", maxDepth=3, under=[4, 4]), + ] + }, + "2024년 간선임도사업(기번3)": {"BOQ11.DBF": [_tree(3, ".")]}, + "2025년 계류보전사업(기번1 ": {"BOQ11.DBF": [_tree(3, ".")]}, + "22-2025년 산불진화임도사업(": {"BOQ11.DBF": [_tree(3, ".")]}, + "2025년 산불진화임도 신설사업": {"BOQ11.DBF": [_tree(3, ".")], "BOQ12.DBF": [_tree(3, ".")]}, + "변경 1차분-2025년 산불진화임": {"BOQ11.DBF": [_tree(3, ".")]}, +} +STRUCT_E = ("E", "C") + + +def _ranges(rows, plan): + """under → range(그 머리 행 ~ 짝 닫는 행).""" + out = [] + for o in plan: + o = dict(o) + if "under" in o: + lv, nth = o.pop("under") + heads = [i for i, r in enumerate(rows) if (r.get("BDCODE1") or "").strip() == f"T{lv}"] + i = heads[nth - 1] + end = next( + j for j, r in enumerate(rows) if j > i and r.get("SORTCODE") == rows[i]["ENDCODE"] + ) + o["range"] = [i, end] + out.append(o) + return out + + +def _groups(res, rows): + """번호 붙은 행을 바로 위 머리(부모)별 차례로 묶음.""" + stack, groups, parent_of = [], {}, {} + ends = {} + for i, r in enumerate(rows): + bd = (r.get("BDCODE1") or "").strip() + while stack and i > ends[stack[-1]]: + stack.pop() + if bd[:1] in STRUCT_E or not bd or bd == ":": + continue + par = stack[-1] if stack else -1 + if res[i]["want"].strip(): + groups.setdefault(par, []).append(i) + parent_of[i] = par + if bd[:1] == "T" and len(bd) == 2: + end = next( + (j for j, q in enumerate(rows) if j > i and q.get("SORTCODE") == r["ENDCODE"]), i + ) + ends[i] = end + stack.append(i) + return groups, parent_of + + +def _clean_prefix(res, idx, parent_want): + """묶음 앞에서부터 원본 규칙 번호인 행 — 숫자 없음·겹침·빈 번호·순서 바뀜·직전공종 번호 다름에서 멈춤.""" + import re as _re + + ok = [] + for k, i in enumerate(idx, 1): + w = res[i]["want"].strip() + m = _re.match(r"^(.*?)(\d+)\.?$", w) + if not m or int(m[2]) != k: + break + head = m[1].strip().rstrip(".") + if head != parent_want.strip().rstrip("."): + break + ok.append(i) + return ok + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_itnum_tree_reproduces_every_rule_number(stc): + load = server.api_load(server.LoadReq(source_path=str(stc))) + plans = next(v for k, v in ITNUM_PLANS.items() if stc.stem.startswith(k)) + tables = {t: _ranges(load["boq_tables"][t], p) for t, p in plans.items()} + out = node({"mode": "itnum", "load": load, "plans": tables}) + total = checked = 0 + for t, res in out.items(): + rows = load["boq_tables"][t] + groups, _par = _groups(res, rows) + good = set() + # 뿌리 쪽부터 — 부모 번호가 원본 규칙 번호로 맞았거나 규칙 꼴이 아닌 머리(□ · [1] 등 = 손 입력)일 때 하위 묶음 대조 + for par in sorted(groups): + pw = res[par]["want"] if par != -1 else "" + if _re_digit(pw) and par not in good: + continue + for i in _clean_prefix(res, groups[par], pw if _re_digit(pw) else ""): + assert res[i]["got"] == res[i]["want"], (t, i, res[i]) + good.add(i) + total += sum(1 for r in res if r["want"].strip() and r["kind"] in ("T", "LEAF")) + checked += len(good) + # 원본 번호 대부분이 규칙 번호 — 나머지는 손으로 고친 번호(□ · 겹침 · 지운 공종 빈자리 뒤) + assert checked >= 0.75 * total, (checked, total) + + +def _re_digit(s): + """규칙 꼴 번호(…숫자 · …숫자.) — 숫자 없는 □ · 「[1]」 같은 손 입력 머리는 아님.""" + import re as _re + + return bool(_re.match(r"^\s*(.*?)(\d+)\.?$", str(s))) + + +RATE_AXES = [ + "I_KONG_TYPE", + "I_KONG_KIKAN", + "I_ANJUN_TYPE", + "I_KOBO_TYPE", + "I_HWAN", + "I_EQIP", + "I_TBUGUM", + "I_ILBAN", +] + + +def test_rate_axis_combos_recalculate(): + """제잡비 화면 요율 축 콤보(wM_KanJub) — 바꾸면 제비율표(RATE_R) 다시 조회 → 총공사비가 다시 셈해짐(실무 7건 중 하나 이상).""" + moved = {k: 0 for k in RATE_AXES} + for stc in STCS: + load = server.api_load(server.LoadReq(source_path=str(stc))) + if not load["rate_tables"].get("RATE_R.DBF"): + continue + out = node({"mode": "rateAxes", "load": load, "axes": RATE_AXES}) + for k, deltas in out.items(): + assert all(isinstance(d, (int, float)) for d in deltas), k + moved[k] += sum(1 for d in deltas if d != 0) + assert all(v > 0 for v in moved.values()), moved + + +@pytest.mark.parametrize("stc", STCS, ids=IDS) +def test_rate_table_edit_saves_only_changed_cell(stc): + """제비율표 수정(wM_KAN_RATE) → /api/ui/save rate_tables — 고친 칸만 RATE_R.DBF 에 · 안 고치면 바이트 그대로.""" + from api_ui_code import apply_struct + from smt_parser import parse_dbf_bytes + + load = server.api_load(server.LoadReq(source_path=str(stc))) + rows = load["rate_tables"].get("RATE_R.DBF") + if not rows: + pytest.skip("RATE_R 없음") + files = server.read_project_files(str(stc)) + same, log0 = apply_struct(files, {"rate_tables": {"RATE_R.DBF": rows}}) + assert log0 == [] and same["RATE_R.DBF"] is files["RATE_R.DBF"] + edit = [dict(r) for r in rows] + r, k = 20, "RATE_01" + edit[r][k] = str(_f(edit[r][k]) + 1.25) + out, log = apply_struct(files, {"rate_tables": {"RATE_R.DBF": edit}}) + assert [(x["table"], x["recno"], x["field"]) for x in log] == [("RATE_R.DBF", r, k)] + back = parse_dbf_bytes(out["RATE_R.DBF"])[1] + assert _f(back[r][k]) == _f(rows[r][k]) + 1.25 + assert all(back[i] == rows[i] for i in range(len(rows)) if i != r) + + +def test_editor_function_list_and_highlight_follow_dfm(): + """산출근거 편집기 함수 단추(cBoxFunc)·문법 강조 낱말(Syn1.KeyWords) = 원본 TWM_EDIT_SAN DFM.""" + dfm = ( + ROOT / "resources/knowledge/original/경쟁사 프로그램/STmate/REA 분석/" + "30_수량문자열_수신편집기_작업자료_20261026/ST_EdSan/resource-TWM_EDIT_SAN.dfm.json" + ) + nodes = {n.get("name"): n for n in json.loads(dfm.read_text(encoding="utf-8"))["nodes"]} + funcs = [x.strip() for x in nodes["cBoxFunc"]["properties"]["Items.Strings"][1:]] + keys = nodes["Syn1"]["properties"]["KeyWords.Strings"] + js = ( + "globalThis.window=globalThis;" + "const L=require('../../tools/stmate-test/scr_edit_lang.js');" + "const X=require('../../tools/stmate-test/scr_edit_expr.js');" + "const ks=L.SYN_KEYWORDS.split(' ');" + "process.stdout.write(JSON.stringify({f:X.FUNC_LIST,k:ks," + "h:ks.map(k=>L.highlight(k.toLowerCase()+'(1)')),v:L.highlight('Qo = 1')}))" + ) + out = json.loads( + subprocess.check_output( + ["node", "-e", js], cwd=str(HELPER.parent), text=True, encoding="utf-8" + ) + ) + assert out["f"] == funcs + assert out["k"] == keys + assert all('class="syn-key"' in h for h in out["h"]) + assert 'class="syn-var"' in out["v"] and "syn-key" not in out["v"] diff --git a/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md index 976f4fab6..8d47ccc97 100644 --- a/tools/stmate-test/PLAN.md +++ b/tools/stmate-test/PLAN.md @@ -20,6 +20,7 @@ - `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_ui_protect.py` · `test_stmate_ui_round3.py` · `test_stmate_stage_slots.py` · `test_stmate_stage_register.py` · `test_stmate_report_link.py` — 보호표시 · 공종번호·직종·제비율표 · 차수 칸 저장 · 계약 변환등록 = 실제 울진 계약 STC · 목차·인쇄 옵션 - `test_stmate_file_*.py` — 실무 STC 7건 무손실 왕복(손대지 않은 저장 = 원본 바이트) · 고친 칸만 바뀜 · 새 공사·복사등록·해당분 압축 - `test_stmate_stage_*.py` — 설계변경(영덕·소광 변경 BOQ·엑셀 대조) · 계약 변환(울진 BOQ12 계약내역·총공사비 1원) · 기성·실행(앞뒤 맞춤만) - `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로 @@ -28,15 +29,15 @@ ## 진행률 -- 합계: 전체 246 · 구현 173 · 부분 68 · 없음 5 → 진행률 84.1%((173 + 68×0.5) / 246) · 구현만 세면 70.3% +- 합계: 전체 246 · 구현 190 · 부분 51 · 없음 5 → 진행률 87.6%((190 + 51×0.5) / 246) · 구현만 세면 77.2% -### G1 화면·메뉴 (구현 37 · 부분 16 · 없음 1) — 83.3% +### G1 화면·메뉴 (구현 47 · 부분 6 · 없음 1) — 92.6% - [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui)) - [x] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 구현(공사목록표 · 다른 내역서 열기(저장 직접비 1원 일치)) -- [ ] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 부분(stc_project 새 공사(빈 상태) · 삭제 화면 없음) +- [x] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 구현(내역서 삭제 = 고른 공사 행·내역 표만 · 마지막 공사 거부(test_delete_project_drops_row_and_table_only)) - [x] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 구현(등록정보 수정) -- [ ] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 부분(stc_project 복사등록 차수 인계(K_ORD·직공비·원가 설정) 실물 대조 · 계약 변환 실물 대조 없음) +- [x] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 구현(REA K_ORD 전이표 7갈래 · 당초/변경 칸 옮김(test_stage_register_k_ord_matrix)) - [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)) @@ -48,8 +49,8 @@ - [x] F1-14 호표 코드 → 대가표·단가표 이동 — wBoq · 현재: 구현(코드 → 단가목록표·대가표 이동) - [x] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 구현(공종목록 코드 넣기 · 대공종 옵션) - [x] F1-16 주공종 집계표(레벨별 계표시 확인) — wMBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계) -- [ ] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 부분(공종번호 자동입력 · 원본 번호 빈 자리 앞까지 재현(봉화·영월)) -- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(내역 할증율 계산 반영(calcBoq) · 근거 등급 C) +- [x] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 구현(하위 전체공종 반영 · 실무 7건 규칙 번호 255/304 일치 · 나머지는 손 입력(test_itnum_tree_reproduces_every_rule_number)) +- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(원본 TwBoqRate 판독(0%=100% · 기준단가×율 · 보호 견적단가 제외) · 실물 표본 0) - [x] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 구현(비목별 단가 끝전 · 줄 고정 단가(server fixedUnit)로 다시 불러도 유지) - [x] F1-20 일위대가표 풀기 — wExpnd · 현재: 구현(일위대가 풀기 = 구성표 × 내역 수량) - [x] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 구현(풀어넣기 비목별 끝전·할증·손료 비율 줄 · 손료 줄 금액 7건 대조) @@ -57,16 +58,16 @@ - [x] F1-23 내역보관함 (1)(2)(3) — wBoq · 현재: 구현(보관함 (1)(2)(3)) - [x] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 구현(코드 종류 10종 · 그림코드 = I 코드(원본 화면 탭)) - [x] F1-25 단가 찾기(명칭·규격 / 산근내용) — wM_JabPum cBox_IFORM · 현재: 구현(단가 찾기·사용처 찾기) -- [ ] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨) +- [x] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 구현(보호(UC=T · M·L·S·W 행) 제외 · 보호 열(test_stmate_ui_protect) · 실물 표본 0) - [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 · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨) +- [x] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 구현(비율 일괄수정 보호·0원 제외 · 보호해제 자동할증(test_stmate_ui_protect)) - [x] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 구현(할증 율·금액(기준단가 기준 · 두 번 해도 같은 값)) -- [ ] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨) +- [x] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 구현(가격지 후보 비교·채택 보호 행 제외(test_stmate_ui_protect)) - [x] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 구현(속성·명칭·불러간 목록 필터) - [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~) - [x] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 구현(구성행 편집 · 저장 = BDQTY 바뀐 호표만 암호로 다시 씀(stc_save_extra)) -- [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음) +- [x] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 구현(산근 편집 + F9 · 함수·문법 검사(test_stmate_smt_lang)) - [ ] 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건 실적단가 자료 없음) @@ -77,17 +78,17 @@ - [x] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 구현(ui_code_amt 일괄변경·물가변동 · 당초⇔변경 단가 옮기기) - [x] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 구현(초기값 화면 전체 항목 · 저장 = Start8x.Ini 바뀐 키) - [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) +- [x] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 구현(직종·노임 적용코드 창 · 저장 시 Start8x.Ini 해당 키만(test_jlab_codes_hourly_and_save)) +- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(요율 축 8개 바꾸면 재조회·재계산(test_rate_axis_combos_recalculate) · 계약형식·낙찰방식·건물종류 계산 근거 없음) +- [x] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 구현(제비율표 수정·취소·저장·출력(test_rate_table_edit_saves_only_changed_cell)) +- [x] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 구현(요율표 선택·기준 요율표 열기·콤보 바꾸면 자동 재조회) - [x] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 구현(잡비 금액 불러오기 → 제잡비 직접입력(C_ 칸)) - [x] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 구현(인쇄 옵션 바꾸면 결과물 바뀜(test_stmate_report_link)) - [x] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 구현(인쇄순서로 원본 3건 시트 차례 재현) - [x] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 구현(인쇄영역 당초/변경/분할·레벨·차수 반영) -- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모 · 그림 넣기 없음) +- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모·그림코드 넣기 · 그림 저장 자리 근거 없음) -### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0% +### G2 계산 엔진 (구현 53 · 부분 7 · 없음 1) — 92.6% - [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84) - [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 부분(노임 환산 갈래 연결 · 실무 표본 기본 갈래만) @@ -103,8 +104,8 @@ - [x] F2-12 일위대가 B코드 구성 조립·성분 절사 — 17번 §2·§5 · 현재: 구현(BDQTY 구성 · 행 소수1 버림 · CHBNM 총액우선 = 행 합계액 기준 · 7건 대조) - [x] F2-13 호표 참조 그래프·위상순서·순환 오류 — 19번 §6.5 · 09번 §3 · 현재: 구현(X·D·B 참조 순서 · 순환 경로 알림(실무 7건 순환 0)) - [x] F2-14 공구손료·잡재료 % 가산 — 07번 §4 · 현재: 구현(PR 줄 = PRPT 표시 행 밑수 PRIN → PROUT · % 단위 자원 = 누계 · C* 뒤 % = 전체 비율) -- [ ] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 부분(calc.js 215 일위 할증만, 박힌 기본 상태 전용) -- [ ] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 부분(합산만, L/M/S 종류별 검사 없음) +- [x] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 구현(금액 가산(% 줄)은 별도 행 계산 · X_QTY·X_HAL = 손료계수 칸(REA 정정) · 실무 7건 1원) +- [x] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 구현(성분 검사 · 실무 7건 어긋남 0 · 일부러 어긋낸 칸만 잡힘(test_component_check_clean_on_real_and_catches_broken)) - [x] F2-17 내역 행 ROUNDDOWN(수량×단가) — 16번 · 06번 §3 · 현재: 구현(calc.js 249) - [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) @@ -151,7 +152,7 @@ - [x] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 구현(기초단가부터 원설계 5건 전 단가·총공사비 1원 · 변경설계 일부 미대조) - [ ] F2-61 표준시장단가 적용(ACTTYP·ACTOPT 노무비 끝전 보정) — 22번 §3.3 · 현재: 없음 -### G3 수량산출·식언어 (구현 23 · 부분 5 · 없음 1) — 87.9% +### G3 수량산출·식언어 (구현 25 · 부분 3 · 없음 1) — 91.4% - [x] F3-01 COSTN.SMT 메모 블록 읽기(IFORM 위치) — 21번 §1 · 23번 · 현재: 구현(smt_parser 150) - [x] F3-02 헤더 변수 정의(;; 줄)·변수 대입 — 23번 · 현재: 구현(smt_engine 112) @@ -168,9 +169,9 @@ - [ ] F3-13 강제금액(직접 지정 금액) — 28번 §4 · 현재: 없음(옛 '재료비: 숫자' 짐작 처리 걷음 · 원본 문법 근거 대기) - [x] F3-14 수량기호 Q·A·T·N — wM_Edit_San · 현재: 구현(Q·A·T·N 줄 · 결과적용 비목 4택·0원 비목(test_code_insert_lines_zero_skip_and_result_strip)) - [x] F3-15 식 문법 검사(줄 단위 오류 메시지) — 28번 §4 · 현재: 구현(줄 단위 문법 오류 · 실무 산근 오류 0(test_section_markers_exclusion_and_grammar_errors)) -- [ ] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 부분(문법 강조·설명·함수 단추 화면) +- [x] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 구현(함수 단추·강조 낱말 = 원본 화면 정의(test_editor_function_list_and_highlight_follow_dfm)) - [x] F3-17 결과적용 비목 4택·0원 비목 입력안함 — 26번 §3 · 현재: 구현(Q·A·T·N 줄 · 결과적용 비목 4택·0원 비목(test_code_insert_lines_zero_skip_and_result_strip)) -- [ ] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 부분(낙찰률 단가 = 원단가×율 버림 · 7건 127개 대조 · 화면 입력 없음) +- [x] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 구현(산근 편집기 낙찰율·결과값 적용·계약단가 · 울진 34개 코드 1·2칸 일치(test_editor_bid_rate_input_matches_contract_codes)) - [x] F3-19 Q식(3600qKfE/Cm 등 10꼴) 평가 — 23번 §3 · 현재: 구현(식 그대로 평가 · 변수 대소문자 무시 · 282개 대조) - [x] F3-20 Cm 사이클식(t1+…·L/V1+L/V2+0.25·선회각 초) — 26번 §1 · 현재: 구현(사칙 대입 · 따옴표 주석 걷음 · 282개 대조) - [x] F3-21 표준중기공식 등록·수정·삭제 — wM_Edit_EQ · 28번 §1.1 · 현재: 구현(표준중기공식 등록·수정·삭제(test_qcode_read_write_round_trip)) @@ -255,9 +256,9 @@ - [x] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 구현(금액 한글 표기 · 원본 한글 금액 11건 전부 일치) - [x] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 구현(SET_REP 서식 제목·시트 이름 적용 · 보고서 시험 포함) -### G6 설계변경·계약·실행·기성 (구현 14 · 부분 22 · 없음 0) — 69.4% +### G6 설계변경·계약·실행·기성 (구현 17 · 부분 19 · 없음 0) — 73.6% -- [ ] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 부분(계산 = 차수별 수량·단가·원가계산서 · 저장은 2·3 칸 덮어씀 view_prj_boq 560) +- [x] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 구현(2·3·4차수 칸 고침이 저장 후 유지(test_slot_2_3_4_edits_persist)) - [ ] F6-02 통합형 BOQ11+BOQ12에서 BOQ12 현재안 — 16번 §4.5 · 현재: 부분(울진 BOQ12 = 계약내역(STMATE 착공내역서) 판명 · contract_calc 로 BOQ11 에서 1원 재현) - [x] F6-03 분리형 변경 STC 생성·차수 인계 — 16번 §4.6 · 현재: 구현(stc_change 소광 원설계 → 실제 변경1차 차수1 말단 47행·직접비·요율 41키 일치(test_stmate_conv_change)) - [x] F6-04 변경 수량 입력 — 설계변경 · 현재: 구현(change_calc · 영덕·소광 변경 BOQ11 1·2차수 수량·단가·금액 일치) @@ -274,12 +275,12 @@ - [x] F6-15 설계변경증감대비표(0648)·금액대비표 — CHANGE_AMT · 현재: 구현(증감대비표·금액대비표 · 영덕 엑셀 행 차례·금액 일치 · 인쇄·엑셀) - [ ] F6-16 변경총괄·굴착·시도 변경내역서(0661~0666) — CHANGE_* · 현재: 부분(변경총괄 = 영덕 엑셀 총괄내역서 일치 · 굴착·시도 양식 없음) - [x] F6-17 계약 변환 비목별 단가적용율(노·재·경) — wM_Mk_Cont · 현재: 구현(contract_calc 노·재·경 적용율 버림 · 울진 BOQ12 계약내역 1원) -- [ ] F6-18 계약 변환 옵션(기초단가·일위/산근 생성·동일코드·노무비율·비과세) — 27번 §2.2 · 현재: 부분(옵션 6개 중 동일코드 개별생성·0% 공내역 반영) +- [ ] F6-18 계약 변환 옵션(기초단가·일위/산근 생성·동일코드·노무비율·비과세) — 27번 §2.2 · 현재: 부분(옵션 6개 동작 · 켬 갈래 실물 일치 · 끔 갈래·비과세 표본 없음) - [x] F6-19 적용율 0% → W코드 공내역 — 27번 §2.2 · 현재: 구현(0% 공내역 W코드 · 울진 대조) - [ ] F6-20 적용제외 공정 선택(레벨 9단계) — 27번 §2.3 · 현재: 부분(적용제외 공정 선택 · 머리 단위 실물 대조 없음) - [x] F6-21 예상적용율 찾기(목표금액 역산)·미리계산 — 27번 §2.3 · 현재: 구현(목표금액 → 적용율 87.745% 역산 · 미리계산) -- [ ] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 부분(변환등록 단추 = /api/file/copy-register) -- [ ] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 부분(산근 재계산 시 2칸 원단가 → 1칸 낙찰 단가 · 저장은 MAMT3 87.745% 일괄 덮어씀) +- [x] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 구현(소광 원설계 → 변환등록·저장 = 실제 울진 계약 STC 일치(test_stmate_stage_register)) +- [x] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 구현(새 코드 낙찰율·단가·산근 글·구성표 저장 = 울진 계약 STC 일치(test_stmate_stage_register)) - [x] F6-24 계약 원가계산(계약 제잡비) — 27번 · 현재: 구현(계약 원가계산 · 울진 총공사비 333,315,000원 일치) - [ ] F6-25 계약·실행내역서(0623·0624) — PM_Nae_Cont · 현재: 부분(계약·실행내역서 화면 · 실물 표본 없음) - [ ] F6-26 실행예산 작업(실행단가·실행수량) — wBoq 실행예산작업 · 현재: 부분(exec_calc 실행단가·수량 · 표본 없음) diff --git a/tools/stmate-test/api_ui_code.py b/tools/stmate-test/api_ui_code.py index edd4b03f2..7ed1baa4d 100644 --- a/tools/stmate-test/api_ui_code.py +++ b/tools/stmate-test/api_ui_code.py @@ -17,6 +17,7 @@ from __future__ import annotations import pathlib +import re from dbf_rw import DbfTable from fastapi import APIRouter, HTTPException @@ -133,12 +134,33 @@ def _boq_codes(files: dict, req: dict, log: list) -> None: files[name] = bt.to_bytes() +def _rates(files: dict, req: dict, log: list) -> None: + """제비율표 수정(wM_KAN_RATE) — rate_tables{표 이름: 행[_recno · RATE_n 칸]} 중 바뀐 칸만.""" + for name, rows in (req.get("rate_tables") or {}).items(): + name = str(name).upper() + if not re.fullmatch(r"RATE[_A-Z0-9]*\.DBF", name) or name not in files: + continue + tbl = DbfTable(files[name]) + for r in rows or []: + i = int(r.get("_recno", -1)) if str(r.get("_recno", "")).isdigit() else -1 + if not 0 <= i < len(tbl): + continue + for k, v in r.items(): + if k.startswith("_") or k.endswith("_HEX") or tbl.fields.get(k) is None: + continue + if tbl.fields[k].type in ("N", "F"): + _put(tbl, i, k, v, log, name) + if tbl.dirty: + files[name] = tbl.to_bytes() + + def apply_struct(files: dict[str, bytes], req: dict) -> tuple[dict[str, bytes], list]: - """구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움).""" + """구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움 · 제비율표 칸).""" out = dict(files) log: list = [] _costn(out, req, log) _boq_codes(out, req, log) + _rates(out, req, log) return out, log diff --git a/tools/stmate-test/api_ui_contract.py b/tools/stmate-test/api_ui_contract.py new file mode 100644 index 000000000..ee28d2032 --- /dev/null +++ b/tools/stmate-test/api_ui_contract.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""설계 → 계약자료 변환등록(wM_Conv_Mode · wM_Mk_Cont) 끼워 넣기 API. + +근거: 27_계약_실행_기성_단계.md §2(계약 변환 옵션 · 적용제외) · 26 §4(NAGCD 낙찰율 열 · 계약단가) · + 16 §4.5(통합형 BOQ11 설계 + BOQ12 계약) · ui_form_catalog wM_Conv_Mode(당초/변경 수량·단가 · 레벨) +실증(울진 2025): 소광 원설계 STC → 계약 STC = PRJLST 새 행(PCODE 다음 번호 · CCODE = 설계 PCODE · PCLASS 4) + + BOQ1m = 설계 내역 복사 위 계약 공종(새 코드 · 비목 단가 버림) + COSTN 새 코드 행(산근 글·구성표 그대로) +흐름: 화면(contract_calc.registerRows)이 계약 행을 만들고 서버는 + stc_project.copy_register(그대로 복사) → stc_save.apply_save(새 표 행 · 새 COSTN · 산근 · 구성표 · PRJLST) 만 함. +""" + +from __future__ import annotations + +import pathlib + +from bdqty import decode_bdqty +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from stc_project import copy_register, write_output +from stc_read import Source +from stc_save import apply_save + +router = APIRouter() +CONTRACT_PCLASS = 4 # 계약 공사(울진 2025 계약 행 · REA K_ORD 표 PCLASS 4) + + +def _root() -> pathlib.Path: + import server # 실행 중 바뀔 수 있는 작업 폴더(시험은 임시 폴더로 바꿈) + + return server.WORK_DBF_ROOT + + +class ContractRegReq(BaseModel): + source_path: str + src_pcode: str = "11" + pname: str = "" + pnum: str = "" + boq_rows: list[dict] = [] # 계약 공종 행 — {_recno, BDCODE1, 단가·금액 칸} + costn_rows: list[dict] = [] # 새 코드 COSTN 행 + costn_memos: dict[str, str] = {} # 새 산근 코드 → 산출근거 글 + bdqty_rows: list[dict] = [] # 새 대가 코드 구성행(decode_bdqty 꼴) + costw1: float | None = None # 계약 총공사비(화면 계약 원가계산서) + target_folder_name: str = "" + + +def contract_register(files: dict, req: ContractRegReq) -> tuple[dict, str, list]: + """(새 files, 새 PCODE, 바뀐 칸 목록) — 쓰기 없음(시험용).""" + out, pcode, log0 = copy_register(files, req.src_pcode, "copy", req.pname) + prj_upd = {"CCODE": req.src_pcode, "PCLASS": CONTRACT_PCLASS} + if req.pnum: + prj_upd["PNUM"] = req.pnum + if req.costw1 is not None: + prj_upd["COSTW1"] = req.costw1 + save = { + "boq_table": f"BOQ{pcode}.DBF", + "boq_updates": [dict(r) for r in req.boq_rows], + "costn_updates": [dict(r) for r in req.costn_rows], + "costn_memo_updates": dict(req.costn_memos), + "prjlst_updates": prj_upd, + } + if req.bdqty_rows and "BDQTY.DBF" in out: + save["bdqty_rows"] = decode_bdqty(out["BDQTY.DBF"]) + [dict(r) for r in req.bdqty_rows] + out2, log = apply_save(out, save) + return out2, pcode, log0 + log + + +@router.post("/api/ui/contract-register") +def api_contract_register(req: ContractRegReq): + try: + s = Source(req.source_path) + except (FileNotFoundError, ValueError) as e: + raise HTTPException(400, str(e)) + try: + files, pcode, changes = contract_register(s.files, req) + except (ValueError, KeyError) as e: + raise HTTPException(400, str(e)) + target = req.target_folder_name or f"{s.path.stem}_계약{pcode}" + names = dict(s.names, **{f"BOQ{pcode}.DBF": f"BOQ{pcode}.DBF"}) + res = write_output(_root(), target, files, names, s.entries) + return {**res, "new_pcode": pcode, "change_count": len(changes)} diff --git a/tools/stmate-test/api_ui_project.py b/tools/stmate-test/api_ui_project.py new file mode 100644 index 000000000..fbdc2db18 --- /dev/null +++ b/tools/stmate-test/api_ui_project.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +"""공사(내역서) 삭제·단계 등록(차수) 끼워 넣기 API — 공사목록표(wPrjList) 「내역서 삭제」(Act_Del_PrjExecute · ST_U_DelPrj Delete_Prj). + +근거: REA 09 resource-TWPRJLIST 메뉴 「내역서 삭제」 · old/30_원자료 module_exports(ST_U_DelPrj: Delete_Prj) + · 02번(PRJLST 한 행 = 공사 · PCODE '1n' ↔ BOQ1n.DBF · PRJ_MEMO = 그 공사 원가 설정) +규칙: 고른 PCODE 의 PRJLST 행과 BOQ1n.DBF 를 뺌 · 다른 공사 행·표·메모는 바이트 그대로 · 마지막 한 공사는 지우지 않음 + (원본 확인 문구·선택지는 근거 없음 — 화면은 확인 창 한 번) +단계 등록(NewProject → MkBoq · REA 31 Main_K_ORD_registration_transition_matrix · 최종보고서 §4.1): + 원본 PCLASS × 등록(6 기성 · 7 설계변경) × 플래그(0 차수별 · 1 당초/변경) → 새 K_ORD(1 또는 K+1) · 상한 없음 + 새 PCLASS = 등록 번호(4 계약 · 6 기성 · 7 설계변경 갈래와 같은 수 — 추정) + K+1 = 다음 차수 칸 열기(stc_project change) · 1 = 원본 마지막 차수 칸을 1·2 칸(당초·변경)으로 옮겨 새로 시작 +""" + +from __future__ import annotations + +import pathlib + +from dbf_rw import DbfTable +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from stc_project import SLOT_COL, copy_register, safe_name, write_output +from stc_read import Source +from stc_save import SaveLog, recompute_boq + +router = APIRouter() + + +def _root() -> pathlib.Path: + import server # 실행 중 바뀔 수 있는 작업 폴더(시험은 임시 폴더로 바꿈) + + return server.WORK_DBF_ROOT + + +def delete_project(files: dict, pcode: str) -> tuple[dict, list]: + """(새 files, 바뀐 목록) — PRJLST 행 · BOQ{pcode}.DBF 뺌.""" + pcode = str(pcode).strip() + if "PRJLST.DBF" not in files: + raise ValueError("PRJLST.DBF 없음") + prj = DbfTable(files["PRJLST.DBF"]) + idx = [i for i in range(len(prj)) if prj.get(i, "PCODE") == pcode] + if not idx: + raise ValueError(f"PRJLST 에 PCODE {pcode} 공사 없음") + if len(prj) - len(idx) < 1: + raise ValueError("마지막 공사는 지울 수 없음") + log = [ + {"table": "PRJLST.DBF", "recno": i, "field": "_delete", "old": pcode, "new": ""} + for i in idx + ] + prj.recs = [r for i, r in enumerate(prj.recs) if i not in set(idx)] + prj.dirty = True + out = dict(files) + out["PRJLST.DBF"] = prj.to_bytes() + tbl = f"BOQ{pcode}.DBF" + if out.pop(tbl, None) is not None: + log.append({"table": tbl, "recno": -1, "field": "_drop", "old": tbl, "new": ""}) + return out, log + + +class DelPrjReq(BaseModel): + source_path: str + pcode: str + target_folder_name: str = "" + + +@router.post("/api/ui/project-delete") +def api_project_delete(req: DelPrjReq): + try: + s = Source(req.source_path) + files, log = delete_project(s.files, req.pcode) + except (FileNotFoundError, ValueError) as e: + raise HTTPException(400, str(e)) + target = req.target_folder_name or s.path.stem + names = {k: v for k, v in s.names.items() if k in files} + res = write_output(_root(), safe_name(target), files, names, s.entries) + tbl = f"BOQ{str(req.pcode).strip()}.DBF" + left = pathlib.Path(res["saved_dir"]) / s.names.get(tbl, tbl) # 같은 작업본 폴더에 남은 옛 표 + if tbl not in files and left.exists(): + left.unlink() + return {**res, "deleted": req.pcode, "changes": log} + + +# 원본 PCLASS × 등록 × 플래그 → 새 K_ORD("1" | "K+1") — 표에 없는 원본(설계 0 등)은 다음 차수(K+1) +K_ORD_RULE = { + (4, 6, None): "1", + (6, 6, None): "K+1", + (7, 6, None): "1", + (4, 7, None): "1", + (6, 7, None): "1", + (7, 7, 0): "K+1", + (7, 7, 1): "1", +} + + +def new_k_ord(pclass: int, op: int, flag: int, k: int) -> int: + rule = K_ORD_RULE.get((pclass, op, flag)) or K_ORD_RULE.get((pclass, op, None)) or "K+1" + return k + 1 if rule == "K+1" else 1 + + +def _move_slot(tbl: DbfTable, src: str, dst: tuple[str, ...]) -> None: + pairs = [] + for c in tbl.order: + m = SLOT_COL.match(c) + if m and m.group(2) == src: + pairs += [(c, f"{m.group(1)}{d}") for d in dst if f"{m.group(1)}{d}" in tbl.fields] + for i in range(len(tbl)): + for a, b in pairs: + tbl.set_raw(i, b, tbl.raw(i, a)) + + +def stage_register(files: dict, src_pcode: str, op: int, flag: int, pname: str = ""): + """(새 files, 새 PCODE, 새 K_ORD) — 단계 등록.""" + prj = DbfTable(files["PRJLST.DBF"]) + si = next(i for i in range(len(prj)) if prj.get(i, "PCODE") == str(src_pcode)) + num = lambda f: int(float(prj.get(si, f) or 0)) if f in prj.fields else 0 # noqa: E731 + k, pclass = num("K_ORD"), num("PCLASS") + nk = new_k_ord(pclass, int(op), int(flag), k) + out, pcode, _log = copy_register( + files, str(src_pcode), "change" if nk == k + 1 else "copy", pname + ) + prj2 = DbfTable(out["PRJLST.DBF"]) + ni = next(i for i in range(len(prj2)) if prj2.get(i, "PCODE") == pcode) + prj2.set(ni, "K_ORD", nk) + if "PCLASS" in prj2.fields: + prj2.set(ni, "PCLASS", int(op)) + if nk == 1 and k + 1 != 1: # 원본 마지막 차수(k+1) 칸 → 새 공사 당초·변경(1·2) 칸 + name = f"BOQ{pcode}.DBF" + bt = DbfTable(out[name]) + _move_slot(bt, str(k + 1), ("1", "2")) + direct = recompute_boq(bt, name, {"1", "2"}, SaveLog()) + for s, vals in direct.items(): + for c, v in zip(("MCOST", "LCOST", "SCOST", "TCOST"), vals): + if f"{c}{s}" in prj2.fields: + prj2.set(ni, f"{c}{s}", v) + out[name] = bt.to_bytes() + out["PRJLST.DBF"] = prj2.to_bytes() + return out, pcode, nk + + +class StageRegReq(BaseModel): + source_path: str + src_pcode: str = "11" + op: int = 7 # 6 기성 · 7 설계변경 + flag: int = 0 # 0 차수별 · 1 당초/변경 + pname: str = "" + target_folder_name: str = "" + + +@router.post("/api/ui/stage-register") +def api_stage_register(req: StageRegReq): + try: + s = Source(req.source_path) + files, pcode, nk = stage_register(s.files, req.src_pcode, req.op, req.flag, req.pname) + except (FileNotFoundError, ValueError, StopIteration) as e: + raise HTTPException(400, str(e) or "공사 없음") + target = req.target_folder_name or f"{s.path.stem}_등록{pcode}" + names = dict(s.names, **{f"BOQ{pcode}.DBF": f"BOQ{pcode}.DBF"}) + res = write_output(_root(), safe_name(target), files, names, s.entries) + return {**res, "new_pcode": pcode, "k_ord": nk} diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js index 357d91ebf..7ee908c43 100644 --- a/tools/stmate-test/app.js +++ b/tools/stmate-test/app.js @@ -175,7 +175,12 @@ 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/ui/save", + Object.assign(p, UC.structPayload(ctx.dbfData), { + rate_tables: ctx.dbfData.rate_dirty ? ctx.dbfData.rate_tables : {}, + }), + ); return postJson("/api/save", p); } @@ -183,6 +188,9 @@ function decorateSavePayload(p) { const d = ctx.dbfData; if (!d) return p; + // 산근 편집기 계약단가 표시(BCONT) — 고친 코드만 COSTN 칸 덧붙임 + const extra = ctx.uiState.costnExtra || {}; + (p.costn_updates || []).forEach((u) => Object.assign(u, extra[String(u.CODE).trim()] || {})); return Object.assign(p, { start_ini: d.start_ini || {}, prjlst_rows: d.prjlst_all || [] }); } diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js index c76e5a68d..f098d9b04 100644 --- a/tools/stmate-test/calc.js +++ b/tools/stmate-test/calc.js @@ -373,7 +373,7 @@ const StmateCalc = (() => { ? 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) · 저장 차수 단가는 이미 반영된 값이라 건너뜀 + // — 금액 가산(BDQTY X_QTY·X_HAL 은 손료계수 칸 · REA 정정)(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; diff --git a/tools/stmate-test/contract_calc.js b/tools/stmate-test/contract_calc.js index eedd61cd9..94cb5049c 100644 --- a/tools/stmate-test/contract_calc.js +++ b/tools/stmate-test/contract_calc.js @@ -37,6 +37,10 @@ const StmateContract = (() => { excludeGov: true, zeroToW: true, sameCodeSeparate: false, + baseRate: true, // 기초단가에 적용율 — □ = 기초단가(M·L·S) 코드·단가 그대로 + makeIlwi: true, // 일위대가/산출근거 생성 — □ = 대가·산근·중기(B·D·X)는 계약단가 W코드로 + taxFree: true, // 비과세자재대 — □ = taxFreeCodes(COSTN NOTA) 금액 그대로 + taxFreeCodes: [], }; // 머리글자별 기존 최대 코드 번호 — 새 계약 코드는 그 다음부터 @@ -83,10 +87,16 @@ const StmateContract = (() => { if (nd.kind !== "leaf") return out; const d = nd.st[stage] || {}; const excluded = ex.has(i); + const kind = String(nd.code || "")[0] || ""; + // 그대로 둘 공종 — 적용제외 · 기초단가 적용율 끔 · 비과세자재대 그대로 + const keep = + excluded || + (o.baseRate === false && /[MLS]/.test(kind)) || + (o.taxFree === false && (o.taxFreeCodes || []).includes(nd.code)); const unit = {}; for (const [c, , rk] of COMPS) { const u = n0(d[c + "Unit"]); - unit[c] = excluded ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits)); + unit[c] = keep ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits)); } const qty = n0(d.qty); const amt = {}; @@ -94,16 +104,19 @@ const StmateContract = (() => { const tUnit = unit.m + unit.l + unit.s; let newCode = nd.code; const designT = n0(d.tUnit); - const zeroed = !excluded && o.zeroToW && designT !== 0 && tUnit === 0; - if (!excluded && counter && nd.code) { - const key = zeroed ? "W" + nd.code.slice(1) : nd.code; + const zeroed = !keep && o.zeroToW && designT !== 0 && tUnit === 0; + // 일위대가/산출근거 생성 끔 — 대가·산근·중기는 낙찰율 적용 계약단가(W코드)로 + const asW = zeroed || (!keep && o.makeIlwi === false && /[BDX]/.test(kind)); + if (!keep && counter && nd.code) { + const key = asW ? "W" + nd.code.slice(1) : nd.code; if (!o.sameCodeSeparate && codeMap[key]) newCode = codeMap[key]; else { - newCode = nextCode(zeroed ? "W" + nd.code.slice(1) : nd.code); + newCode = nextCode(key); codeMap[key] = newCode; } - } else if (zeroed) newCode = "W" + nd.code.slice(1); + } else if (asW) newCode = "W" + nd.code.slice(1); out.excluded = excluded; + out.kept = keep; out.zeroed = zeroed; out.newCode = newCode; out.design = d; @@ -252,8 +265,64 @@ const StmateContract = (() => { return out; } + /** + * 설계 → 계약자료 변환등록 저장 요청(wM_Conv_Mode · 26 §4 · 울진 2025 실증) + * 내역: 계약 공종 행 = 새 코드 · 비목 단가·금액(1칸) · 변경 수량 적용이면 QTY1 = 그 차수 수량 + * 새 COSTN 행: 설계 코드의 명칭·규격·단위·분류 칸을 옮김 + * 대가·산근(B·D·X) = 2칸 원단가(설계) · 1칸 계약단가 · NAGCD = 적용율 · UC(결과값에 낙찰율적용)·BCONT(계약단가) = T + * · 산출근거 글(COSTN.SMT)·구성표(BDQTY) 그대로 옮김 + * 기초단가·계약단가 W = 1칸 = 2칸 = 계약단가(낙찰율 열 없음) + */ + const COPY_COLS = ["NAME", "SIZE", "UNIT", "BQCODE", "EQCODE", "JDCODE", "NAME2", "SIZE2"]; + const COPY_MORE = ["SBMOK", "S_ADJ", "ADJ_M", "ADJ_L", "ADJ_S", "EDT_DATE", "WQCODE"]; + function registerRows(conv, costnRows, memos = {}, bdqtyRows = []) { + const st = conv.stage; + const by = new Map((costnRows || []).map((r) => [String(r.CODE || "").trim(), r])); + const o = conv.opts; + const same = n0(o.rateL) === n0(o.rateM) && n0(o.rateL) === n0(o.rateS); + const boq = [], + costn = [], + memo = {}, + bdqty = [], + made = new Set(); + for (const nd of conv.nodes) { + if (nd.kind !== "leaf" || nd.recno == null || nd.kept) continue; + const s = nd.st[st]; + const u = { _recno: nd.recno, BDCODE1: nd.newCode }; + if (!nd.pct) { + Object.assign(u, { MAMT1: s.mUnit, LAMT1: s.lUnit, SAMT1: s.sUnit, TAMT1: s.tUnit }); + Object.assign(u, { M_AMT1: s.mAmt, L_AMT1: s.lAmt, S_AMT1: s.sAmt, T_AMT1: s.tAmt }); + if (st !== 1) u.QTY1 = s.qty; + } + boq.push(u); + if (made.has(nd.newCode) || by.has(nd.newCode) || nd.newCode === nd.code) continue; + made.add(nd.newCode); + const src = by.get(nd.code) || {}; + const row = { CODE: nd.newCode }; + COPY_COLS.forEach((k) => src[k] != null && (row[k] = src[k])); + const comp = /^[BDX]/.test(nd.newCode); + const d = nd.design || {}; + const one = { M: s.mUnit, L: s.lUnit, S: s.sUnit, T: s.tUnit }; + const two = comp ? { M: n0(d.mUnit), L: n0(d.lUnit), S: n0(d.sUnit), T: n0(d.tUnit) } : one; + for (const c of ["T", "M", "L", "S"]) + Object.assign(row, { [`${c}AMT1`]: one[c], [`${c}AMT2`]: two[c] }); + if (comp) { + COPY_MORE.forEach((k) => src[k] != null && (row[k] = src[k])); + Object.assign(row, { NAGCD: same ? n0(o.rateL) : 0, UC: "T", BCONT: "T" }); + const m = memos[nd.code]; + if (m && String(m.text || "").trim()) memo[nd.newCode] = m.text; + (bdqtyRows || []) + .filter((q) => String(q.BDCODE || "").trim() === nd.code) + .forEach((q) => bdqty.push(Object.assign({}, q, { BDCODE: nd.newCode }))); + } + costn.push(row); + } + return { boq, costn, memos: memo, bdqty }; + } + return { CONTRACT_CARRY, + registerRows, DEFAULT_OPTS, codeBaseFromCostn, convert, diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html index c6e89af3f..6bb5daf3a 100644 --- a/tools/stmate-test/index.html +++ b/tools/stmate-test/index.html @@ -67,6 +67,7 @@ + diff --git a/tools/stmate-test/scr_edit_lang.js b/tools/stmate-test/scr_edit_lang.js index f24b61e55..149e0925b 100644 --- a/tools/stmate-test/scr_edit_lang.js +++ b/tools/stmate-test/scr_edit_lang.js @@ -92,7 +92,11 @@ const StmateSmtLang = (() => { .join("\r\n"); } - // 문법 강조 — 주석문 · Text(따옴표) · 숫자 · 변수·단가코드 · 계표시 + // 문법 강조 낱말 — 원본 TWM_EDIT_SAN Syn1.KeyWords(함수·상수) · 대소문자 가리지 않음 + const SYN_KEYWORDS = + "ABS COS CUT EXP FACT IIF INT LA LN LOG MA MOD PI POWER ROUND ROUNDDOWN SIN SQRT STMATE TAN"; + const KEYS = new Set(SYN_KEYWORDS.split(" ")); + // 문법 강조 — 주석문 · Text(따옴표) · 숫자 · 함수 낱말 · 변수·단가코드 · 계표시 function highlight(line) { const s = String(line); const out = []; @@ -127,7 +131,8 @@ const StmateSmtLang = (() => { continue; } if ((m = rest.match(/^[A-Za-z_][A-Za-z_0-9]*/))) { - out.push(`${esc(m[0])}`); + const cls = KEYS.has(m[0].toUpperCase()) ? "syn-key" : "syn-var"; + out.push(`${esc(m[0])}`); i += m[0].length; continue; } @@ -155,7 +160,17 @@ const StmateSmtLang = (() => { "함수 — ∏ INT SIN COS TAN SQRT √ ROUND ROUNDDOWN ROUNDUP CUT LOG LN ABS EXP MOD POWER FACT IIF(조건, 참, 거짓)", ]; - return { MARK_NAMES, TRANS, marker, codeLines, stripResults, highlight, GRAMMAR_HELP, esc }; + return { + MARK_NAMES, + TRANS, + marker, + codeLines, + stripResults, + highlight, + GRAMMAR_HELP, + SYN_KEYWORDS, + esc, + }; })(); if (typeof window !== "undefined") window.STMATE_SMT_LANG = StmateSmtLang; diff --git a/tools/stmate-test/scr_edit_san.js b/tools/stmate-test/scr_edit_san.js index 385f1d02c..906ca72bb 100644 --- a/tools/stmate-test/scr_edit_san.js +++ b/tools/stmate-test/scr_edit_san.js @@ -157,6 +157,15 @@ ${sel("san-func", [["", "각종 지원함수"]].concat(X().FUNC_LIST.map((f) => [f, f])), "")} ${sel("san-mark", marks, "")} ${sel("san-qty", qopts, "")} + ${sel( + "san-img", + [["", "그림코드(Ctrl+I)"]].concat( + ((ctx.dbfData && ctx.dbfData.costn_rows) || []) + .filter((r) => /^I\d{5}$/.test(String(r.CODE).trim())) + .map((r) => [String(r.CODE).trim(), `${String(r.CODE).trim()} ${r.NAME || ""}`]), + ), + "", + )} ${sel("san-eq", [["", eq.length ? "중기공식 불러오기" : "등록된 중기공식 없음"]].concat(eq.map((e, i) => [String(i), `${e.name} (${e.ins_x ? "공식+코드" : "공식"})`])), "")}
@@ -182,6 +191,9 @@ 결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())} + 낙찰율 % + + ${adjOn ? ["M", "L", "S"].map((k) => `${k}:x `).join(" ") : ""}
@@ -218,6 +230,22 @@ `; } + /** + * 낙찰율(Ed_NAGCD) · 결과값에 낙찰율적용(Ck_UC → COSTN UC) — 26 §3·§4 · REA 30 TWM_EDIT_SAN + * 처음 걸 때 지금 1칸 단가를 2칸(원단가) 자리로 옮기고, 지우면 2칸 원단가를 1칸으로 되돌림 + * 다시 셈(계산완료)은 smt_engine — 2칸 = 산근 결과 · 1칸 = 비목별 원단가 × 낙찰율 버림(실무 7건 UC=T ⇔ NAGCD>0) + */ + function setBidRate(row, rate, uc = true) { + const r = num(rate), + had = num(row.NAGCD) > 0; + const cols = ["T", "M", "L", "S"]; + if (r > 0 && !had) cols.forEach((k) => (row[`${k}AMT2`] = String(row[`${k}AMT1`] || "0"))); + if (!(r > 0) && had) cols.forEach((k) => (row[`${k}AMT1`] = String(row[`${k}AMT2`] || "0"))); + row.NAGCD = String(r > 0 ? r : 0); + row.UC = r > 0 && uc ? "T" : "F"; + return row; + } + // 순환 — 이 산근이 부르는 코드가 다시 이 산근에 닿는지(REA 30 mode1 순환참조 검사) function cycleErrors(ctx, code) { const C = COMP(); @@ -273,6 +301,7 @@ const redo = () => (keep(), app.renderWorkspace()); area.onkeydown = (e) => { if (e.key === "F9") (e.preventDefault(), $("san-calc").click()); + if (e.ctrlKey && (e.key === "i" || e.key === "I")) (e.preventDefault(), $("san-img").focus()); }; const pick = (id, fn) => $(id) && @@ -285,6 +314,8 @@ pick("san-func", (v) => insertAt(area, v)); pick("san-mark", (v) => insertLines(area, [v])); pick("san-qty", (v) => insertAt(area, v)); + // 그림 넣기 — 「IMG(I00001)」 한 줄(문법설명 (5) · 그림코드는 한 줄에 하나) + pick("san-img", (v) => insertLines(area, [`IMG(${v})`])); pick("san-eq", (v) => { const it = (u.eqItems || [])[Number(v)]; if (!it) return; @@ -335,6 +366,18 @@ ); const row = ENG().costnLookup(ctx).byCode[code] || {}; $("san-sbmok").onchange = (e) => ((row.SBMOK = e.target.value), redo()); + const bid = () => ( + keep(), + setBidRate(row, $("san-nag").value, $("san-uc").checked), + app.cascadeAndRender() + ); + $("san-nag").onchange = bid; + $("san-uc").onchange = bid; + // 계약단가 표시(BCONT) — 인쇄용 · 저장 묶음에 덧붙임(app.js decorateSavePayload) + $("san-cont").onchange = (e) => { + row.BCONT = e.target.checked ? "T" : "F"; + (u.costnExtra = u.costnExtra || {})[code] = { BCONT: row.BCONT }; + }; $("san-adj").onchange = (e) => { row.S_ADJ = e.target.checked ? "T" : "F"; for (const k of "MLS") if (!num(row[`ADJ_${k}`])) row[`ADJ_${k}`] = "1"; @@ -374,5 +417,6 @@ } P.screens.wM_Edit_iLWi = { render, bind }; - global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines }; -})(window); + global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines, setBidRate }; + if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_EDIT_SAN; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/scr_kanjub.js b/tools/stmate-test/scr_kanjub.js index f2bd73186..10793e205 100644 --- a/tools/stmate-test/scr_kanjub.js +++ b/tools/stmate-test/scr_kanjub.js @@ -277,6 +277,37 @@ return out; } + const RATE_AXES = [ + "I_KONG_TYPE", + "I_KONG_KIKAN", + "I_ANJUN_TYPE", + "I_KOBO_TYPE", + "I_HWAN", + "I_EQIP", + "I_TBUGUM", + "I_ILBAN", + ]; + // 제비율표 다시 조회 — 마지막에 적용한 표(없으면 공사 RATE_R) · 수동 확정이면 그대로(scr_kanjub_rate) + function reRate(ctx, api, cfg) { + const R = global.StmateRate, + u = ctx.uiState; + const rows = + u.kjRateRows || + (ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables["RATE_R.DBF"]); + if (!R || !rows || !rows.length || u.kjAutoRate === false) return null; + const n = stageCfg(ctx).n; + const calc = () => ( + api.recompute(), + (ctx.computed.stages && ctx.computed.stages[n] && ctx.computed.stages[n].kanjub) || + ctx.computed.kanjubComputed + ); + const res = R.applyWithLoopCheck(cfg, R.matrixFromRows(rows), calc, { + withTbugum: true, + }); + u.kjMsg = res.loop ? res.msg : res.manual ? "요율 수동 확정 상태 — 요율 그대로" : ""; + return res; + } + function bind(ctx, api) { const re = () => api.renderWorkspace(); const { cfg, st } = stageCfg(ctx); @@ -291,6 +322,8 @@ switchScheme(cfg, schemeOf(k2)); } setKey(cfg, key, val); + // 요율 축(공사종류·기간·안전공종·고용등급·환경·건설기계·퇴직공제·일반관리비) — 제비율표 다시 조회 후 다시 셈 + if (RATE_AXES.includes(key)) reRate(ctx, api, cfg); re(); }); api.bindEach(".kjx-fix", "onchange", (el) => { @@ -348,6 +381,7 @@ 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) 없음"); + u.kjRateRows = rows; // 요율 축 콤보를 바꿀 때 다시 씀 const m = R.matrixFromRows(rows), n = stageCfg(ctx).n; const calc = () => ( diff --git a/tools/stmate-test/scr_kanjub_rate.js b/tools/stmate-test/scr_kanjub_rate.js index 044e7f6c4..0f15d2d37 100644 --- a/tools/stmate-test/scr_kanjub_rate.js +++ b/tools/stmate-test/scr_kanjub_rate.js @@ -145,7 +145,12 @@ const StmateRate = (() => { // 순환 점검 — 규모 밑수를 계산 결과에서 다시 잡을 때 묶음이 왕복하면 멈추고 알림(06 §2 계약 ①~③) // 수동 확정(cfg.rateConfirm.manual)이 있으면 다시 고르지 않음 · 왕복이면 오간 조회 결과(choices)를 돌려줌 - function applyWithLoopCheck(cfg, m, calcFn, { scaleOf = (kj) => kj.dT, maxIter = 6 } = {}) { + function applyWithLoopCheck( + cfg, + m, + calcFn, + { scaleOf = (kj) => kj.dT, maxIter = 6, withTbugum = false } = {}, + ) { let kj = calcFn(cfg); if (cfg.rateConfirm && cfg.rateConfirm.manual) return { cfg, kj, loop: false, manual: true }; const seen = [], @@ -162,7 +167,7 @@ const StmateRate = (() => { if (seen[seen.length - 1] === sig) return { cfg, kj, loop: false }; seen.push(sig); found[sig] = res; - applyToCfg(cfg, res); + applyToCfg(cfg, res, { withTbugum }); kj = calcFn(cfg); } return { @@ -349,7 +354,10 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate return `
원가계산 제비율표 [TWM_KAN_RATE · ${esc(src)} ${m.length}행×${m[0] ? m[0].length : 0}열]
-
+ + + + ${ctx.dbfData && ctx.dbfData.rate_dirty ? "고친 칸은 [계산완료·저장] 때 공사 STC 의 이 표에 씀" : ""}
${lookupPanel(ctx, m)}
${tabs}
${body}
`; } @@ -371,6 +379,14 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate const r = n0(el.getAttribute("data-r")), c = n0(el.getAttribute("data-c")); if (!m[r]) return; + // 처음 고칠 때 원래 표를 남김(수정 취소) + const rows0 = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src]; + u.krOrig = u.krOrig || {}; + if (!u.krOrig[src]) + u.krOrig[src] = { + m: JSON.parse(JSON.stringify(m)), + rows: rows0 && JSON.parse(JSON.stringify(rows0)), + }; m[r][c - 1] = n0(el.value); // 공사 안 표면 원본 행(RATE_xx 칸)에도 적음 const rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src]; @@ -379,9 +395,31 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate .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)); + ctx.dbfData.rate_dirty = true; // 저장 길 = /api/ui/save rate_tables(api_ui_code._rates) } re(); }); + api.onClick("kr-undo", () => { + const o = u.krOrig && u.krOrig[src]; + if (!o) return; + u.krMats[src] = o.m; + if (o.rows && ctx.dbfData && ctx.dbfData.rate_tables) ctx.dbfData.rate_tables[src] = o.rows; + delete u.krOrig[src]; + if (ctx.dbfData) ctx.dbfData.rate_dirty = Object.keys(u.krOrig).length > 0; + re(); + }); + api.onClick("kr-print", () => { + const w = global.open("", "_blank"); + if (!w) return; + const t = m + .map((row, i) => `${i}${row.map((v) => `${v}`).join("")}`) + .join(""); + w.document.write( + `원가계산 제비율표 ${esc(src)}

원가계산 제비율표 — ${esc(src)}

${t}
`, + ); + w.document.close(); + w.print(); + }); api.onClick("kr-apply", () => { const S = global.STMATE_KANJUB_SCREEN, { n, cfg } = S.stageCfg(ctx); diff --git a/tools/stmate-test/scr_stage_contract.js b/tools/stmate-test/scr_stage_contract.js index bba1f3ae8..f3e3d7792 100644 --- a/tools/stmate-test/scr_stage_contract.js +++ b/tools/stmate-test/scr_stage_contract.js @@ -11,11 +11,10 @@ const S = (global.STMATE_STAGE = global.STMATE_STAGE || { tabs: [] }); const n0 = (v) => Number(v) || 0; const OPTIONS = [ - ["basicApply", "기초단가(재,노,경,일식)에 단가적용율 적용하기"], + ["baseRate", "기초단가(재,노,경,일식)에 단가적용율 적용하기"], ["makeIlwi", "적용율 적용된 일위대가/산출근거 생성하기"], ["sameCodeSeparate", "계약내역내 동일코드의 계약단가 개별생성"], - ["laborRatio", "노무비율 적용"], - ["taxFreeApply", "비과세자재대(원가계산)에 단가적용율 적용하기"], + ["taxFree", "비과세자재대(원가계산)에 단가적용율 적용하기"], ["zeroToW", '단가적용율이 0% 이면 계약단가(W코드)를 "0"化 (공내역 생성)'], ]; const LEVELS = [ @@ -53,6 +52,13 @@ excludeGov: true, zeroToW: true, sameCodeSeparate: false, + baseRate: true, + makeIlwi: true, + taxFree: true, + // 비과세자재대 = COSTN NOTA 표시 코드(실무 표본 0건) + taxFreeCodes: ((ctx.dbfData && ctx.dbfData.costn_rows) || []) + .filter((r) => String(r.NOTA || "").trim() === "T") + .map((r) => String(r.CODE).trim()), level: 0, stage: 1, target: "", @@ -204,6 +210,7 @@ ) .join("")} 단가 적용율 【노】${inp("ct-rl", o.rateL)}%【재】${inp("ct-rm", o.rateM)}%【경】${inp("ct-rs", o.rateS)}% + 단가 절사 자리${inp("ct-dg", o.digits, 40)} @@ -315,20 +322,28 @@ o.rateL = o.rateM = o.rateS = o.found.rate; re(); }); + api.onClick("ct-labor", () => ((o.rateM = o.rateS = o.rateL), re())); + // 설계 → 계약자료 변환등록(wM_Conv_Mode) — 고른 차수(당초/변경) 수량·단가 · 새 PCODE 공사 · 새 코드 COSTN(NAGCD) api.onClick("ct-reg", async () => { if (!ctx.currentSourcePath) return alert("먼저 공사를 불러옴"); const c = compute(ctx); - const ex = c.conv.nodes.filter((nd) => nd.kind === "leaf" && nd.excluded && nd.recno != null); + const d = ctx.dbfData; + const reg = K.registerRows(c.conv, d.costn_rows, d.costn_memos || {}, d.bdqty_rows || []); try { - const r = await api.postJson("/api/file/copy-register", { + const r = await api.postJson("/api/ui/contract-register", { source_path: ctx.currentSourcePath, src_pcode: String((ctx.state.projectMeta && ctx.state.projectMeta.pcode) || "11"), - mode: "contract", pname: `${S.pname(ctx)} (계약)`, - rates: { M: o.rateM, L: o.rateL, S: o.rateS }, - exclude_recnos: ex.map((nd) => nd.recno), + pnum: `${o.rateL}% 적용`, + boq_rows: reg.boq, + costn_rows: reg.costn, + costn_memos: reg.memos, + bdqty_rows: reg.bdqty, + costw1: c.kj.grandTotal, }); - alert(`계약자료 변환등록 — ${JSON.stringify(r).slice(0, 300)}`); + alert( + `계약자료 변환등록 — 새 공사 PCODE ${r.new_pcode} · 새 코드 ${reg.costn.length}건 · ${r.saved_stc || ""}`, + ); } catch (e) { alert("변환등록 실패: " + e.message); } diff --git a/tools/stmate-test/scr_ui_boq_dlg.js b/tools/stmate-test/scr_ui_boq_dlg.js index 2742a194f..7c4371a5f 100644 --- a/tools/stmate-test/scr_ui_boq_dlg.js +++ b/tools/stmate-test/scr_ui_boq_dlg.js @@ -302,8 +302,17 @@ ], "none", )} -
-

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

+
+
하위 전체공종 반영일 때만
+

예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 … / 하위 전체공종 반영 = 고른 레벨 머리 아래 공종·단가코드를 묶음마다 1.1 · 1.1.1

`, ); $("it-ok").onclick = () => { @@ -322,7 +331,9 @@ }); Object.assign(o, { prefix: val("it-prefix"), - resetLevel: rs === "" ? undefined : Number(rs), + resetLevel: rs === "" || rs === "tree" ? undefined : Number(rs), + tree: rs === "tree", + maxDepth: Number(val("it-depth")) || 0, }); const ix = x.selIdx(ctx); if (val("it-range") === "sel") { @@ -348,8 +359,9 @@ ], "all", )} -
-
+
※ 재료비,노무비,경비에 동일한 할증율이 적용됩니다. ※
+
+
-

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

+

※ 할증율이 0%인경우 100%로 적용됨 · 단가 = 기준단가(할증전 단가) × 할증율 · 내역 행 RATE·RM·RL·RS 칸에 기록

`, ); $("br-ok").onclick = () => { @@ -368,9 +380,16 @@ recs, { all: val("br-all"), M: val("br-m"), L: val("br-l"), S: val("br-s") }, val("br-mode"), + { + notUC: $("br-notuc").checked, + base: (c) => { + const r = (ctx.dbfData.costn_rows || []).find((x) => String(x.CODE).trim() === c); + return r && { M: B.num(r.MAMT1), L: B.num(r.LAMT1), S: B.num(r.SAMT1) }; + }, + }, ); api.closeModal(); - ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록`; + ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록${res.kept ? ` · 보호 견적 ${res.kept}행 그대로` : ""}`; x.after(ctx, api, { ok: true }, true); }; } diff --git a/tools/stmate-test/scr_ui_dan.js b/tools/stmate-test/scr_ui_dan.js index e21038abf..d23a2f2f9 100644 --- a/tools/stmate-test/scr_ui_dan.js +++ b/tools/stmate-test/scr_ui_dan.js @@ -97,6 +97,7 @@ }) .join("")} ${ad ? esc(slotName(ad)) : base && U.isUserEdited(r) ? "직접" : ""} + ${/^[MLSW]/.test(c) ? `` : ""} ${esc(r.BQCODE || "")}${esc(r.EQCODE || "")} ${/^[BDX]/.test(c) ? `` : ""} `; @@ -124,7 +125,7 @@ ${toolBar(ctx, s)}
- ${slots.list.map((x) => ``).join("")} + ${slots.list.map((x) => ``).join("")}${body}
No코드명칭규격단위재료비노무비경비${esc(slots.applied)}${esc(x.name)}채택분류번호시설코드${esc(x.name)}채택보호분류번호시설코드
`; } @@ -137,10 +138,11 @@ const names = UA.nameList(ctx.dbfData.costn_rows, s.kind === "ALL" ? "" : s.kind, "NAME"); const b = (id, t, on) => ``; - return `
${b("dan-new", "신규등록(A)")}${b("dan-del", "현재코드 삭제", Boolean(s.code))}${b("dan-chg", "코드번호 변경", Boolean(s.code))}${b("dan-rp", "현재코드 항목교체(내역포함)", Boolean(s.code))}${b("dan-jd", "호환규정코드 검색")}${b("dan-hal", "적용단가 일괄할증")}${b("dan-chgamt", "단가 일괄변경")}${b("dan-xr", "손료계수(F6)")} + return `
${b("dan-new", "신규등록(A)")}${b("dan-del", "현재코드 삭제", Boolean(s.code))}${b("dan-chg", "코드번호 변경", Boolean(s.code))}${b("dan-rp", "현재코드 항목교체(내역포함)", Boolean(s.code))}${b("dan-jd", "호환규정코드 검색")}${b("dan-hal", "적용단가 일괄할증")}${b("dan-chgamt", "단가 일괄변경")}${b("dan-xr", "손료계수(F6)")}${b("dan-check", "성분 검사(TAMT=재+노+경)")} ${pp.length ? `` : ""} -
`; + +
`; } function boxa(ctx, api) { @@ -166,7 +168,8 @@ keepUser: scope !== "one" && $("bx-keep").checked, }); ch.forEach((c) => syncRes(ctx, c.code, c.to, `slot${c.slot}`)); - ctx.uiState.danMsg = `${ch.length}건 채택 바꿈`; + const kept = rows.filter(U.isProtected).length; + ctx.uiState.danMsg = `${ch.length}건 채택 바꿈${kept ? ` · 보호 ${kept}건 그대로` : ""}`; api.closeModal(); api.cascadeAndRender(); }; @@ -180,7 +183,7 @@

※ 단【 적용단가 】가 "0" 원인것은 수정안됨 · 기준단가 = 채택 물가지 단가(채택 없으면 지금 적용단가)

-

보호표시 칸은 COSTN 자료에서 확인되지 않아 걸러내지 못함

+

※ 보호표시(√)된 단가도 수정안됨

`, ); $("eh-ok").onclick = () => { @@ -193,12 +196,37 @@ mode: $("eh-up").value, }); ch.forEach((c) => syncRes(ctx, c.code, c.to, "manual")); - ctx.uiState.danMsg = `기준단가 수정 ${ch.length}건`; + const kept = ctx.dbfData.costn_rows.filter(U.isProtected).length; + ctx.uiState.danMsg = `기준단가 수정 ${ch.length}건${kept ? ` · 보호 ${kept}건 제외` : ""}`; api.closeModal(); api.cascadeAndRender(); }; } + // 성분 검사(10 §3) — COSTN · 지금 내역 표 · 어긋난 칸 목록 + function checkAll(ctx, api) { + const CK = global.STMATE_UI_CODE_CHECK; + if (!CK) return; + const bad = CK.checkComponents(ctx.dbfData.costn_rows).concat( + CK.checkBoq(ctx.dbfData.boq_rows), + ); + const { fmt } = api; + api.openModal( + `성분 검사 — 어긋남 ${bad.length}건`, + `

TAMT = 재료비 + 노무비 + 경비 · L/M/S 코드는 자기 비목만 · 차수 칸 1~4 · 내역은 단가·금액 칸

+ ${ + bad.length + ? `
${bad + .map( + (x) => + `${["T", "M", "L", "S"].map((k) => ``).join("")}`, + ) + .join("")}
자리코드차수내용계재노경
${esc(x.where)}${esc(x.code)}${x.slot}${esc(x.msg)}${fmt(x.v[k])}
` + : "

어긋난 칸 없음

" + }
`, + ); + } + function findUse(ctx, api) { const s = st(ctx), f = U.findUse(s.code, ctx.dbfData); @@ -237,9 +265,23 @@ on("dan-chgamt", () => T2.openChgAmt(ctx, api)); on("dan-xr", () => T2.openXRate(ctx, api, s.code)); } + on("dan-check", () => checkAll(ctx, api)); if ($("dan-pp")) $("dan-pp").onchange = (e) => ((s.pp = e.target.value), rr()); if ($("dan-nm")) $("dan-nm").onchange = (e) => ((s.nm = e.target.value), rr()); if ($("dan-called")) $("dan-called").onchange = (e) => ((s.called = e.target.checked), rr()); + if ($("dan-autohal")) $("dan-autohal").onchange = (e) => (s.autoHal = e.target.checked); + document.querySelectorAll("#dan-form .dan-prot").forEach( + (el) => + (el.onchange = () => { + const c = el.getAttribute("data-code"); + const r = ctx.dbfData.costn_rows.find((x) => U.codeOf(x) === c); + const hal = s.autoHal !== false && ctx.uiState.danHal; + const res = U.setProtect(r, el.checked, { autoHal: hal || null }); + if (res && res.hal) syncRes(ctx, c, res.hal.to, "manual"); + ctx.uiState.danMsg = `${c} 보호${el.checked ? "" : " 해제"}${res && res.hal ? ` · 자동할증 ${res.hal.to}` : ""}`; + api.cascadeAndRender(); + }), + ); const grid = $("dan-grid"); if (s.scroll) grid.scrollTop = s.scroll; grid.onscroll = () => (s.scroll = grid.scrollTop); diff --git a/tools/stmate-test/scr_ui_dan2.js b/tools/stmate-test/scr_ui_dan2.js index f731fbf80..81cd84553 100644 --- a/tools/stmate-test/scr_ui_dan2.js +++ b/tools/stmate-test/scr_ui_dan2.js @@ -126,6 +126,7 @@ rate: 100, digits: 4, skipZero: false, + force: true, // 차수 칸 옮김은 보호표시와 무관하게 전부 }); if (to === 1) syncState( @@ -171,6 +172,13 @@ ); $("hl-ok").onclick = () => { const k = val("hl-k"); + // 마지막 할증 설정 — 보호해제시 자동할증(Chk_Auto_Hal)이 다시 씀 + ctx.uiState.danHal = { + how: val("hl-how"), + value: num(val("hl-v")), + digits: Number(val("hl-dot")), + mode: val("hl-up"), + }; const ch = UA.halAll(ctx.dbfData.costn_rows, { kinds: k === "one" ? c[0] : k, codes: k === "one" ? [c] : null, diff --git a/tools/stmate-test/scr_ui_prj.js b/tools/stmate-test/scr_ui_prj.js index 0858dbdb2..ec102c0e2 100644 --- a/tools/stmate-test/scr_ui_prj.js +++ b/tools/stmate-test/scr_ui_prj.js @@ -163,6 +163,30 @@ api.renderWorkspace(); }; + // 단계 등록(기성 6 · 설계변경 7 / 차수별 0 · 당초/변경 1) — 원본 PCLASS·K_ORD 로 새 K_ORD(api_ui_project.py) → 새 공사 열고 단계 탭 + const stageReg = (op, flag, act) => async (ctx, api) => { + const r = selRow(ctx); + if (!r || !ctx.currentSourcePath) return stage(act)(ctx, api); + const what = op === 6 ? "기성내역" : flag ? "설계변경(당초/변경)" : "차수별 설계변경"; + if (!confirm(`[${String(r.PCODE).trim()}] ${r.PNAME} 을(를) ${what} 으로 등록할까요?`)) + return stage(act)(ctx, api); + try { + const res = await api.postJson("/api/ui/stage-register", { + source_path: ctx.currentSourcePath, + src_pcode: String(r.PCODE).trim(), + op, + flag, + pname: `${r.PNAME} (${what})`, + }); + await api.loadProjectFromServer(res.saved_stc, "wPrjList"); + ctx.uiState.prjSel = res.new_pcode; + ctx.uiState.boqMsg = `${what} 등록 — PCODE ${res.new_pcode} · K_ORD ${res.k_ord}`; + stage(act)(ctx, api); + } catch (err) { + alert(`${what} 등록 실패: ${err.message}`); + } + }; + // 다른 내역서 열기 — 고른 공사 행의 BOQ{PCODE}.DBF 로 바꿔 불러옴(/api/ui/load-boq · api_ui_code.py) async function openBoq(ctx, api, pcode) { const d = ctx.dbfData; @@ -232,9 +256,9 @@ Act_Prn: (ctx, api) => go(ctx, api, "PM_Prt"), 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"), + ActMkCont10: stageReg(6, 0, "Act_Stg_Prog"), + ActMkCont21: stageReg(7, 1, "Act_Stg_Change"), + ActMkCont20: stageReg(7, 0, "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) => { @@ -254,6 +278,25 @@ Act_Indx_PCODE: sortBy("PCODE"), Act_Indx_PNAME: sortBy("PNAME"), Act_Indx_PNUM: sortBy("PNUM"), + // 내역서 삭제(Act_Del_PrjExecute) — 고른 공사 행·BOQ1n 을 뺀 작업본 STC 를 쓰고 그것을 다시 엶(api_ui_project.py) + Act_Del_Prj: async (ctx, api) => { + const r = selRow(ctx); + if (!r || !ctx.currentSourcePath) return; + const pc = String(r.PCODE).trim(); + if (prjRows(ctx).length < 2) return alert("마지막 공사는 지울 수 없음"); + if (!confirm(`[${pc}] ${r.PNAME} 내역서를 삭제할까요? (작업본 STC 에 반영 · 원본은 그대로)`)) + return; + try { + const res = await api.postJson("/api/ui/project-delete", { + source_path: ctx.currentSourcePath, + pcode: pc, + }); + ctx.uiState.prjSel = ""; + await api.loadProjectFromServer(res.saved_stc, "wPrjList"); + } catch (err) { + alert("내역서 삭제 실패: " + err.message); + } + }, Act_List_UP: moveSel(-1), Act_List_Down: moveSel(1), }; diff --git a/tools/stmate-test/scr_ui_sys.js b/tools/stmate-test/scr_ui_sys.js index 5957de317..86e5db017 100644 --- a/tools/stmate-test/scr_ui_sys.js +++ b/tools/stmate-test/scr_ui_sys.js @@ -78,6 +78,7 @@
${field({ key: rx.rx, type: "text" }, d)}
${field({ key: rx.con, type: "codes" }, d)}
+
소 수 점 적 용 기 준${S.FIELDS.digits.map((f) => row(f, d)).join("")}
계 산 방 식${S.FIELDS.calc.map((f) => row(f, d)).join("")} @@ -118,8 +119,45 @@ ctx.uiState.boqMsg = `초기값 ${changed.length}칸 반영(${changed.join(", ")})`; } + // 직종·노임 설정(wM_Set_JLab) — 노무비 코드 목록에서 고른 코드를 적용코드 목록으로(추가·삭제) · 적용단가 미리보기 + function openJLab(ctx, api, conKey, orig) { + const d = draft(ctx), + $ = (id) => document.getElementById(id); + const ev = global.STMATE_SMT_EVAL; + const hourly = ev && ev.hourlyWage; + const ini = Object.assign({}, ctx.dbfData.start_ini, d); + const j = S.jlab(ctx.dbfData.costn_rows, ini, conKey, hourly); + const fmt = api.fmt; + const tr = (r, cls) => + `${esc(r.code)}${esc(r.name)}${esc(r.size)}${esc(r.unit)}${cls === "jl-del" ? `${r.hourly == null ? "" : fmt(r.hourly)}` : ""}${fmt(r.price)}`; + api.openModal( + `시간당 노임산출계수 적용코드 [wM_Set_JLab] — ${conKey} · 환산식 ${j.formula || "(없음)"}`, + `

노무비 코드를 목록에서 선택하십시요 — 선택코드 추가 · 선택코드삭제 · 적용 / 취소

+
+
노무비 목록(Grd_BDLst)
${j.source.map((r) => tr(r, "jl-add")).join("")}
코드번호명칭규격단위단가
+
적용될 코드목록(Grd_CONLTD)
${j.target.map((r) => tr(r, "jl-del")).join("")}
코드번호명칭규격단위적용단가단가
+
+
`, + ); + let cur = String(d[conKey] != null ? d[conKey] : ini[conKey] || ""); + const back = orig == null ? cur : orig; + const picked = (cls) => [...document.querySelectorAll(`.${cls}:checked`)].map((x) => x.value); + const apply = (next) => ( + (d[conKey] = next), + api.closeModal(), + openJLab(ctx, api, conKey, back) + ); + $("jl-add").onclick = () => apply((cur = S.jlabEdit(cur, picked("jl-add"), []))); + $("jl-del").onclick = () => apply((cur = S.jlabEdit(cur, [], picked("jl-del")))); + $("jl-ok").onclick = () => (api.closeModal(), api.renderWorkspace()); + $("jl-cancel").onclick = () => ((d[conKey] = back), api.closeModal(), api.renderWorkspace()); + } + function bind(ctx, api) { if (!ctx.dbfData) return; + document + .querySelectorAll("#sys-form .sys-jlab") + .forEach((b) => (b.onclick = () => openJLab(ctx, api, b.getAttribute("data-k")))); const d = draft(ctx), u = ctx.uiState, $ = (id) => document.getElementById(id); diff --git a/tools/stmate-test/ui_boq_tools.js b/tools/stmate-test/ui_boq_tools.js index 99a2c1e7e..27455fe5b 100644 --- a/tools/stmate-test/ui_boq_tools.js +++ b/tools/stmate-test/ui_boq_tools.js @@ -43,6 +43,7 @@ * range: [시작 행, 끝 행] 선택 범위(없으면 전체) } */ function autoItnum(rows, opts) { + if (opts && opts.tree) return treeItnum(rows, opts); const o = Object.assign( { level: 2, target: "group", first: "", last: ".", type: "num", pad: 1, margin: 0 }, { prefix: "none" }, @@ -89,6 +90,66 @@ return { changed }; } + /** + * 하위 전체공종 반영(cBx_Bl_Lev 첫 선택) — 대상 레벨 머리(뿌리)부터 그 안의 공종(계표시)·단가코드를 묶음마다 한 차례로 + * 뿌리 = 여백 + 첫 글자 + 번호 + 끝 글자 · 번호는 바로 위 머리마다 처음부터 + * 하위 = 직전공종 번호(끝 「.」 뗌) + 「.」 + 번호(cBx_itNum_B 직전공종의 공종번호) · maxDepth = 번호 마디 수 한도 + * o.range = [시작 행, 끝 행] 선택 범위 — 범위 밖 행은 그대로(번호 차례는 범위 안에서만 셈) + */ + function treeItnum(rows, opts) { + const o = Object.assign( + { level: 3, target: "both", first: "", last: "", type: "num", pad: 1, margin: 0 }, + opts || {}, + ); + const lo = o.range ? o.range[0] : 0, + hi = o.range ? o.range[1] : rows.length - 1; + const links = B.linkPairs(rows); + const idx = new Map(rows.map((r, i) => [r, i])); + const open = []; + const cnt = new Map(); // 부모 머리(또는 뿌리 위 머리) → 지금 번호 + const depth = new Map(); // 번호 붙은 머리 → 마디 수 + const wantGroup = o.target !== "leaf", + wantLeaf = o.target !== "group"; + const lv = Number(o.level); + let changed = 0; + for (let i = 0; i < rows.length; i++) { + const r = rows[i], + k = rowKind(r); + while (open.length && idx.get(links.get(open[open.length - 1])) < i) open.pop(); + const isT = k.kind === "T"; + if (i >= lo && i <= hi && (isT || k.kind === "LEAF")) { + const root = [...open].reverse().find((h) => rowKind(h).n === lv); + const par = open.length ? open[open.length - 1] : null; + const isRoot = isT && k.n === lv; + const inTree = !isRoot && root && depth.has(par); + const want = isT ? wantGroup : wantLeaf; + if ((isRoot || inTree) && (want || isT)) { + const key = isRoot ? par || "top" : par; + const n = (cnt.get(key) || 0) + 1; + const d = isRoot ? 1 : depth.get(par) + 1; + if (!o.maxDepth || d <= Number(o.maxDepth)) { + cnt.set(key, n); + const seq = seqText(n, o.type, o.pad); + const text = isRoot + ? " ".repeat(Number(o.margin) || 0) + (o.first || "") + seq + (o.last || "") + : String(par.ITNUM || "") + .trim() + .replace(/\.$/, "") + + "." + + seq; + if (want) { + r.ITNUM = text; + changed += 1; + } + if (isT) depth.set(r, d); + } + } + } + if (isT && links.get(r)) open.push(r); + } + return { changed }; + } + // 반올림·절사 — 소수 d 자리 function roundTo(v, d, mode) { const f = Math.pow(10, Number(d) || 0); @@ -235,13 +296,24 @@ return { changed }; } - // 할증율 적용(wBoqRate) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별) · 계산 원천 항목에도 halRate 로 둠 - function applyHalRate(model, recnos, rates, mode) { + /** + * 할증율 적용(wBoqRate · ST_Boq.CPM TwBoqRate DFM) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별, N7.3) + * 할증율 = 기준단가(할증전 단가)에 곱할 백분율 · 「할증율이 0%인경우 100%로 적용됨」 → 0 = 그대로 + * CBoxNotUC 「보호된 견적단가 수정안함」(기본 켬) — 견적단가(W) 행 중 보호(UC=T) 행은 건너뜀 + * 계산 원천 halRate = 할증율 − 100(calc.js 가산식 × (1 + p/100) = × 할증율/100) + * o.base(코드) = 기준단가 {M,L,S}(COSTN 1칸) — 주면 행 1칸 단가도 비목별 원 미만 버림으로 씀(저장 반영) + */ + function applyHalRate(model, recnos, rates, mode, o) { + const opt = Object.assign({ notUC: true }, o || {}); const set = recnos ? new Set(recnos.map(num)) : null; const byRec = new Map(model.items.map((it) => [num(it._recno), it])); - let changed = 0; + const add = (v) => (num(v) > 0 ? num(v) - 100 : 0); + let changed = 0, + kept = 0; model.rows.forEach((r) => { if (!isLeaf(r) || (set && !set.has(num(r._recno)))) return; + if (opt.notUC && codeOf(r)[0] === "W" && String(r.UC || "").trim() === "T") + return void kept++; const all = num(rates.all), m = mode === "all" ? all : num(rates.M), l = mode === "all" ? all : num(rates.L), @@ -253,15 +325,27 @@ RS: String(s), }); const it = byRec.get(num(r._recno)); - if (it) it.halRate = { M: m, L: l, S: s }; + if (it) it.halRate = { M: add(m), L: add(l), S: add(s) }; + const b = opt.base && opt.base(codeOf(r)); + if (b) { + const u = { M: m, L: l, S: s }; + ["M", "L", "S"].forEach( + (c) => + (r[`${c}AMT1`] = String( + Math.trunc((num(b[c]) * (u[c] > 0 ? u[c] : 100)) / 100 + 1e-9), + )), + ); + r.TAMT1 = String(num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1)); + } changed += 1; }); - return { changed }; + return { changed, kept }; } global.STMATE_UI_BOQ_TOOLS = { seqText, autoItnum, + treeItnum, roundTo, expandIlwi, roundQty, diff --git a/tools/stmate-test/ui_code.js b/tools/stmate-test/ui_code.js index f0a0cb13d..247b8b8da 100644 --- a/tools/stmate-test/ui_code.js +++ b/tools/stmate-test/ui_code.js @@ -459,7 +459,7 @@ // 구성표(bdqty_rows)는 기본 저장 묶음이 함께 보냄(stc_save_extra.save_bdqty) return { costn_struct, costn_deleted: d.costn_deleted || [], boq_code_rows }; } - const needsUiSave = (d) => Boolean(d && d.ui_dirty); + const needsUiSave = (d) => Boolean(d && (d.ui_dirty || d.rate_dirty)); global.STMATE_UI_CODE = { CODE_RE, diff --git a/tools/stmate-test/ui_code_amt.js b/tools/stmate-test/ui_code_amt.js index b526792f1..1991ef68b 100644 --- a/tools/stmate-test/ui_code_amt.js +++ b/tools/stmate-test/ui_code_amt.js @@ -17,6 +17,8 @@ }; const codeOf = (r) => txt(r && r.CODE); const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" }; + // 보호표시(wM_Boxa 「보호」 열 = 기초·견적단가 UC) — 일괄 손질에서 건너뜀(ui_dan.isProtected 와 같은 판정) + const isProtected = (r) => /^[MLSW]/.test(codeOf(r)) && txt(r.UC) === "T"; // 반올림·절사 — 소수 d 자리(음수 = 10^-d 원 단위) function roundTo(v, d, mode) { @@ -51,6 +53,7 @@ const c = codeOf(r); if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return; if (num(r.NAGCD) > 0 && (Number(opt.from) === 2 || Number(opt.to) === 2)) return; + if (isProtected(r) && !opt.force) return; const src = slotVals(r, opt.from); const T0 = src.M + src.L + src.S; if (opt.skipZero && !(T0 > 0)) return; @@ -81,6 +84,7 @@ (rows || []).forEach((r) => { const c = codeOf(r); if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return; + if (isProtected(r)) return; const cur = slotVals(r, 1); const curT = cur.M + cur.L + cur.S; const base = num(r.AMT_61) || curT; @@ -310,6 +314,7 @@ } global.STMATE_UI_CODE_AMT = { + isProtected, roundTo, chgAmt, halAll, diff --git a/tools/stmate-test/ui_code_check.js b/tools/stmate-test/ui_code_check.js new file mode 100644 index 000000000..8bab6d71e --- /dev/null +++ b/tools/stmate-test/ui_code_check.js @@ -0,0 +1,62 @@ +/** + * STMATE w 8.0 — 단가 성분 검사 (ui_code_check.js) · 화면·DOM 없음(node 시험 가능) + * 근거: 지식DB STmate/old/20_분석 10 §3(검증 조건식 `TAMT1 <> MAMT1 + LAMT1 + SAMT1` 세 모듈 · TAMT = 총계) + * · 코드종류별 성분 — L → LAMT = TAMT · M → MAMT = TAMT · S → SAMT = TAMT · 복합/W → TAMT = M + L + S + * 차수 칸 1~4 마다 · 내역(BOQ) 단가코드 행은 단가 칸(TAMTn)과 금액 칸(T_AMTn) 둘 다 + */ +(function (global) { + "use strict"; + + const txt = (v) => String(v == null ? "" : v).trim(); + const num = (v) => { + const n = parseFloat(txt(v).replace(/,/g, "")); + return Number.isFinite(n) ? n : 0; + }; + const OWN = { M: "M", L: "L", S: "S" }; + const STRUCT = /^(T\d|E\d|C\d|:)$/; + const near = (a, b) => Math.abs(a - b) < 1e-6; + + // 한 칸 무리 검사 — pre(단가 "" · 금액 "_") + function checkSlot(r, code, s, pre, where) { + const out = []; + const f = (k) => `${k}${pre}AMT${s}`; + if (!(f("T") in r)) return out; + const v = { M: num(r[f("M")]), L: num(r[f("L")]), S: num(r[f("S")]), T: num(r[f("T")]) }; + if (!near(v.T, v.M + v.L + v.S)) + out.push({ where, code, slot: s, rule: "sum", msg: `${f("T")} ≠ 재+노+경`, v }); + const own = OWN[code[0]]; + if (own && ["M", "L", "S"].some((k) => k !== own && v[k] !== 0)) + out.push({ where, code, slot: s, rule: "own", msg: `${own} 코드에 다른 비목 금액`, v }); + return out; + } + + /** COSTN 행 — 차수 칸 1~4 · [{ where, code, slot, rule: sum|own, msg, v }] */ + function checkComponents(rows) { + const out = []; + for (const r of rows || []) { + const c = txt(r.CODE).toUpperCase(); + if (!c) continue; + for (const s of [1, 2, 3, 4]) out.push(...checkSlot(r, c, s, "", "COSTN")); + } + return out; + } + + /** 내역 단가코드 행 — 단가(TAMTn) · 금액(T_AMTn) · % 줄은 금액 칸만 */ + function checkBoq(rows) { + const out = []; + for (const r of rows || []) { + const c = txt(r.BDCODE1).toUpperCase(); + if (!c || STRUCT.test(c)) continue; + const pct = txt(r.UNIT1).endsWith("%"); + for (const s of [1, 2, 3, 4]) { + if (!pct) out.push(...checkSlot(r, c, s, "", `BOQ#${r._recno}`)); + out.push(...checkSlot(r, c, s, "_", `BOQ#${r._recno}`)); + } + } + return out; + } + + const api = { checkComponents, checkBoq }; + global.STMATE_UI_CODE_CHECK = api; + if (typeof module !== "undefined" && module.exports) module.exports = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/ui_dan.js b/tools/stmate-test/ui_dan.js index c56beec5e..89bb996c3 100644 --- a/tools/stmate-test/ui_dan.js +++ b/tools/stmate-test/ui_dan.js @@ -4,6 +4,10 @@ * · 22_UI_폼_분석 §3.1 wM_Edt_Hal(적용단가 = 기준단가 × % · 소수 자리 미만 절사/반올림 · 적용단가 0원 제외) * · STC_필드사전_핵심(AMT_11~51 후보 5칸 · PG_1 = 채택 출처 · PG_11~51 페이지) · Start8x.Ini JMOK1~5·JMK10~50·JEMNM * 그림코드 = I 코드(DFM uM_ChgCode·uM_DelCode·wM_Boxa 의 Tab_I 「그림코드(Ctrl+I)」 · Act_Sel_I) — 실자료 표본에는 없음 + * 보호표시 = wM_Boxa 단가 격자 「보호」 열(√/공란 = True/False · 논리 칸) → 기초·견적단가(M·L·S·W) 행의 COSTN UC 로 읽음 + * · 일괄 손질(가격지 채택·기준단가 수정·일괄할증·일괄변경)은 보호 행을 건너뜀(22 §3.1 안내문) + * · 보호해제시 적용단가 자동할증(Chk_Auto_Hal) = 풀 때 그 행에만 일괄할증을 다시 걺 + * · B·D 행의 UC 는 산근 편집기 「결과값에 낙찰율적용」(Ck_UC) 자리 — 실무 7건 UC=T ⇔ NAGCD>0 이라 보호로 보지 않음 */ (function (global) { "use strict"; @@ -32,6 +36,17 @@ ]; const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" }; const isBase = (code) => /^[MLS]/.test(code); + const isProtected = (r) => /^[MLSW]/.test(codeOf(r)) && String(r.UC || "").trim() === "T"; + // 보호 켜기·끄기 — 끌 때 autoHal(일괄할증 설정)이 있으면 그 행만 다시 할증(STMATE_UI_CODE_AMT.halAll) + function setProtect(r, on, o) { + if (!/^[MLSW]/.test(codeOf(r))) return null; + r.UC = on ? "T" : "F"; + const UA = global.STMATE_UI_CODE_AMT; + if (on || !(o && o.autoHal) || !UA) return { code: codeOf(r), on: Boolean(on), hal: null }; + const kind = codeOf(r)[0]; + const hal = UA.halAll([r], Object.assign({}, o.autoHal, { kinds: kind, codes: [codeOf(r)] })); + return { code: codeOf(r), on: false, hal: hal[0] || null }; + } // 가격지 이름 — 프로젝트 초기값(JMOK1~5 · 줄임 JMK10~50 · 표시 차례 JEMNM) function slotNames(ini) { @@ -109,7 +124,7 @@ function adopt(r, how, o) { const opt = Object.assign({ skipZero: true }, o || {}); const c = codeOf(r); - if (!isBase(c) || how === "none") return null; + if (!isBase(c) || how === "none" || isProtected(r)) return null; const cand = candidates(r); let slot = Number(how), v = cand[slot - 1]; @@ -168,6 +183,7 @@ rows.forEach((r) => { const c = codeOf(r); if (!c || !isBase(c) || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return; + if (isProtected(r)) return; const cur = appliedPrice(r); if (!(cur > 0)) return; const s = adoptedSlot(r), @@ -216,6 +232,8 @@ num, codeOf, isBase, + isProtected, + setProtect, slotNames, candidates, pages, diff --git a/tools/stmate-test/ui_style.css b/tools/stmate-test/ui_style.css index 42e0f40ce..4f2dbb923 100644 --- a/tools/stmate-test/ui_style.css +++ b/tools/stmate-test/ui_style.css @@ -197,3 +197,27 @@ table.dbgrid tr.boq-new td.rowhead { font-size: 14px; color: var(--text-sub); } + +/* 산출근거 문법 강조(scr_edit_lang highlight · 원본 TWM_EDIT_SAN Syn1) — 주석 · Text · 단가코드 · 계표시 · 숫자 · 함수 */ +.syn-cmt { + color: #15803d; + font-style: italic; +} +.syn-txt { + color: #9a3412; +} +.syn-code { + color: #1d4ed8; + font-weight: 700; +} +.syn-mark { + color: #7e22ce; + font-weight: 700; +} +.syn-num { + color: #0f766e; +} +.syn-key { + color: #1e3a8a; + font-weight: 700; +} diff --git a/tools/stmate-test/ui_sys.js b/tools/stmate-test/ui_sys.js index c5214e262..e0ea3d1d7 100644 --- a/tools/stmate-test/ui_sys.js +++ b/tools/stmate-test/ui_sys.js @@ -222,7 +222,55 @@ return a.join(""); } + /** + * 시간당 노임산출계수 적용코드(wM_Set_JLab · Grd_BDLst → Grd_CONLTD) — 노무비(L) 코드 목록 고르기 + * conKey = CONLD|CONTD|CON1D~4D · 짝 환산식 = RXXNM|RXTNM|RX1NM~4NM(빈 칸이면 RXXNM) + * 적용단가 = 일 노임(LAMT1) × 환산식(왼쪽부터) · JCDNM 자리 미만 버림(smt_eval.hourlyWage) + * 돌려줌 { formula, target: [{code,name,size,unit,price,hourly}], source: [고를 수 있는 L 코드 행] } + */ + const rxOf = (conKey) => + conKey === "CONLD" ? "RXXNM" : (RX_SETS.find((x) => x.con === conKey) || {}).rx || "RXXNM"; + function jlab(costnRows, ini, conKey, hourly) { + const i = ini || {}; + const formula = String(i[rxOf(conKey)] || i.RXXNM || "").trim(); + const digits = Number(String(i.JCDNM || "0").trim()) || 0; + const by = new Map((costnRows || []).map((r) => [String(r.CODE || "").trim(), r])); + const list = splitCodes(i[conKey]); + const rowOf = (c) => { + const r = by.get(c) || {}; + const price = Number(r.LAMT1) || 0; + const h = hourly ? hourly(price, formula, digits) : null; + return { + code: c, + name: r.NAME || "", + size: r.SIZE || "", + unit: r.UNIT || "", + price, + hourly: h, + }; + }; + const inList = new Set(list); + const source = (costnRows || []) + .map((r) => String(r.CODE || "").trim()) + .filter((c) => /^L\d{5}$/.test(c) && !inList.has(c)) + .map(rowOf); + return { formula, target: list.map(rowOf), source }; + } + // 선택코드 추가·삭제 — 차례 유지 · 겹침 없음 · 결과 = 이어 붙인 글(Start8x.Ini 값) + function jlabEdit(cur, add, del) { + const gone = new Set((del || []).map((c) => String(c).trim().toUpperCase())); + const out = splitCodes(cur).filter((c) => !gone.has(c)); + for (const c of add || []) { + const k = String(c).trim().toUpperCase(); + if (/^[A-Z]\d{5}$/.test(k) && !out.includes(k)) out.push(k); + } + return joinCodes(out); + } + global.STMATE_UI_SYS = { + jlab, + jlabEdit, + rxOf, FIELDS, RX_SETS, XML_PRESET,