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

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

-
`, +
+ 비목별 단가 끝전(wcBx_Pnt1_M·_L … · rd_Amt1) +
+ ${[ + ["M", "재료비"], + ["L", "노무비"], + ["S", "경비"], + ] + .map( + ([k, t]) => + `
`, + ) + .join("")} +
`, ); + $("mr-aok").onclick = () => { + const recs = val("mr-arange") === "sel" ? x.sel(ctx) : null; + if (recs && !recs.length) return alert("선택된 항목 없음"); + const units = {}; + (ctx.computed.boqComputed.rows || []).forEach( + (r) => + r._recno !== undefined && + (units[Number(r._recno)] = { M: r.mUnit, L: r.lUnit, S: r.sUnit }), + ); + const per = {}; + ["M", "L", "S"].forEach( + (k) => + $(`mr-on-${k}`).checked && + (per[k] = { mode: val(`mr-m-${k}`), digits: Number(val(`mr-d-${k}`)) }), + ); + const res = T.roundUnits(x.model(ctx), recs, units, per); + api.closeModal(); + ctx.uiState.boqMsg = `단가 ${res.changed}행 보정`; + x.after(ctx, api, { ok: true }, true); + }; $("mr-ok").onclick = () => { const recs = val("mr-range") === "sel" ? x.sel(ctx) : null; if (recs && !recs.length) return alert("선택된 항목 없음"); @@ -320,36 +457,80 @@ }; } - // 일위대가표 풀기(wExpnd) — RGrpToT · CMBoxUpDn · CMBoxDot + // 일위대가표 풀기 — wM_Expnd10(계표시·할증·손료 추가입력) + wM_Expnd(비목별 끝전 M·L·X·W) function expnd(ctx, api, x) { const r = ctx.dbfData.boq_rows.find((q) => Number(q._recno) === x.sel(ctx)[0]); if (!r || B.codeOf(r)[0] !== "B") return alert("풀 일위대가(B 코드) 행 하나를 고르세요"); + const UPDN = [ + ["trunc", "0 절사하여"], + ["round", "1 반올림 하여"], + ]; + const DOT5 = [0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]); + const PIN = [ + [1, "재료비의"], + [2, "노무비의"], + [3, "경비의"], + [4, "전체의"], + ]; + const POUT = [ + [1, "재료비로 계산"], + [2, "노무비로 계산"], + [3, "경비로 계산"], + [4, "해당비목 계산"], + ]; + const per = [ + ["M", "재료비코드(M)"], + ["L", "노무비코드(L)"], + ["X", "중장비코드(X)"], + ["W", "기 타 코드(W)"], + ] + .map( + ([k, t]) => + `
`, + ) + .join(""); + const add = (n) => + `
% 를
`; api.openModal( - "일위대가표 풀기 (wExpnd)", + "일위대가표 풀기 (wM_Expnd10 · wM_Expnd)", `

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

-
${opt( [ + [0, "입력안함"], [1, "소계(=)"], [2, "계(==)"], [3, "합계(===)"], ], 1, - )}
-
-

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

-
`, + )} +
+ 수량 처리방법(비목별 끝전)${per} + 3. 손료계산 추가입력${add(1)}${add(2)} +

비율(PR)·%단위·구분 구성 줄은 풀지 않음 · 추가 줄 = 풀린 줄 합의 % (내역 비율 줄 PR)

+
`, ); $("ex-ok").onclick = () => { + const perOpt = Object.fromEntries( + ["M", "L", "X", "W"].map((k) => [ + k, + { mode: val(`ex-m-${k}`), digits: Number(val(`ex-d-${k}`)) }, + ]), + ); + const adds = [1, 2] + .filter((n) => $(`ex-a${n}`).checked) + .map((n) => ({ + name: val(`ex-an${n}`), + prin: Number(val(`ex-ai${n}`)), + pct: Number(val(`ex-ap${n}`)), + prout: Number(val(`ex-ao${n}`)), + })); const o = { level: Number(val("ex-lv")), - mode: val("ex-mode"), - digits: Number(val("ex-dot")), + keepName: $("ex-name").checked, + hal: { L: Number(val("ex-hl")) || 1, M: Number(val("ex-hm")) || 1 }, + per: perOpt, + adds, }; const res = T.expandIlwi( x.model(ctx), diff --git a/tools/stmate-test/scr_ui_code.js b/tools/stmate-test/scr_ui_code.js new file mode 100644 index 000000000..8d6b65afc --- /dev/null +++ b/tools/stmate-test/scr_ui_code.js @@ -0,0 +1,295 @@ +/** + * STMATE w 8.0 — 코드 관리 창 끼워 넣기 (scr_ui_code.js) · 코드변경 · 항목교체 · 코드삭제 · +α · 신규등록 + * 근거: DFM 원문 — uM_ChgCode(현재코드 · 바뀔코드 · * 사용 · 코드변환실행 · 코드일괄변환 · 현재이후 자동입력 · 바뀔코드 초기화) + * · uM_ReplCode(대상코드 · 항목교체실행 · 동일항목(명칭, 규격... 단가) 대상코드 자동입력) · uM_DelCode(선 택 · 선택코드 + * 삭제실행 · 코드일괄삭제(중기비제외/포함) · 사용안된 "천원" "인" 일위대가 전체선택 · 동일항목 자동선택) + * · uM_Chg_Plus(종류별 현재최종 + α) · wM_Boxa Act_CopyNew(신규등록) · Act_DEL_CODE · Act_CH_CODE · Act_RP_CODE + * 계산 = ui_code.js · 저장 = app.js postSave(/api/ui/save · api_ui_code.py) + */ +(function (global) { + "use strict"; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const UC = global.STMATE_UI_CODE; + const esc = (s) => + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const $ = (id) => document.getElementById(id); + const KINDS = ["L", "M", "S", "B", "X", "D", "W"]; + const env = (ctx) => ({ + d: ctx.dbfData, + state: ctx.state, + memo: ctx.uiState.editedSmtMap || (ctx.uiState.editedSmtMap = {}), + }); + const need = (ctx) => (ctx.dbfData ? true : (alert("열린 프로젝트가 없음"), false)); + const done = (ctx, api, msg) => { + ctx.uiState.danMsg = msg; + api.closeModal(); + api.cascadeAndRender(); + }; + const fail = (res) => alert((res.errors || [res.error || "실패"]).slice(0, 12).join("\n")); + + const TITLE = { + change: "코드변경 [uM_ChgCode] — 현재코드 → 바뀔코드", + replace: "항목교체(내역포함) [uM_ReplCode] — 현재코드 → 대상코드", + delete: "코드삭제 [uM_DelCode]", + }; + + function st(ctx, mode) { + const u = ctx.uiState; + if (!u.codeMgr || u.codeMgr.mode !== mode) + u.codeMgr = { mode, kind: "L", map: {}, sel: {}, kw: "" }; + return u.codeMgr; + } + + function priceOf(r, k) { + const f = { L: "LAMT1", M: "MAMT1", S: "SAMT1" }[k] || "TAMT1"; + return Number(r[f]) || 0; + } + + function mgrHtml(ctx, api) { + const s = ctx.uiState.codeMgr, + e = env(ctx), + used = UC.usedSet(e); + const rows = (ctx.dbfData.costn_rows || []).filter((r) => { + const c = UC.codeOf(r); + if (c[0] !== s.kind) return false; + const kw = String(s.kw || "") + .trim() + .toLowerCase(); + return ( + !kw || `${c} ${r.NAME || ""} ${r.SIZE || ""} ${r.BQCODE || ""}`.toLowerCase().includes(kw) + ); + }); + const cnt = (k) => (ctx.dbfData.costn_rows || []).filter((r) => UC.codeOf(r)[0] === k).length; + const tabs = KINDS.map( + (k) => + ``, + ).join(""); + const col = s.mode === "delete" ? "선 택" : s.mode === "replace" ? "대상코드" : "바뀔코드"; + const body = rows + .map((r) => { + const c = UC.codeOf(r); + const cell = + s.mode === "delete" + ? `` + : ` `; + return `${c}${cell}${esc(r.NAME)}${esc(r.SIZE)}${esc(r.UNIT)}${esc(r.BQCODE || "")}${esc(r.EQCODE || "")}${api.fmt(priceOf(r, s.kind))}${used.has(c) ? "*" : ""}`; + }) + .join(""); + const btn = (id, t) => ``; + const acts = { + change: [ + btn("cm-run-sel", "코드변환실행(선택)"), + btn("cm-run-all", "코드일괄변환"), + btn("cm-auto", "현재이후 자동입력"), + btn("cm-clear", "바뀔코드 초기화"), + btn("cm-plus", "현재코드 + α"), + ], + replace: [ + btn("cm-run-all", "항목교체실행"), + btn("cm-same", "동일항목 대상코드 자동입력"), + btn("cm-clear", "대상코드 초기화"), + ], + delete: [ + btn("cm-del", "선택코드 삭제실행"), + btn("cm-del-x", "코드일괄삭제 (중기비제외)"), + btn("cm-del-t", "코드일괄삭제 (중기비포함)"), + btn("cm-1000", '사용안된 "천원" "인" 일위대가 선택'), + btn("cm-same", "동일항목 자동선택"), + btn("cm-clear", "선택코드 초기화"), + ], + }[s.mode]; + return `
${tabs}
+
${acts.join("")}
+
${body}
현재코드${col}명칭규격단위분류번호시설코드단가*
+

* = 내역서·구성표·산출근거 식에 쓰임 · 쓰이는 코드는 지우지 않음 · 바꾼 뒤 [계산 완료] 저장 때 작업본에 씀

`; + } + + function openMgr(ctx, api, mode) { + if (!need(ctx)) return; + const s = st(ctx, mode); + api.openModal(TITLE[mode], mgrHtml(ctx, api)); + const re = () => openMgr(ctx, api, mode); + document + .querySelectorAll(".cm-kind") + .forEach((b) => (b.onclick = () => ((s.kind = b.getAttribute("data-k")), re()))); + $("cm-kw").onchange = (e) => ((s.kw = e.target.value), re()); + document + .querySelectorAll(".cm-map") + .forEach( + (i) => + (i.onchange = () => (s.map[i.getAttribute("data-c")] = i.value.trim().toUpperCase())), + ); + document + .querySelectorAll(".cm-sel") + .forEach((i) => (i.onchange = () => (s.sel[i.getAttribute("data-c")] = i.checked))); + const on = (id, fn) => $(id) && ($(id).onclick = fn); + const pick = (onlySel) => + Object.fromEntries(Object.entries(s.map).filter(([c, v]) => v && (!onlySel || s.sel[c]))); + const run = (onlySel) => { + const m = pick(onlySel); + const res = mode === "replace" ? UC.replaceCodes(env(ctx), m) : UC.changeCodes(env(ctx), m); + if (!res.ok) return fail(res); + s.map = {}; + s.sel = {}; + done( + ctx, + api, + `${mode === "replace" ? "항목교체" : "코드변경"} ${res.count}건 · 내역 ${res.refs.boq} · 구성 ${res.refs.comp} · 식 ${res.refs.memo}곳`, + ); + }; + on("cm-run-sel", () => run(true)); + on("cm-run-all", () => run(false)); + on("cm-clear", () => { + const k = s.kind; + Object.keys(s.map).forEach((c) => c[0] === k && delete s.map[c]); + Object.keys(s.sel).forEach((c) => c[0] === k && delete s.sel[c]); + re(); + }); + on("cm-auto", () => { + const list = (ctx.dbfData.costn_rows || []).map(UC.codeOf).filter((c) => c[0] === s.kind); + const from = list.find((c) => s.sel[c]) || list[0]; + const start = prompt(`${from} 부터 차례로 넣을 첫 바뀔코드`, from); + if (!start || !UC.CODE_RE.test(start.trim().toUpperCase())) return; + let n = UC.codeNum(start.trim().toUpperCase()); + list.slice(list.indexOf(from)).forEach((c) => (s.map[c] = UC.mkCode(s.kind, n++))); + re(); + }); + on("cm-plus", () => openPlus(ctx, api)); + on("cm-same", () => { + const m = UC.autoReplaceMap(ctx.dbfData.costn_rows, s.kind); + if (mode === "delete") Object.keys(m).forEach((c) => (s.sel[c] = true)); + else Object.assign(s.map, m); + if (!Object.keys(m).length) alert("동일항목 없음"); + re(); + }); + on( + "cm-1000", + () => (UC.unusedBUnit(env(ctx)).forEach((c) => (s.sel[c] = true)), (s.kind = "B"), re()), + ); + const del = (codes, what) => { + if (!codes.length) return alert(`${what} 없음`); + if ( + !confirm( + `${what} ${codes.length}건을 지움\n${codes.slice(0, 20).join(", ")}${codes.length > 20 ? " …" : ""}`, + ) + ) + return; + const res = UC.deleteCodes(env(ctx), codes); + if (!res.ok) return fail(res); + s.sel = {}; + done( + ctx, + api, + `코드삭제 ${res.count}건${res.kept.length ? ` · 쓰이는 코드 ${res.kept.length}건은 남김` : ""}`, + ); + }; + on("cm-del", () => + del( + Object.keys(s.sel).filter((c) => s.sel[c]), + "선택코드", + ), + ); + on("cm-del-x", () => del(UC.unusedCodes(env(ctx), false), "안 쓰이는 코드(중기비제외)")); + on("cm-del-t", () => del(UC.unusedCodes(env(ctx), true), "안 쓰이는 코드(중기비포함)")); + } + + // 코드번호 변경(현재코드 + α) + function openPlus(ctx, api) { + if (!need(ctx)) return; + const last = UC.lastCodes(ctx.dbfData.costn_rows); + const kinds = ["L", "M", "S", "B", "X", "D", "W"]; + api.openModal( + "코드번호 변경 ( 현재코드 + α ) [uM_Chg_Plus]", + `
${kinds + .map( + (k) => + `
`, + ) + .join( + "", + )}
`, + ); + $("cp-no").onclick = api.closeModal; + $("cp-ok").onclick = () => { + const alpha = Object.fromEntries(kinds.map((k) => [k, Number($(`cp-${k}`).value) || 0])); + const res = UC.changeCodes(env(ctx), UC.plusMap(ctx.dbfData.costn_rows, alpha)); + if (!res.ok) return fail(res); + done(ctx, api, `코드번호 + α ${res.count}건`); + }; + } + + // ── 단가목록표 한 코드 동작(신규등록 · 현재코드 삭제 · 코드번호 변경 · 항목교체) ── + function copyNew(ctx, api, code) { + if (!need(ctx)) return; + const kind = code + ? code[0] + : (prompt("새 코드 종류 글자(L M S B X D W)", "M") || "").trim().toUpperCase(); + if (!kind) return; + const def = UC.nextCode(ctx.dbfData.costn_rows, kind); + const nc = (prompt(code ? `${code} 를 복사한 새 코드` : "새 코드", def) || "") + .trim() + .toUpperCase(); + if (!nc) return; + const fields = code + ? {} + : { + NAME: prompt("명칭", "") || "", + SIZE: prompt("규격", "") || "", + UNIT: prompt("단위", "") || "", + }; + const res = UC.addCode(env(ctx), { from: code || null, kind, code: nc, fields }); + if (!res.ok) return fail(res); + const s = ctx.uiState.dan || (ctx.uiState.dan = {}); + Object.assign(s, { code: res.code, kind: "ALL", kw: res.code }); + ctx.uiState.danMsg = `${res.code} 신규등록${code ? ` (${code} 복사)` : ""}`; + api.cascadeAndRender(); + } + function delOne(ctx, api, code) { + if (!code || !confirm(`${code} 삭제?`)) return; + const res = UC.deleteCodes(env(ctx), [code]); + if (!res.ok) return fail(res); + ctx.uiState.dan.code = ""; + ctx.uiState.danMsg = `${code} 삭제`; + api.cascadeAndRender(); + } + function chgOne(ctx, api, code, replace) { + if (!code) return; + const to = ( + prompt(replace ? `${code} 를 쓰는 자리를 바꿀 대상코드` : `${code} → 바뀔코드`, "") || "" + ) + .trim() + .toUpperCase(); + if (!to) return; + const res = replace + ? UC.replaceCodes(env(ctx), { [code]: to }) + : UC.changeCodes(env(ctx), { [code]: to }); + if (!res.ok) return fail(res); + ctx.uiState.dan.code = replace ? code : to; + ctx.uiState.danMsg = `${code} → ${to} ${replace ? "항목교체" : "코드변경"} · 내역 ${res.refs.boq} · 구성 ${res.refs.comp} · 식 ${res.refs.memo}곳`; + api.cascadeAndRender(); + } + + Object.assign(P.actions, { + Act_Chg_Code_Sel: (ctx, api) => openMgr(ctx, api, "change"), + Act_Chg_Code_All: (ctx, api) => openMgr(ctx, api, "change"), + Act_Chg_Plus: (ctx, api) => openPlus(ctx, api), + Act_Del_Code_Sel: (ctx, api) => openMgr(ctx, api, "delete"), + Act_Del_Code_All_Minus: (ctx, api) => ( + openMgr(ctx, api, "delete"), + $("cm-del-x") && $("cm-del-x").click() + ), + Act_Del_Code_All_Plus: (ctx, api) => ( + openMgr(ctx, api, "delete"), + $("cm-del-t") && $("cm-del-t").click() + ), + Act_Chg_Item: (ctx, api) => openMgr(ctx, api, "replace"), + }); + global.STMATE_UI_CODE_SCR = { openMgr, openPlus, copyNew, delOne, chgOne, env }; +})(window); diff --git a/tools/stmate-test/scr_ui_dan.js b/tools/stmate-test/scr_ui_dan.js index ca39c4bea..e21038abf 100644 --- a/tools/stmate-test/scr_ui_dan.js +++ b/tools/stmate-test/scr_ui_dan.js @@ -47,13 +47,20 @@ const ini = ctx.dbfData.start_ini || {}; const slots = U.slotNames(ini); const sort = s.sort || prefs().dflIndex || "C"; - const list = U.filterRows(rows, { + let list = U.filterRows(rows, { kind: s.kind, kw: s.kw, mode: s.mode, sort, memos: ctx.dbfData.costn_memos || {}, }); + const UA = global.STMATE_UI_CODE_AMT; + if (UA && s.pp) list = list.filter((r) => UA.ppMatch(r, s.pp)); // 속성 추려보기(rg_FLT_*_PP) + if (s.nm) list = list.filter((r) => String(r.NAME || "").trim() === s.nm); // 명칭 추려보기 + if (UA && s.called) { + const called = UA.calledCodes(ctx.dbfData); // 불러간 목록(wM_Box_H) + list = list.filter((r) => called.has(U.codeOf(r))); + } const cnt = U.counts(rows); const n2 = prefs().useName2; const nm = (r, k) => (n2 && String(r[`${k}2`] || "").trim() ? r[`${k}2`] : r[k]) || ""; @@ -114,6 +121,7 @@ ${esc(ctx.uiState.danMsg || "")} + ${toolBar(ctx, s)}
${slots.list.map((x) => ``).join("")} @@ -121,6 +129,20 @@ `; } + // 코드 관리·단가 손질 단추줄 — scr_ui_code.js · scr_ui_dan2.js · 추려보기(속성·명칭·불러간 목록) + function toolBar(ctx, s) { + const UA = global.STMATE_UI_CODE_AMT; + if (!UA || !global.STMATE_UI_CODE_SCR) return ""; + const pp = s.kind === "B" ? UA.PP_B : s.kind === "M" ? UA.PP_M : []; + 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)")} + ${pp.length ? `` : ""} + +
`; + } + function boxa(ctx, api) { const slots = U.slotNames(ctx.dbfData.start_ini); const s = st(ctx); @@ -200,6 +222,24 @@ $("dan-boxa").onclick = () => boxa(ctx, api); $("dan-edthal").onclick = () => edtHal(ctx, api); $("dan-use").onclick = () => findUse(ctx, api); + const C = global.STMATE_UI_CODE_SCR, + T2 = global.STMATE_UI_DAN2; + const on = (id, fn) => $(id) && ($(id).onclick = fn); + if (C) { + on("dan-new", () => C.copyNew(ctx, api, s.code)); + on("dan-del", () => C.delOne(ctx, api, s.code)); + on("dan-chg", () => C.chgOne(ctx, api, s.code, false)); + on("dan-rp", () => C.chgOne(ctx, api, s.code, true)); + } + if (T2) { + on("dan-jd", () => T2.openJD(ctx, api)); + on("dan-hal", () => T2.openHal(ctx, api)); + on("dan-chgamt", () => T2.openChgAmt(ctx, api)); + on("dan-xr", () => T2.openXRate(ctx, api, s.code)); + } + 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()); 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 new file mode 100644 index 000000000..f731fbf80 --- /dev/null +++ b/tools/stmate-test/scr_ui_dan2.js @@ -0,0 +1,374 @@ +/** + * STMATE w 8.0 — 단가 손질 도구 창 끼워 넣기 (scr_ui_dan2.js) + * 단가 일괄변경·물가변동(uM_Chg_Amt · Act_1st_2nd/2nd_1st) · 적용단가 일괄할증(Edt_Hal_Rate/Amt) · 프로젝트 계산 초기값(wM_Set_Init) + * · 호환규정코드 찾기/입력(wM_JDCode) · 시간당 손료계수·수량계산기(wM_Edit_XRate · F6) · 사용자문자(wM_Edit_USER) + * · 실적단가 → 일식단가(wM_Conv_A2W) · 특수문자(mMinMsg_) · 메모(wInDlgMemo) · 동일코드 추려보기(wSameCode) + * 계산 = ui_code_amt.js · calc_xrate.js(STMATE_XRATE) · calc_a2w.js(STMATE_A2W) · 코드 등록 = ui_code.js + */ +(function (global) { + "use strict"; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const UA = global.STMATE_UI_CODE_AMT; + const UC = global.STMATE_UI_CODE; + const esc = (s) => + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const $ = (id) => document.getElementById(id); + const val = (id) => ($(id) ? $(id).value : ""); + const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0; + const opt = (list, cur) => + list + .map( + ([v, t]) => + ``, + ) + .join(""); + const need = (ctx) => (ctx.dbfData ? true : (alert("열린 프로젝트가 없음"), false)); + const selCode = (ctx) => (ctx.uiState.dan || {}).code || ""; + const KN = (k) => UC.KIND_NAME[k] || k; + const DOT = [ + [-1, "10원 단위"], + [0, "1원 단위(소수 0)"], + [1, "소수 1자리"], + [2, "소수 2자리"], + [4, "소수 4자리"], + ]; + + // 계산 원천 맞춤 — 1차 단가 바뀐 코드(기초단가 = 자원 · 그 밖 = 비목 합성 자원) + function syncState(ctx, codes) { + const rows = new Map((ctx.dbfData.costn_rows || []).map((r) => [UC.codeOf(r), r])); + codes.forEach((c) => { + const r = rows.get(c); + if (!r) return; + if (/^[MLS]/.test(c)) { + const res = (ctx.state.resources || []).find((x) => x.code === c && !x.isSynthetic); + if (res) Object.assign(res, { adoptMode: "manual", manualPrice: num(r.TAMT1) }); + } else + global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice( + ctx, + c, + num(r.MAMT1), + num(r.LAMT1), + num(r.SAMT1), + ); + }); + } + const finish = (ctx, api, msg) => ( + (ctx.uiState.danMsg = msg), + api.closeModal(), + api.cascadeAndRender() + ); + + // ── 단가 일괄변경·물가변동 ── + const SLOT = [ + [1, "1 (당초·적용)"], + [2, "2 (변경)"], + [3, "3"], + [4, "4"], + ]; + function openChgAmt(ctx, api) { + if (!need(ctx)) return; + const c = selCode(ctx); + api.openModal( + "단가 일괄변경 · 물가변동 [uM_Chg_Amt]", + `
${["L", "M", "S", "W", "B", "D", "X"].map((k) => ``).join("")}
+
단가 × % → 단가
+
${c ? `` : ""}
+

비목(재·노·경) 칸마다 따로 곱함 · 낙찰률 행은 2차 칸(100% 단가 자리)을 건드리지 않음 · 대가·산근·중기 1차는 [계산 완료] 때 다시 셈

+
`, + ); + $("ca-ok").onclick = () => { + const kinds = [...document.querySelectorAll(".ca-k:checked")].map((i) => i.value).join(""); + const ch = UA.chgAmt(ctx.dbfData.costn_rows, { + kinds, + from: Number(val("ca-from")), + to: Number(val("ca-to")), + rate: num(val("ca-rate")), + digits: Number(val("ca-dot")), + mode: val("ca-up"), + skipZero: $("ca-z").checked, + codes: $("ca-one") && $("ca-one").checked ? [c] : null, + }); + if (Number(val("ca-to")) === 1) + syncState( + ctx, + ch.map((x) => x.code), + ); + finish( + ctx, + api, + `단가 일괄변경 ${ch.length}건 (${val("ca-from")}차 × ${val("ca-rate")}% → ${val("ca-to")}차)`, + ); + }; + } + const copySlot = (from, to) => (ctx, api) => { + if ( + !need(ctx) || + !confirm(`${from === 1 ? "당초단가 ⇒ 변경단가" : "변경단가 ⇒ 당초단가"} 로 모든 단가를 옮김`) + ) + return; + const ch = UA.chgAmt(ctx.dbfData.costn_rows, { + kinds: "LMSWBDX", + from, + to, + rate: 100, + digits: 4, + skipZero: false, + }); + if (to === 1) + syncState( + ctx, + ch.map((x) => x.code), + ); + ctx.uiState.danMsg = `${from}차 → ${to}차 단가 ${ch.length}건`; + api.cascadeAndRender(); + }; + + // ── 적용단가 일괄할증 ── + function openHal(ctx, api) { + if (!need(ctx)) return; + const c = selCode(ctx); + api.openModal( + "적용단가 일괄할증 [Edt_Hal_Rate · Edt_Hal_Amt]", + `
+
+
+

적용단가 = 기준단가(AMT_61, 없으면 지금 적용단가) × (1 + 율) 또는 + 금액 · 0 이면 기준단가로 되돌림

+
`, + ); + $("hl-ok").onclick = () => { + const k = val("hl-k"); + const ch = UA.halAll(ctx.dbfData.costn_rows, { + kinds: k === "one" ? c[0] : k, + codes: k === "one" ? [c] : null, + how: val("hl-how"), + value: num(val("hl-v")), + digits: Number(val("hl-dot")), + mode: val("hl-up"), + }); + syncState( + ctx, + ch.map((x) => x.code), + ); + finish(ctx, api, `일괄할증 ${ch.length}건`); + }; + } + + // ── 프로젝트 계산 초기값(분류번호·적용기준일·시설코드·수공분류코드·호환규정코드 초기화) ── + const INIT_G = [ + ["BQ", "분류번호"], + ["ED", "적용기준일"], + ["EQ", "시설코드"], + ["WQ", "수공분류코드"], + ["JD", "호환규정코드"], + ]; + function openSetInit(ctx, api) { + if (!need(ctx)) return; + const box = (g) => + `
${g[1]} 초기화${UA.INIT_KINDS.map((k) => ``).join("")}
`; + api.openModal( + "프로젝트 분류번호 / 시설코드 / 호환규정코드 / 수공분류코드 / 적용기준일 초기화 [wM_Set_Init]", + `
${INIT_G.map(box).join("")}
`, + ); + document.querySelectorAll(".si-all").forEach( + (b) => + (b.onclick = () => { + const cs = [...document.querySelectorAll(`.si-c[data-g="${b.getAttribute("data-g")}"]`)]; + const on = !cs.every((x) => x.checked); + cs.forEach((x) => (x.checked = on)); + }), + ); + $("si-ok").onclick = () => { + const pick = {}; + document + .querySelectorAll(".si-c:checked") + .forEach( + (x) => + (pick[x.getAttribute("data-g")] = (pick[x.getAttribute("data-g")] || "") + x.value), + ); + if (!Object.keys(pick).length || !confirm("선택한 칸을 비움")) return; + const n = UA.initCols(ctx.dbfData.costn_rows, pick); + ctx.dbfData.ui_dirty = true; + finish( + ctx, + api, + `초기화 — ${ + Object.entries(n) + .map(([k, v]) => `${k} ${v}건`) + .join(" · ") || "비울 칸 없음" + }`, + ); + }; + } + + // ── 호환규정코드 찾기 / 입력 ── + function openJD(ctx, api) { + if (!need(ctx)) return; + const c = selCode(ctx); + const r = (ctx.dbfData.costn_rows || []).find((x) => UC.codeOf(x) === c); + const dup = UA.jdDup(ctx.dbfData.costn_rows); + api.openModal( + "공사원가 호환규정코드 찾기 / 입력 [wM_JDCode]", + `
${r ? `

${c} ${esc(r.NAME)} ${esc(r.SIZE)} ${esc(r.UNIT)} — 지금 호환규정코드 ${esc(r.JDCODE || "(없음)")}

` : '

단가목록표에서 코드를 먼저 고르세요(중복체크만 가능)

'} +
+
+
+

조달청 호환규정코드 목록(JDCODE_*.BSC)은 암호 자료라 쓰지 않음 — 등록된 프로젝트의 같은 항목 코드를 후보로 보임

+ 호환규정코드 중복체크 ${dup.length}건
No코드명칭규격단위재료비노무비경비${esc(slots.applied)}${esc(x.name)}채택분류번호시설코드
${dup.map((x) => ``).join("")}
${esc(x.jd)}${x.codes.join(", ")}
`, + ); + const set = (v) => { + r.JDCODE = v; + ctx.dbfData.ui_dirty = true; + finish(ctx, api, `${c} 호환규정코드 ${v || "삭제"}`); + }; + if (!r) return; + $("jd-set").onclick = () => set(val("jd-in").trim()); + $("jd-del").onclick = () => set(""); + $("jd-go").onclick = async () => { + const kind = /^[LMS]/.test(c) ? "LMS" : c[0] === "W" ? "W" : "XBD"; + const res = await api.postJson("/api/ui/search", { + kind, + kw: String(r.NAME || "").trim(), + limit: 2000, + }); + const pool = res.rows.concat( + (ctx.dbfData.costn_rows || []).map((x) => Object.assign({ project: "(지금 프로젝트)" }, x)), + ); + const cand = UA.jdCandidates(r, pool, val("jd-base")); + $("jd-res").innerHTML = cand.length + ? `${cand.map((x) => ``).join("")}
호환규정코드건수단위프로젝트
${esc(x.jd)}${x.n}${esc(x.unit)}${esc(x.src.slice(0, 3).join(" · "))}
` + : '

같은 기준의 후보 없음

'; + document + .querySelectorAll(".jd-pick") + .forEach((b) => (b.onclick = () => ($("jd-in").value = b.getAttribute("data-jd")))); + }; + } + + // ── 시간당 손료계수 수정 · 노무/장비 수량계산기(F6) ── + function openXRate(ctx, api, code) { + if (!need(ctx)) return; + const c = code || selCode(ctx) || ctx.uiState.selectedIlwiCode || ""; + const by = Object.fromEntries((ctx.dbfData.costn_rows || []).map((r) => [UC.codeOf(r), r])); + const rows = (ctx.dbfData.bdqty_rows || []).filter( + (q) => + String(q.BDCODE || "").trim() === c && + by[String(q.CODE || "").trim()] && + String(by[String(q.CODE).trim()].UNIT || "").trim() === "천원", + ); + const q0 = rows[0] ? num(rows[0].QTY1) : 0; + const X = global.STMATE_XRATE; + api.openModal( + "시간당 손료계수 수정 [wM_Edit_XRate]", + `
${rows.length ? `

${esc(c)} ${esc((by[c] || {}).NAME)} — 기계가격 줄 ${esc(rows[0].CODE)} 수량 ${q0} (= 계 ${UA.lossSum(q0, 100)} × 10-7 × 1,000)

` : `

${esc(c || "(코드 없음)")} — 천원 단위 기계가격 구성 줄이 없음(중기 X 코드를 고르세요) · 아래 수량계산기만 씀

`} +
+
+
%
+
수량계산기 +
+
`, + ); + const sumNow = () => + $("xr-way").value === "1" + ? num(val("xr-a")) + num(val("xr-b")) + num(val("xr-c")) + : num(val("xr-sum")); + ["xr-a", "xr-b", "xr-c"].forEach( + (id) => ($(id).oninput = () => $("xr-way").value === "1" && ($("xr-sum").value = sumNow())), + ); + $("xr-ok").onclick = () => { + const q = UA.lossQty(sumNow(), num(val("xr-rate"))); + rows.forEach((r) => (r.QTY1 = String(q))); + finish(ctx, api, `${c} 손료계수 계 ${sumNow()} × 적용율 ${val("xr-rate")}% → 수량 ${q}`); + }; + $("xq-go").onclick = () => { + if (!X) return ($("xq-out").textContent = "수량계산기(calc_xrate.js) 없음"); + const o = X.compute({ + qty1: val("xq-q1"), + cond: val("xq-cond"), + hrat: val("xq-h"), + lqty: val("xq-l"), + labor: val("xq-lab") === "1", + point: val("xq-p"), + auto: $("xq-auto").checked, + }); + $("xq-out").textContent = o.ok + ? `일당 시공량 ${o.qday} · 적용 수량 ${o.cqty} (자리 ${o.point})` + : o.error; + }; + } + + P.screens = P.screens || {}; + Object.assign(P.actions, { + Act_1st_2nd: copySlot(1, 2), + Act_2nd_1st: copySlot(2, 1), + Act_UI_ChgAmt: (ctx, api) => openChgAmt(ctx, api), + Act_UI_SetInit: (ctx, api) => openSetInit(ctx, api), + Act_UI_XRate: (ctx, api) => openXRate(ctx, api), + }); + P.menus.push( + { menu: "m1", act: "Act_UI_SetInit", label: "프로젝트 분류번호/시설코드/호환규정코드 초기화" }, + { menu: "m4", act: "Act_UI_ChgAmt", label: "단가 일괄변경 · 물가변동" }, + { menu: "m2", act: "Act_UI_XRate", label: "시간당 손료계수 수정 · 수량계산기 (F6)" }, + ); + // F6 — 대가표·단가목록표 화면에서 손료계수 창 + document.addEventListener("keydown", (e) => { + const A = global.STMATE_APP; + if (e.key !== "F6" || !A || !/^(wM_Edit_iLWi|wM_JabPum)$/.test(A.ctx.uiState.activeScreen)) + return; + e.preventDefault(); + openXRate(A.ctx, A); + }); + global.STMATE_UI_DAN2 = { openChgAmt, openHal, openSetInit, openJD, openXRate, syncState }; +})(window); diff --git a/tools/stmate-test/scr_ui_merge.js b/tools/stmate-test/scr_ui_merge.js new file mode 100644 index 000000000..795af936c --- /dev/null +++ b/tools/stmate-test/scr_ui_merge.js @@ -0,0 +1,394 @@ +/** + * STMATE w 8.0 — 다른 프로젝트 자료 창 끼워 넣기 (scr_ui_merge.js) + * 프로젝트 DATA검색(wSrch_PL) · 설계예산시스템 코드병합(wM_Stmate_Merge_Code) · 데이타병합(uM_Merge) · 잡비 금액 불러오기(wM_GetAMT) + * 근거: DFM 원문 — wSrch_PL(wcBx_Type 4택 · 검색결과 내 재검색 · 열: 프로젝트명·코드번호·명칭·규격·단위·분류번호·시설코드·계·노·재·경· + * 호환규정코드·적용기준일 · 프로젝트 찾아가기) · wM_Stmate_Merge_Code(rg_Set_Sys 4택 · 코드전송시 전송여부 묻기) + * · uM_Merge(병합기준선택 · 단가목록표 · 병합코드 확인/검토 · 최종확인 신규코드번호부여 · 내역서(Ctrl+N)) + * · wM_GetAMT(공종 추려보기 6택 · 필터 · 복수선택 합산 · 금액전송) + * 계산 = ui_merge.js · 검색 = /api/ui/search(api_ui_code.py) · 복사원 자료 = /api/load + */ +(function (global) { + "use strict"; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const UM = global.STMATE_UI_MERGE; + const UC = global.STMATE_UI_CODE; + const B = global.STMATE_UI_BOQ; + const esc = (s) => + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const $ = (id) => document.getElementById(id); + const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0; + const env = (ctx) => ({ + d: ctx.dbfData, + state: ctx.state, + memo: ctx.uiState.editedSmtMap || (ctx.uiState.editedSmtMap = {}), + }); + const others = (ctx) => + (ctx.sourcesData.projects || []).filter((p) => p.path !== ctx.currentSourcePath); + const prjOpts = (ctx, cur) => + others(ctx) + .map( + (p) => + ``, + ) + .join(""); + const SRC = {}; // 복사원 자료 — 경로별 한 번 + async function srcData(api, path) { + if (!SRC[path]) SRC[path] = await api.postJson("/api/load", { source_path: path }); + return SRC[path]; + } + + // ── 프로젝트 DATA검색 ── + const TYPES = [ + ["LMS", "노무비, 재료비, 경비"], + ["XBD", "중기비, 일위대가, 단가산출"], + ["W", "일식/표준시장"], + ["BOQ", "내역서"], + ]; + function srchHtml(ctx, api) { + const s = ctx.uiState.srch; + const rows = s.rows || []; + const isBoq = s.kind === "BOQ"; + const head = isBoq + ? "No프로젝트명공종명칭규격수량단위계금액코드" + : "No프로젝트명코드번호명칭규격단위분류번호시설코드계노무비재료비경비호환규정코드적용기준일"; + const go = (i) => + `${isBoq ? "" : ``}`; + const body = rows + .map((r, i) => + isBoq + ? `${i + 1}${esc(r.project)}${esc(r.ITNUM)}${esc(r.NAME1)}${esc(r.SIZE1)}${esc(r.QTY1)}${esc(r.UNIT1)}${api.fmt(num(r.T_AMT1))}${esc(r.BDCODE1)}${go(i)}` + : `${i + 1}${esc(r.project)}${esc(r.CODE)}${esc(r.NAME)}${esc(r.SIZE)}${esc(r.UNIT)}${esc(r.BQCODE)}${esc(r.EQCODE)}${api.fmt(num(r.TAMT1))}${api.fmt(num(r.LAMT1))}${api.fmt(num(r.MAMT1))}${api.fmt(num(r.SAMT1))}${esc(r.JDCODE)}${esc(r.EDT_DATE)}${go(i)}`, + ) + .join(""); + return `
+ + + ${rows.length}건${s.truncated ? " (앞 500건)" : ""}${s.err ? ` · 못 연 프로젝트 ${s.err}` : ""}
+
${head}${body}
`; + } + function openSrch(ctx, api) { + const s = (ctx.uiState.srch = ctx.uiState.srch || { kind: "LMS", kw: "", rows: [], re: false }); + api.openModal("프로젝트 DATA검색(명칭, 규격) [wSrch_PL]", srchHtml(ctx, api)); + $("sp-kind").onchange = (e) => (s.kind = e.target.value); + $("sp-kw").onchange = (e) => (s.kw = e.target.value); + $("sp-re").onchange = (e) => (s.re = e.target.checked); + $("sp-go").onclick = async () => { + s.kw = $("sp-kw").value; + if (s.re && s.rows.length) { + const w = s.kw.toLowerCase().split(/\s+/).filter(Boolean); + const hay = (r) => `${r.NAME || r.NAME1 || ""} ${r.SIZE || r.SIZE1 || ""}`.toLowerCase(); + s.rows = s.rows.filter((r) => w.every((x) => hay(r).includes(x))); + } else { + const res = await api.postJson("/api/ui/search", { kind: s.kind, kw: s.kw }); + Object.assign(s, { + rows: res.rows, + truncated: res.truncated, + err: (res.errors || []).length, + }); + } + openSrch(ctx, api); + }; + document.querySelectorAll(".sp-go").forEach( + (b) => + (b.onclick = () => { + const r = s.rows[Number(b.getAttribute("data-i"))]; + api.closeModal(); + api.loadProjectFromServer(r.path, s.kind === "BOQ" ? "wBoq" : "wM_JabPum").then(() => { + if (s.kind !== "BOQ") + ctx.uiState.dan = Object.assign(ctx.uiState.dan || {}, { + kind: "ALL", + kw: r.CODE, + code: r.CODE, + }); + api.renderWorkspace(); + }); + }), + ); + document.querySelectorAll(".sp-get").forEach( + (b) => + (b.onclick = () => { + const r = s.rows[Number(b.getAttribute("data-i"))]; + openMergeCode(ctx, api, { path: r.path, codes: [r.CODE] }); + }), + ); + } + + // ── 코드병합 · 데이타병합 ── + const KINDS = ["L", "M", "S", "B", "X", "D", "W"]; + const POL_KINDS = ["L", "M", "S", "B", "D", "W"]; + function mgHtml(ctx, api, m) { + const src = m.src; + const list = src + ? src.costn_rows.filter( + (r) => + UC.codeOf(r)[0] === m.kind && + (!m.kw || `${r.CODE} ${r.NAME} ${r.SIZE}`.toLowerCase().includes(m.kw.toLowerCase())), + ) + : []; + const tabs = KINDS.map( + (k) => + ``, + ).join(""); + const pol = m.data + ? `
${POL_KINDS.map( + (k) => + ``, + ).join(" ")}
+
+
` + : ""; + const plan = (m.plan || []) + .map( + (p) => + `${p.src}${{ map: "복사처적용", copy: "복사원적용", new: "신규등록", w: "일식단가(W)" }[p.act]}${p.dest}${esc((src.costn_rows.find((r) => UC.codeOf(r) === p.src) || {}).NAME)}`, + ) + .join(""); + return `
복사처 = 지금 프로젝트
+ ${ + src + ? `
${tabs}
+
${list + .map( + (r) => + ``, + ) + .join("")}
선택코드명칭규격단위계호환규정코드
${UC.codeOf(r)}${esc(r.NAME)}${esc(r.SIZE)}${esc(r.UNIT)}${api.fmt(num(r.TAMT1))}${esc(r.JDCODE || "")}
+
${pol} +
※ 단 복사처에 등록되지 않은코드 : 코드번호 생성(병합)
+ ${plan ? `
${plan}
복사원코드적용방법복사처코드명칭
` : ""}` + : "" + }
`; + } + function openMergeCode(ctx, api, o) { + if (!ctx.dbfData) return alert("열린 프로젝트가 없음"); + const prev = ctx.uiState.mg; + const m = (ctx.uiState.mg = Object.assign( + { kind: "B", sel: {}, mode: 2, conn: 2, policy: {}, data: false, byCode: false }, + prev && !(o && o.reset) ? prev : {}, + o || {}, + )); + if (o && o.codes) o.codes.forEach((c) => ((m.sel[c] = true), (m.kind = c[0]))); + if (o && o.path && (!m.src || m.src.source_path !== o.path)) + return srcData(api, o.path).then( + (d) => ((m.src = d), (m.path = o.path), openMergeCode(ctx, api)), + ); + const title = m.data + ? `데이타 병합 【 복사처 <= 복사원 】 [uM_Merge${m.byCode ? " · 코드번호로 인식" : ""}]` + : "설계예산시스템 코드병합 [wM_Stmate_Merge_Code]"; + api.openModal(title, mgHtml(ctx, api, m)); + const re = () => openMergeCode(ctx, api); + $("mg-open").onclick = () => + openMergeCode(ctx, api, { path: $("mg-src").value, sel: {}, plan: null }); + if (!m.src) return; + document + .querySelectorAll(".mg-kind") + .forEach((b) => (b.onclick = () => ((m.kind = b.getAttribute("data-k")), re()))); + document + .querySelectorAll(".mg-sel") + .forEach( + (i) => + (i.onchange = () => ((m.sel[i.getAttribute("data-c")] = i.checked), (m.plan = null))), + ); + document + .querySelectorAll(".mg-pol") + .forEach( + (s) => + (s.onchange = () => ( + (m.policy[s.getAttribute("data-k")] = Number(s.value)), + (m.plan = null) + )), + ); + $("mg-kw").onchange = (e) => ((m.kw = e.target.value), re()); + $("mg-mode").onchange = (e) => ((m.mode = Number(e.target.value)), (m.plan = null)); + if ($("mg-conn")) + $("mg-conn").onchange = (e) => ( + (m.conn = e.target.value === "code" ? "code" : Number(e.target.value)), + (m.plan = null) + ); + if ($("mg-bdw")) $("mg-bdw").onchange = (e) => ((m.bdW = e.target.checked), (m.plan = null)); + if ($("mg-xw")) $("mg-xw").onchange = (e) => ((m.xW = e.target.checked), (m.plan = null)); + $("mg-check").onclick = () => { + if (m.mode === 3) return ((m.sel = {}), (m.plan = null), re()); // 코드병합 취소 + const codes = Object.keys(m.sel).filter((c) => m.sel[c]); + if (!codes.length) return alert("병합할 코드를 고르세요"); + m.plan = UM.plan(env(ctx), m.src, { + codes, + mode: m.mode, + conn: m.byCode ? "code" : m.conn, + policy: m.data ? m.policy : {}, + bdW: m.data && m.bdW, + xW: m.data && m.xW, + }); + re(); + }; + $("mg-ok").onclick = () => { + if (!m.plan) return; + const res = UM.apply(env(ctx), m.src, m.plan); + const c = res.count; + ctx.uiState.danMsg = `병합 — 신규 ${c.new} · 복사원적용 ${c.copy} · 복사처적용 ${c.map} · W ${c.w}${res.unresolved.length ? ` · 못 이은 하위코드 ${res.unresolved.join(",")}` : ""}`; + Object.assign(m, { plan: null, sel: {} }); + api.closeModal(); + api.cascadeAndRender(); + }; + } + + // ── 잡비 금액 불러오기 ── + const LV = [ + " 모두보기", + " 소 계(+=)", + " 계(+==)", + " 합 계(+===)", + " 총 계(+====)", + " 총합계(+=====)", + ]; + async function openGetAmt(ctx, api) { + if (!ctx.dbfData) return alert("열린 프로젝트가 없음"); + const g = (ctx.uiState.getAmt = ctx.uiState.getAmt || { path: "", lv: 0, flt: "", sel: {} }); + const rows = g.path + ? (await srcData(api, g.path)).boq_rows + : global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx); + const words = g.flt.toLowerCase().split(/\s+/).filter(Boolean); + const list = g.lv + ? B.groupTotals(rows, g.lv).map((x) => ({ + key: `g${x.recno}`, + it: x.itnum, + name: x.name, + size: "", + qty: "", + unit: "", + amt: x.sum.T, + })) + : rows + .filter(B.isLeaf) + .map((r) => ({ + key: `r${r._recno}`, + it: r.ITNUM || "", + name: r.NAME1 || "", + size: r.SIZE1 || "", + qty: r.QTY1, + unit: r.UNIT1 || "", + amt: num(r.T_AMT1), + })); + const shown = list.filter((x) => + words.every((w) => `${x.name} ${x.size}`.toLowerCase().includes(w)), + ); + const sum = list.filter((x) => g.sel[x.key]).reduce((a, x) => a + x.amt, 0); + const keys = Object.keys((ctx.state.kanjubConfig && ctx.state.kanjubConfig.keys) || {}).filter( + (k) => /^C_/.test(k), + ); + api.openModal( + "잡비 금액 불러오기 [wM_GetAMT]", + `
+ +
+
${shown + .map( + (x) => + ``, + ) + .join("")}
공종명칭규격수량단위계금액
${esc(x.it)}${esc(x.name)}${esc(x.size)}${esc(x.qty)}${esc(x.unit)}${api.fmt(x.amt)}
+
선택 합산 ${api.fmt(sum)}원
`, + ); + const re = () => openGetAmt(ctx, api); + $("ga-prj").onchange = (e) => ((g.path = e.target.value), (g.sel = {}), re()); + $("ga-lv").onchange = (e) => ((g.lv = Number(e.target.value)), (g.sel = {}), re()); + $("ga-flt").onchange = (e) => ((g.flt = e.target.value), re()); + document + .querySelectorAll(".ga-sel") + .forEach((i) => (i.onchange = () => ((g.sel[i.getAttribute("data-k")] = i.checked), re()))); + $("ga-ok").onclick = () => { + const k = $("ga-key").value.slice(2); + const cfg = ctx.state.kanjubConfig; + cfg.keys["C_" + k] = Math.trunc(sum); + cfg.keys["D_" + k] = 1; // 고정(직접입력 금액 사용) + ctx.uiState.danMsg = `잡비 ${k} ← ${sum}원(금액전송)`; + api.closeModal(); + api.renderWorkspace(); + }; + } + + // ── 자료파일 업데이트(wM_Mrg_BSC) — 복사원 = 다른 프로젝트 단가목록(원본 기준자료 BSC 는 암호 자료) ── + const MKEY = [ + ["JD", "호환규정코드"], + ["EQ", "시설코드"], + ["NSU", "명칭/규격/단위"], + ["JNSU", "호환+명+규+단"], + ["NU", "명칭/단위"], + ["", "설정안함"], + ]; + async function openMrgBsc(ctx, api) { + if (!ctx.dbfData) return alert("열린 프로젝트가 없음"); + const b = (ctx.uiState.mrgBsc = ctx.uiState.mrgBsc || { + path: "", + keys: ["JD", "NSU", "NU", "", ""], + name: 0, + amt: 1, + jd: 0, + }); + const sel = (id, list, cur) => + ``; + const UPD = [ + [0, "업데이트 안함(복사처적용)"], + [1, "업데이트 (복사원적용)"], + ]; + api.openModal( + "자료파일 업데이트 [wM_Mrg_BSC]", + `
+
${[0, 1, 2, 3, 4].map((i) => `${sel(`mb-k${i}`, MKEY, b.keys[i])}`).join("")}
+
${sel("mb-name", UPD, b.name)}${sel("mb-amt", UPD, b.amt)}${sel( + "mb-jd", + UPD.concat([ + [2, "복사처 우선적용 업데이트"], + [3, "복사원 우선적용 업데이트"], + ]), + b.jd, + )}
+

대상 = 노무·재료·경비·견적(W) · 원본 기준자료(ST_BSC)는 암호 자료라 다른 프로젝트 단가목록을 복사원으로 씀

+
`, + ); + $("mb-ok").onclick = async () => { + Object.assign(b, { + path: $("mb-src").value, + keys: [0, 1, 2, 3, 4].map((i) => $(`mb-k${i}`).value), + name: Number($("mb-name").value), + amt: Number($("mb-amt").value), + jd: Number($("mb-jd").value), + }); + const src = await srcData(api, b.path); + const ch = UM.mrgBsc(ctx.dbfData.costn_rows, src.costn_rows, b); + if (global.STMATE_UI_DAN2) + global.STMATE_UI_DAN2.syncState( + ctx, + ch.filter((x) => x.fields.some((f) => /AMT1$/.test(f))).map((x) => x.code), + ); + if (ch.some((x) => x.fields.includes("JDCODE"))) ctx.dbfData.ui_dirty = true; + ctx.uiState.danMsg = `자료파일 업데이트 ${ch.length}건`; + api.closeModal(); + api.cascadeAndRender(); + }; + } + + Object.assign(P.actions, { + Act_UI_MrgBsc: (ctx, api) => openMrgBsc(ctx, api), + Act_Merge: (ctx, api) => openMergeCode(ctx, api, { data: true, byCode: false, reset: true }), + Act_Merge_C: (ctx, api) => openMergeCode(ctx, api, { data: true, byCode: true, reset: true }), + Act_UI_MergeCode: (ctx, api) => + openMergeCode(ctx, api, { data: false, byCode: false, reset: true }), + Act_UI_SrchPL: (ctx, api) => openSrch(ctx, api), + Act_UI_GetAmt: (ctx, api) => openGetAmt(ctx, api), + }); + P.menus.push( + { menu: "m4", act: "Act_UI_MergeCode", label: "설계예산시스템 코드병합" }, + { menu: "m4", act: "Act_UI_SrchPL", label: "프로젝트 DATA검색(명칭, 규격)" }, + { menu: "m3", act: "Act_UI_GetAmt", label: "잡비 금액 불러오기" }, + { menu: "m2", act: "Act_UI_MrgBsc", label: "단가(명칭,규격, 호환규정코드..) 업데이트" }, + ); + global.STMATE_UI_MERGE_SCR = { openSrch, openMergeCode, openGetAmt, openMrgBsc }; +})(window); diff --git a/tools/stmate-test/scr_ui_prj.js b/tools/stmate-test/scr_ui_prj.js index 5d94e8b8a..0858dbdb2 100644 --- a/tools/stmate-test/scr_ui_prj.js +++ b/tools/stmate-test/scr_ui_prj.js @@ -163,16 +163,34 @@ api.renderWorkspace(); }; - function needActive(ctx) { - const r = selRow(ctx); - if (r && String(r.PCODE).trim() !== activeCode(ctx)) { - alert( - `BOQ${String(r.PCODE).trim()}.DBF 내역서 열기는 서버 불러오기 대기 — 지금 열린 내역서는 BOQ${activeCode(ctx)}`, - ); + // 다른 내역서 열기 — 고른 공사 행의 BOQ{PCODE}.DBF 로 바꿔 불러옴(/api/ui/load-boq · api_ui_code.py) + async function openBoq(ctx, api, pcode) { + const d = ctx.dbfData; + const dirty = + d && (d.ui_dirty || (d.boq_deleted || []).length || (d.boq_rows || []).some((r) => r._new)); + if (dirty && !confirm("지금 내역서에 저장 안 한 손질이 있음 — 버리고 다른 내역서를 열까요?")) + return false; + try { + const data = await api.postJson("/api/ui/load-boq", { + source_path: ctx.currentSourcePath, + pcode, + }); + ctx.dbfData = data; + ctx.state = data.sim_state; + Object.assign(ctx.uiState, { editedSmtMap: {}, boqSel: [], sysDraft: null, prjSel: pcode }); + return true; + } catch (err) { + alert("내역서 열기 실패: " + err.message); return false; } - return true; } + // 고른 공사가 열린 내역서가 아니면 먼저 연 뒤 이어서 + const withActive = (fn) => (ctx, api) => { + const r = selRow(ctx); + const pc = r ? String(r.PCODE).trim() : ""; + if (!r || pc === activeCode(ctx)) return fn(ctx, api); + openBoq(ctx, api, pc).then((ok) => ok && fn(ctx, api)); + }; function editMeta(ctx, api) { const r = selRow(ctx); @@ -205,11 +223,11 @@ } const H = { - ActEditBoq: (ctx, api) => needActive(ctx) && go(ctx, api, "wBoq"), + ActEditBoq: withActive((ctx, api) => go(ctx, api, "wBoq")), ActHaeCost: (ctx, api) => go(ctx, api, "wM_JabPum"), Act_ED_PNUM: editMeta, - Act_Ed_Jab: (ctx, api) => needActive(ctx) && go(ctx, api, "wM_KanJub"), - Act_CalBoq: (ctx, api) => needActive(ctx) && api.cascadeAndRender(), + Act_Ed_Jab: withActive((ctx, api) => go(ctx, api, "wM_KanJub")), + Act_CalBoq: withActive((ctx, api) => api.cascadeAndRender()), Act_Calc_Hae: (ctx, api) => api.cascadeAndRender(), Act_Prn: (ctx, api) => go(ctx, api, "PM_Prt"), ActMkCont00: stage("Act_Stg_Contract"), diff --git a/tools/stmate-test/scr_ui_text.js b/tools/stmate-test/scr_ui_text.js new file mode 100644 index 000000000..26f5a43db --- /dev/null +++ b/tools/stmate-test/scr_ui_text.js @@ -0,0 +1,324 @@ +/** + * STMATE w 8.0 — 글자 입력 도우미·내역 보조 창 끼워 넣기 (scr_ui_text.js) + * 특수문자(mMinMsg_ · 14종 · 문자 입력후 종료 · 다중문자 동시입력) · 사용자문자 편집(wM_Edit_USER · 검색명칭/입력내용/적용) + * · 메모 입력(wInDlgMemo · Ok/Cancel/Load/Clear — 내역 행 DES1·DES2) · 동일코드 추려보기(wSameCode «추려보기» · N번 사용중 · 더블클릭 → 내역 위치) + * · 실적단가 → 일식단가 변환등록(wM_Conv_A2W · 항목선택 → 비목율 → 변환코드 → 변환/생성) + * 글자 넣는 자리 = 마지막으로 손댄 입력칸·글상자(커서 자리) · 사용자문자 저장 = 브라우저(localStorage, 원본 USR_SYNC.DBF 자리) + */ +(function (global) { + "use strict"; + const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { + screens: {}, + actions: {}, + menus: [], + }); + const UA = global.STMATE_UI_CODE_AMT; + const UC = global.STMATE_UI_CODE; + const esc = (s) => + String(s == null ? "" : s).replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const $ = (id) => document.getElementById(id); + const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0; + + // 마지막 입력 자리 — 창(모달) 안 입력칸은 빼고 작업 화면 칸만 기억 + let target = null; + document.addEventListener("focusin", (e) => { + const el = e.target; + if ( + /^(INPUT|TEXTAREA)$/.test(el.tagName) && + !el.closest("#stmate-modal-window") && + el.type !== "checkbox" + ) + target = el; + }); + function insertText(s) { + const el = target; + if (!el || !document.body.contains(el)) + return (navigator.clipboard && navigator.clipboard.writeText(s), false); + const a = el.selectionStart ?? el.value.length, + b = el.selectionEnd ?? el.value.length; + el.value = el.value.slice(0, a) + s + el.value.slice(b); + el.selectionStart = el.selectionEnd = a + s.length; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + const where = () => + target && document.body.contains(target) + ? `넣을 자리: ${target.id || target.className || target.tagName}` + : "넣을 자리 없음 — 클립보드로 복사"; + + // ── 특수문자 ── + const dec = typeof TextDecoder !== "undefined" ? new TextDecoder("euc-kr") : null; + function openSpChar(ctx, api) { + const s = (ctx.uiState.spc = ctx.uiState.spc || { i: 0, close: true, multi: false, buf: "" }); + const chars = dec ? UA.specialChars(s.i, (b) => dec.decode(b)) : []; + api.openModal( + "특수문자 [mMinMsg_]", + `
+
${chars.map((c) => ``).join("")}
+
+

${esc(where())}

`, + ); + $("sc-kind").onchange = (e) => ((s.i = Number(e.target.value)), openSpChar(ctx, api)); + $("sc-close").onchange = (e) => (s.close = e.target.checked); + $("sc-multi").onchange = (e) => (s.multi = e.target.checked); + const put = (t) => { + insertText(t); + s.buf = ""; + if (s.close) api.closeModal(); + }; + document + .querySelectorAll(".sc-ch") + .forEach( + (b) => + (b.onclick = () => + s.multi + ? (($("sc-buf").value += b.textContent), (s.buf = $("sc-buf").value)) + : put(b.textContent)), + ); + $("sc-put").onclick = () => put($("sc-buf").value); + } + + // ── 사용자문자 편집 ── + const UKEY = "stmate_user_text"; + const loadU = () => { + try { + return JSON.parse(global.localStorage.getItem(UKEY) || "[]"); + } catch (_e) { + return []; + } + }; + const saveU = (list) => { + try { + global.localStorage.setItem(UKEY, JSON.stringify(list)); + } catch (_e) { + // 저장 공간 없음 — 이번 실행 동안만 + } + }; + function openUser(ctx, api) { + const list = loadU(); + const u = (ctx.uiState.usr = ctx.uiState.usr || { kw: "", sel: 0 }); + const shown = UA.userTextFind( + list.map((x, i) => Object.assign({ i }, x)), + u.kw, + ).map((x) => x.i); + const all = u.kw ? shown : list.map((_x, i) => i); + api.openModal( + "사용자문자 편집 [wM_Edit_USER]", + `
+
+
${all + .map( + (i) => + ``, + ) + .join("")}
검 색 명 칭입 력 내 용적용

${esc(where())}

`, + ); + const re = () => openUser(ctx, api); + const save = () => (saveU(list), re()); + $("us-kw").onchange = (e) => ((u.kw = e.target.value), re()); + document + .querySelectorAll(".us-r") + .forEach((r) => (r.onchange = () => (u.sel = Number(r.getAttribute("data-i"))))); + document + .querySelectorAll(".us-k") + .forEach( + (r) => + (r.onchange = () => ( + (list[Number(r.getAttribute("data-i"))].key = r.value), + saveU(list) + )), + ); + document + .querySelectorAll(".us-t") + .forEach( + (r) => + (r.onchange = () => ( + (list[Number(r.getAttribute("data-i"))].text = r.value), + saveU(list) + )), + ); + document + .querySelectorAll(".us-on") + .forEach( + (r) => + (r.onchange = () => ( + (list[Number(r.getAttribute("data-i"))].on = r.checked), + saveU(list) + )), + ); + document + .querySelectorAll(".us-put") + .forEach( + (b) => + (b.onclick = () => ( + insertText(list[Number(b.getAttribute("data-i"))].text || ""), + api.closeModal() + )), + ); + $("us-add").onclick = () => ( + list.splice(u.sel + 1, 0, { key: "", text: "", on: true }), + (u.sel += 1), + save() + ); + $("us-del").onclick = () => list[u.sel] && (list.splice(u.sel, 1), save()); + const mv = (d) => { + const j = u.sel + d; + if (!list[u.sel] || j < 0 || j >= list.length) return; + [list[u.sel], list[j]] = [list[j], list[u.sel]]; + u.sel = j; + save(); + }; + $("us-up").onclick = () => mv(-1); + $("us-dn").onclick = () => mv(1); + $("us-init").onclick = () => confirm("사용자문자 목록을 비움") && (list.splice(0), save()); + $("us-sp").onclick = () => openSpChar(ctx, api); + } + + // ── 내역 메모(DES1·DES2) ── + const SC = () => global.STMATE_UI_BOQ_SCREEN; + const selRow = (ctx) => + (ctx.dbfData.boq_rows || []).find((r) => Number(r._recno) === (ctx.uiState.boqSel || [])[0]); + function openMemo(ctx, api) { + const r = selRow(ctx); + if (!r) return alert("메모를 넣을 내역 행을 고르세요"); + api.openModal( + `메모 입력 [wInDlgMemo] — ${esc(r.ITNUM || "")} ${esc(r.NAME1 || r.BDCODE1 || "")}`, + `
+
+

줄 1 = 비고1(DES1) · 줄 2 = 비고2(DES2) — 내역 저장 때 함께 씀

`, + ); + $("mm-no").onclick = api.closeModal; + $("mm-clr").onclick = () => ($("mm-t").value = ""); + $("mm-load").onclick = () => $("mm-file").click(); + $("mm-file").onchange = (e) => { + const f = e.target.files[0]; + if (f) f.text().then((t) => ($("mm-t").value = t)); + }; + $("mm-ok").onclick = () => { + const [a, b] = $("mm-t").value.split(/\r?\n/); + Object.assign(r, { DES1: (a || "").trim(), DES2: (b || "").trim() }); + api.closeModal(); + SC().after(ctx, api, { ok: true }, true); + }; + } + + // ── 동일코드 추려보기 ── + function openSame(ctx, api) { + const r = selRow(ctx); + const c = r ? String(r.BDCODE1 || "").trim() : ""; + if (!c || /^(T\d|E\d|C\d|:)$/.test(c)) return alert("단가코드 행을 고르세요"); + const ev = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx).filter( + (x) => String(x.BDCODE1 || "").trim() === c, + ); + api.openModal( + `추려보기 [wSameCode] — ${c}`, + `

${ev.length}번 사용중 · Enter/더블클릭 → 내역서의 해당위치로 이동

${ev + .map( + (x) => + ``, + ) + .join("")}
공종번호명칭규격단위수량단가금액노무비재료비경비비고
${esc(x.ITNUM)}${esc(x.NAME1)}${esc(x.SIZE1)}${esc(x.UNIT1)}${esc(x.QTY1)}${api.fmt(num(x.TAMT1))}${api.fmt(num(x.T_AMT1))}${api.fmt(num(x.L_AMT1))}${api.fmt(num(x.M_AMT1))}${api.fmt(num(x.S_AMT1))}${esc(x.BDCODE1)}
`, + ); + const go = (tr) => { + const rec = Number(tr.getAttribute("data-r")); + Object.assign(ctx.uiState, { boqSel: [rec], boqLevelMin: 0, boqScrollTo: rec }); + api.closeModal(); + api.renderWorkspace(); + }; + document + .querySelectorAll(".sm-go") + .forEach( + (tr) => ( + (tr.ondblclick = () => go(tr)), + (tr.onkeydown = (e) => e.key === "Enter" && go(tr)) + ), + ); + } + + // ── 실적단가 → 일식단가 ── + function openA2W(ctx, api) { + if (!ctx.dbfData) return alert("열린 프로젝트가 없음"); + const src = ctx.dbfData.costa_rows || []; + const A = global.STMATE_A2W; + const w = (ctx.uiState.a2w = ctx.uiState.a2w || { + sel: {}, + L: 100, + M: 0, + own: false, + start: UC.nextCode(ctx.dbfData.costn_rows, "W"), + }); + if (!src.length) + return api.openModal( + "실적단가를 일식단가로 변환등록 [wM_Conv_A2W]", + '

이 프로젝트에 실적단가(COSTA.DBF) 자료가 없음

', + ); + const ed = A ? A.normalizeEditor(w.L, w.M, "L") : { L: w.L, M: w.M, E: 100 - w.L - w.M }; + api.openModal( + "실적단가를 일식단가로 변환등록 [wM_Conv_A2W]", + `
${src + .map( + (r, i) => + ``, + ) + .join("")}
선택코드공종명칭규격단위단가노무비율
${esc(r.CODE)}${esc(r.CODEA || "")}${esc(r.NAME)}${esc(r.SIZE)}${esc(r.UNIT)}${api.fmt(num(r.AAMT1))}${esc(r.RATE_L || "")}
+
경 비 율 ${ed.E} % +
+
`, + ); + document + .querySelectorAll(".aw-sel") + .forEach((i) => (i.onchange = () => (w.sel[i.getAttribute("data-i")] = i.checked))); + $("aw-ok").onclick = () => { + Object.assign(w, { + L: num($("aw-l").value), + M: num($("aw-m").value), + own: $("aw-own").checked, + start: $("aw-start").value.trim().toUpperCase(), + }); + const rows = src.map((r, i) => ({ sel: Boolean(w.sel[i]), rateL: num(r.RATE_L) })); + const rates = A + ? A.bulkApply(rows, { L: w.L, M: w.M, sender: "L" }, { individual: w.own }) + : rows; + const pick = src.map((r, i) => Object.assign({}, r, rates[i])).filter((_r, i) => w.sel[i]); + if (!pick.length) return alert("변환할 항목을 고르세요"); + const taken = new Set(ctx.dbfData.costn_rows.map(UC.codeOf)); + const made = UA.a2w(pick, { start: w.start, taken }); + const env = { d: ctx.dbfData, state: ctx.state, memo: ctx.uiState.editedSmtMap }; + made.forEach((m) => + UC.addCode(env, { + kind: "W", + code: m.CODE, + fields: { + NAME: m.NAME, + SIZE: m.SIZE, + UNIT: m.UNIT, + MAMT1: String(m.M), + LAMT1: String(m.L), + SAMT1: String(m.S), + TAMT1: String(m.T), + }, + }), + ); + ctx.uiState.danMsg = `일식단가(W) ${made.length}건 생성 ${made[0].CODE}~`; + w.sel = {}; + api.closeModal(); + api.cascadeAndRender(); + }; + } + + Object.assign(P.actions, { + Act_UI_SpChar: (ctx, api) => openSpChar(ctx, api), + Act_UI_UserText: (ctx, api) => openUser(ctx, api), + Act_UI_A2W: (ctx, api) => openA2W(ctx, api), + }); + P.menus.push( + { menu: "m4", act: "Act_UI_SpChar", label: "특수문자" }, + { menu: "m4", act: "Act_UI_UserText", label: "사용자문자 편집" }, + { menu: "m2", act: "Act_UI_A2W", label: "실적단가(A) ⇒ 일식단가(W) 변환" }, + ); + global.STMATE_UI_TEXT = { insertText, openSpChar, openUser, openMemo, openSame, openA2W }; +})(window); diff --git a/tools/stmate-test/server.py b/tools/stmate-test/server.py index 1aa643bb4..212a0757a 100644 --- a/tools/stmate-test/server.py +++ b/tools/stmate-test/server.py @@ -122,6 +122,42 @@ def _make_syn_resource(res_id: str, name: str, spec: str, unit: str, kind: str, } +def pct_row_base(boq_rows: list[dict], i: int) -> dict: + """코드 붙은 % 줄(조달수수료 M%·T%) 밑수 — 같은 상위 공종 안 그 위 말단 줄들(닫힌 하위 공종 포함). + + 단가 칸 = 밑수 합 · 수량 칸 = 율(%) — 실무 봉화·울진·대흥 저장값 대조. + """ + ids, depth = [], [] + for j in range(i - 1, -1, -1): + bd = (boq_rows[j].get("BDCODE1") or "").strip() + if re.match(r"^E\d$", bd): + depth.append(bd[1]) + elif re.match(r"^T\d$", bd): + if depth and depth[-1] == bd[1]: + depth.pop() + else: + break + elif bd and not STRUCT_RE.match(bd): + ids.append(f"BOQ_{j + 1}") + unit = (boq_rows[i].get("UNIT1") or "").strip() + return {"pctComp": unit[0] if unit[:1] in ("M", "L", "S", "T") else "T", "pctBase": ids[::-1]} + + +def boq_fixed_unit(r: dict, crow: dict | None, is_pct: bool) -> dict: + """내역 줄 단가가 단가목록(COSTN 1칸)과 다르면 줄 고정 단가(fixedUnit) — 단가 끝전 보정 등 줄 단위 수정 보존. + + 0원 줄(변경 차수에서만 쓰는 신규 공종의 당초 칸)은 제외. + """ + if is_pct or not crow: + return {} + unit = {c: fnum(r.get(f"{c}AMT1")) for c in "MLS"} + if not any(unit.values()) or all( + abs(unit[c] - fnum(crow.get(f"{c}AMT1"))) < 1e-6 for c in "MLS" + ): + return {} + return {"fixedUnit": unit} + + def convert_dbf_to_sim_state( costn_rows: list[dict], boq_rows: list[dict], @@ -131,6 +167,7 @@ def convert_dbf_to_sim_state( ) -> dict: """원본 COSTN.DBF(전수) / COSTN.SMT / BOQ11~14.DBF(전수) / PRJLST.SMT를 무손실 변환.""" costn_memos = costn_memos or {} + costn_by_code = {(r.get("CODE") or "").strip(): r for r in costn_rows} resources: list[dict] = [] machines: list[dict] = [] san_items: list[dict] = [] @@ -338,6 +375,8 @@ def convert_dbf_to_sim_state( "PRIN": r.get("PRIN", ""), "PROUT": r.get("PROUT", ""), "prQtys": [fnum(r.get(f"QTY{n}")) for n in range(1, 5)], + **(pct_row_base(boq_rows, i) if is_pct_fee else {}), + **boq_fixed_unit(r, costn_by_code.get(bd), is_pct_fee), } ) diff --git a/tools/stmate-test/stc_save.py b/tools/stmate-test/stc_save.py index 9a9cfc79d..5bae8ec6a 100644 --- a/tools/stmate-test/stc_save.py +++ b/tools/stmate-test/stc_save.py @@ -95,6 +95,68 @@ def boq_spans(tbl: DbfTable) -> tuple[list[tuple[int, int, int, bool]], set[int] return spans, excl +_PR_COMP = {"1": "M", "2": "L", "3": "S", "4": "T"} + + +def _pct_rows(tbl: DbfTable, name: str, s: str, log: SaveLog) -> None: + """% 줄 다시 셈 — 단가 칸 = 밑수 · 금액 = 밑수 × 율(수량 칸, %) 원 미만 절사. + + 코드 붙은 줄(조달수수료 M%·T%) = 같은 상위 공종 안 앞 말단 줄 비목 합(server.pct_row_base) + PR 줄 = 앞 PR 줄 뒤 표시(PRPT) 줄의 밑수 비목(PRIN) 합 → 대상 비목(PROUT) (calc.js prRow) + """ + if not all(f in tbl.fields for f in (f"QTY{s}", f"MAMT{s}", f"M_AMT{s}", "UNIT1")): + return + bds = [tbl.get(i, "BDCODE1") for i in range(len(tbl))] + amt = lambda i, c: ( # noqa: E731 + sum(_dec(tbl.get(i, f"{k}_AMT{s}")) for k in "MLS") + if c == "T" + else _dec(tbl.get(i, f"{c}_AMT{s}")) + ) + marked = {"M": Decimal(0), "L": Decimal(0), "S": Decimal(0)} + for i in range(len(tbl)): + bd, unit = bds[i], tbl.get(i, "UNIT1").strip() + if not _leaf(bd): + continue + if bd == "PR" and "PRIN" in tbl.fields: + bk, ok = ( + _PR_COMP.get(tbl.get(i, "PRIN").strip()), + _PR_COMP.get(tbl.get(i, "PROUT").strip()), + ) + if bk and ok: + base = sum(marked.values()) if bk == "T" else marked[bk] + _set_pct(tbl, name, i, s, "M" if ok == "T" else ok, base, log) + marked = {k: Decimal(0) for k in marked} + continue + elif unit.endswith("%") and unit[:1] in "MLST": + base, depth = Decimal(0), [] + for j in range(i - 1, -1, -1): + b = bds[j] + if re.match(r"^E\d$", b): + depth.append(b[1]) + elif re.match(r"^T\d$", b): + if depth and depth[-1] == b[1]: + depth.pop() + else: + break + elif _leaf(b): + base += amt(j, unit[0]) + own = "L" if bd[:1] == "L" else "S" if bd[:1] == "S" else "M" + _set_pct(tbl, name, i, s, own, base, log) + continue + if "PRPT" in tbl.fields and tbl.get(i, "PRPT").strip() not in ("", "0"): + for k in marked: + marked[k] += _dec(tbl.get(i, f"{k}_AMT{s}")) + + +def _set_pct(tbl: DbfTable, name: str, i: int, s: str, own: str, base: Decimal, log: SaveLog): + q = _dec(tbl.get(i, f"QTY{s}")) + a = (base * q / 100).quantize(Decimal(1), rounding=ROUND_DOWN) + for c in "MLS": + log.set(tbl, name, i, f"{c}AMT{s}", base if c == own else 0) + log.set(tbl, name, i, f"{c}_AMT{s}", a if c == own else 0) + log.set(tbl, name, i, f"T_AMT{s}", a) + + def recompute_boq(tbl: DbfTable, name: str, slots: set[str], log: SaveLog) -> dict[str, list]: """바뀐 차수의 소계 행 다시 셈 · 차수별 직접비(재·노·경·계) 반환.""" spans, excl = boq_spans(tbl) @@ -104,6 +166,7 @@ def recompute_boq(tbl: DbfTable, name: str, slots: set[str], log: SaveLog) -> di cols = [f"{c}_AMT{s}" for c in "MLST"] if not all(c in tbl.fields for c in cols): continue + _pct_rows(tbl, name, s, log) vals = [[_dec(tbl.get(i, c)) for c in cols] for i in range(len(tbl))] for i, a, b, isx in spans: tot = [Decimal(0)] * 4 diff --git a/tools/stmate-test/ui_app_menu.js b/tools/stmate-test/ui_app_menu.js index 973a0f7f6..4de04bcfd 100644 --- a/tools/stmate-test/ui_app_menu.js +++ b/tools/stmate-test/ui_app_menu.js @@ -56,7 +56,7 @@ const base = String(payload.target_folder_name || "stmate").slice(0, 20); payload.target_folder_name = `${base}_백업_${kind === "day" ? today() : nowTag()}`; try { - const res = await API.postJson("/api/save", payload); + const res = await API.postSave(payload); if (kind === "day") prefs.lastBackDay = today(); if (kind === "30") prefs.lastBack30 = Date.now(); savePrefs(); diff --git a/tools/stmate-test/ui_boq_tools.js b/tools/stmate-test/ui_boq_tools.js index 6d06263b5..99a2c1e7e 100644 --- a/tools/stmate-test/ui_boq_tools.js +++ b/tools/stmate-test/ui_boq_tools.js @@ -1,5 +1,5 @@ /** - * STMATE w 8.0 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd · wM_Rate · wBoqRate + * STMATE w 8.0 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd(wM_Expnd·wM_Expnd10) · wM_Rate · wBoqRate * 근거: 지식DB STmate/old/30_원자료/ui_form_catalog.txt * wITNum RdoFirstChar(공란/./(/-/추가입력) · RdoLastChar(공란/./)) · CBoxType(가..하 / a..z) · CBoxLeft(여백 0~7) * Fmt_Typ(0/00/000) · cBx_itNum_B(직전공종 번호 / 길이만큼 여백) · cBx_OnlyIT(대상) · cBx_Bl_Lev(초기화 레벨) @@ -97,18 +97,25 @@ } /** - * 일위대가 풀기(wExpnd) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈 - * 구성 수량 = 구성표 수량 × 내역 수량 → 절삭/반올림 소수 d 위 · 비율(PR)·% 단위·구분(T/C) 줄은 풀지 않고 개수만 알림 + * 일위대가 풀기(wExpnd · wM_Expnd · wM_Expnd10) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈 + * 구성 수량 = 구성표 수량 × 할증(노무·재료) × 내역 수량 → 비목별(M·L·X·기타 W) 절삭/반올림 소수 d 위 + * o = { level(0 = 입력안함 · 감싸지 않음), mode, digits, per: { M|L|X|W: { mode, digits } }, hal: { L, M }, + * keepName(공종의 명칭/규격 입력), adds: [{ pct, prin, prout, name }](손료계산 추가입력 → PR 비율 줄) } + * 비율(PR)·% 단위·구분(T/C) 구성 줄은 풀지 않고 개수만 알림 · 추가 줄의 밑수 = 풀린 줄(PRPT 표시) */ function expandIlwi(model, recno, bdqtyRows, costnByCode, opts, ini) { - const o = Object.assign({ level: 1, mode: "trunc", digits: 4 }, opts || {}); + const o = Object.assign( + { level: 1, mode: "trunc", digits: 4, per: {}, hal: {}, keepName: true, adds: [] }, + opts || {}, + ); const row = model.rows.find((x) => num(x._recno) === num(recno)); if (!row || !isLeaf(row)) return { ok: false, error: "풀 일위대가 행을 고르세요" }; const code = codeOf(row); if (code[0] !== "B") return { ok: false, error: `일위대가(B) 코드만 풀 수 있음 — ${code}` }; const comps = (bdqtyRows || []).filter((r) => String(r.BDCODE || "").trim() === code); if (!comps.length) return { ok: false, error: `${code} 구성표(BDQTY) 없음` }; - const Q = num(row.QTY1); + const Q = num(row.QTY1) || 1; // 실행수량 0 이면 1(wM_Expnd 안내) + const adds = (o.adds || []).filter((a) => num(a.pct) && a.prin && a.prout); const made = []; let skipped = 0; comps.forEach((c) => { @@ -116,36 +123,70 @@ cr = costnByCode[cc]; const unit = cr ? String(cr.UNIT || "").trim() : ""; if (!cr || cc === "PR" || /^[TC]\d$/.test(cc) || /%$/.test(unit)) return void (skipped += 1); + const k = "MLX".includes(cc[0]) ? cc[0] : "W"; + const r = Object.assign({ mode: o.mode, digits: o.digits }, (o.per || {})[k] || {}); + const h = num((o.hal || {})[cc[0]]) || 1; made.push({ code: cc, name: cr.NAME || "", spec: cr.SIZE || "", unit, - qty: roundTo(num(c.QTY1) * Q, o.digits, o.mode), + qty: roundTo(num(c.QTY1) * h * Q, r.digits, r.mode), }); }); if (!made.length) return { ok: false, error: "풀 수 있는 구성 자원 없음" }; const at = model.rows.indexOf(row); - const head = B.makeRow(model, { - BDCODE1: `T${o.level}`, - NAME1: row.NAME1 || "", - SIZE1: row.SIZE1 || "", - }); - head.ITNUM = row.ITNUM || ""; - let rec = num(head._recno); + const wrap = Number(o.level) > 0; + const head = wrap + ? B.makeRow(model, { + BDCODE1: `T${o.level}`, + NAME1: o.keepName ? row.NAME1 || "" : "", + SIZE1: o.keepName ? row.SIZE1 || "" : "", + }) + : null; + if (head) head.ITNUM = row.ITNUM || ""; + let rec = head ? num(head._recno) : B.nextRecno(model) + 1; const body = made.map((m) => { const r = B.makeRow(model, { BDCODE1: m.code, NAME1: m.name, SIZE1: m.spec, UNIT1: m.unit }); Object.assign(r, { QTY1: String(m.qty), _recno: (rec -= 1) }); + if (adds.length) r.PRPT = "1"; return r; }); - const tail = B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) }); - tail._recno = rec - 1; - const res = B.edit(model, (rows, links) => { - links.set(head, tail).set(tail, head); - rows.splice(at, 1, head, ...body, tail); - if (!row._new) model.deleted.push(num(row._recno)); - return { count: body.length, skipped }; + const prs = adds.map((a) => { + const r = B.makeRow(model, { BDCODE1: "PR", NAME1: a.name || "손료", UNIT1: "%" }); + Object.assign(r, { + QTY1: String(num(a.pct)), + PRIN: String(a.prin), + PROUT: String(a.prout), + _recno: (rec -= 1), + }); + return r; }); + const tail = wrap + ? B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) }) + : null; + if (tail) tail._recno = rec - 1; + const res = B.edit(model, (rows, links) => { + if (wrap) links.set(head, tail).set(tail, head); + rows.splice(at, 1, ...(wrap ? [head] : []), ...body, ...prs, ...(wrap ? [tail] : [])); + if (!row._new) model.deleted.push(num(row._recno)); + return { count: body.length + prs.length, skipped }; + }); + // 계산 원천 — 비율 줄·밑수 표시(calc.js prRow · PRPT) + if (res.ok && adds.length) { + const byRec = new Map((model.items || []).map((it) => [num(it._recno), it])); + body.forEach((r) => byRec.get(num(r._recno)) && (byRec.get(num(r._recno)).PRPT = "1")); + prs.forEach((r) => { + const it = byRec.get(num(r._recno)); + if (it) + Object.assign(it, { + PRIN: r.PRIN, + PROUT: r.PROUT, + PRPT: "0", + prQtys: [num(r.QTY1), 0, 0, 0], + }); + }); + } return res; } @@ -164,6 +205,36 @@ return { changed }; } + /** + * 비목별 단가 끝전 보정(wM_Rate wcBx_Pnt1_M·_L … · rd_Amt1/rd_Amt2 범위) — 지금 행 단가(재·노·경)를 비목마다 절사/반올림 + * units = { recno: { M, L, S } }(계산 결과 행 단가) · 계산 원천 항목 fixedUnit · 저장 원천 MAMT1·LAMT1·SAMT1·TAMT1 + */ + function roundUnits(model, recnos, units, per) { + const set = recnos ? new Set(recnos.map(num)) : null; + const byRec = new Map(model.items.map((it) => [num(it._recno), it])); + let changed = 0; + model.rows.forEach((r) => { + const u = (units || {})[num(r._recno)]; + if (!isLeaf(r) || !u || (set && !set.has(num(r._recno)))) return; + const v = {}; + ["M", "L", "S"].forEach((c) => { + const o = (per || {})[c]; + v[c] = o ? roundTo(num(u[c]), o.digits, o.mode) : num(u[c]); + }); + if (["M", "L", "S"].every((c) => v[c] === num(u[c]))) return; + Object.assign(r, { + MAMT1: String(v.M), + LAMT1: String(v.L), + SAMT1: String(v.S), + TAMT1: String(v.M + v.L + v.S), + }); + const it = byRec.get(num(r._recno)); + if (it) it.fixedUnit = v; + changed += 1; + }); + return { changed }; + } + // 할증율 적용(wBoqRate) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별) · 계산 원천 항목에도 halRate 로 둠 function applyHalRate(model, recnos, rates, mode) { const set = recnos ? new Set(recnos.map(num)) : null; @@ -188,6 +259,14 @@ return { changed }; } - global.STMATE_UI_BOQ_TOOLS = { seqText, autoItnum, roundTo, expandIlwi, roundQty, applyHalRate }; + global.STMATE_UI_BOQ_TOOLS = { + seqText, + autoItnum, + roundTo, + expandIlwi, + roundQty, + roundUnits, + applyHalRate, + }; if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_BOQ_TOOLS; })(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/ui_code.js b/tools/stmate-test/ui_code.js new file mode 100644 index 000000000..f0a0cb13d --- /dev/null +++ b/tools/stmate-test/ui_code.js @@ -0,0 +1,494 @@ +/** + * STMATE w 8.0 — 코드 관리 순수 계산 (ui_code.js) · 신규등록 · 삭제 · 코드변경 · +α · 항목교체 · 동일항목 + * 근거: DFM 원문(C:/경영정보(주)/System/ST_CPM) — ST_U_ChgCode(uM_ChgCode 현재코드→바뀔코드 · uM_Chg_Plus 현재최종+α + * · uM_ReplCode 현재코드→대상코드 · 동일항목(명칭, 규격... 단가) 대상코드 자동입력) · ST_U_DelCode(uM_DelCode 선택 · + * 코드일괄삭제(중기비제외/포함) · 사용안된 "천원" "인" 일위대가 · *[USE_] 사용표시) · STmate.Cpm wM_Boxa + * Act_CopyNew(선택된 코드를 복사하여 새로운코드 생성) + * env = { d: dbfData, state: sim_state, memo: uiState.editedSmtMap } — 저장 원천(d)과 계산 원천(state)을 함께 고침 + * 저장 표시: d.ui_dirty · 새 행 _new(_from = 복사원 _recno) · 지운 원본 d.costn_deleted · 다른 표 코드 r._bd0 + * 구성표는 원래 행 그대로 바꿔 둠 — 저장은 기본 묶음의 bdqty_rows(stc_save_extra.save_bdqty) + */ +(function (global) { + "use strict"; + + const CODE_RE = /^[A-Z]\d{5}$/; + const KIND_NAME = { L: "노무비", M: "재료비", S: "경비", B: "일위대가", X: "중기비" }; + Object.assign(KIND_NAME, { D: "단가산출", W: "견적단가", I: "그림코드", A: "실적단가" }); + 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 codeOf = (r) => txt(r && r.CODE); + const BDK = ["BDCODE1", "BDCODE2", "BDCODE3", "BDCODE4"]; + + // 내역 표 행 — 열린 표(boq_rows)와 표 묶음(boq_tables)이 같은 배열이 아닐 때도 한 번씩 + function boqRowSets(d) { + const out = []; + const seen = new Set(); + const add = (table, rows) => { + if (!rows || seen.has(rows)) return; + seen.add(rows); + out.push([table, rows]); + }; + add(d.boq_table_name || "", d.boq_rows); + Object.entries(d.boq_tables || {}).forEach(([t, rows]) => add(t, rows)); + return out; + } + + const memoText = (env, c) => { + const m = env.memo || {}; + if (Object.prototype.hasOwnProperty.call(m, c)) return m[c] || ""; + const x = ((env.d && env.d.costn_memos) || {})[c]; + return (x && x.text) || ""; + }; + const REF_RE = /&([A-Z]\d{5})([LMS]?(?::[LMS])?)&/g; + const memoRefs = (t) => [...String(t || "").matchAll(REF_RE)].map((m) => m[1]); + + /** 사용표시(*) — 내역서(모든 표 · 1~4차 코드) · 구성표(BDQTY) · 산출근거 식 참조 */ + function usedSet(env) { + const d = env.d, + used = new Set(); + boqRowSets(d).forEach(([, rows]) => + rows.forEach((r) => BDK.forEach((k) => txt(r[k]) && used.add(txt(r[k])))), + ); + (d.bdqty_rows || []).forEach((r) => txt(r.CODE) && used.add(txt(r.CODE))); + (d.costn_rows || []).forEach((r) => + memoRefs(memoText(env, codeOf(r))).forEach((c) => used.add(c)), + ); + return used; + } + + // 현재최종 — 종류(첫 글자)별 가장 큰 코드 + function lastCodes(rows) { + const out = {}; + (rows || []).forEach((r) => { + const c = codeOf(r); + if (CODE_RE.test(c) && (!out[c[0]] || c > out[c[0]])) out[c[0]] = c; + }); + return out; + } + const codeNum = (c) => parseInt(String(c).slice(1), 10) || 0; + const mkCode = (k, n) => `${k}${String(n).padStart(5, "0")}`; + function nextCode(rows, kind, taken) { + const last = lastCodes(rows)[kind]; + let n = last ? codeNum(last) + 1 : 1; + while (taken && taken.has(mkCode(kind, n))) n += 1; + return n > 99999 ? "" : mkCode(kind, n); + } + + /** 동일항목 — 명칭+규격+단위+단가(계) 같은 종류 안 묶음 · 첫 코드(작은 번호)가 대표 */ + function sameItems(rows, kinds) { + const g = new Map(); + (rows || []).forEach((r) => { + const c = codeOf(r); + if (!CODE_RE.test(c) || (kinds && !kinds.includes(c[0]))) return; + const k = [c[0], txt(r.NAME), txt(r.SIZE), txt(r.UNIT), num(r.TAMT1)].join("\u0001"); + g.set(k, (g.get(k) || []).concat(c)); + }); + return [...g.values()].filter((a) => a.length > 1).map((a) => a.slice().sort()); + } + + // ── 계산 원천(state) 코드 바꿈 — id·code·refId·machineId · 합성 자원 「코드_M/L/S」 ── + function stateRename(state, map) { + if (!state) return; + const sw = (v) => { + if (typeof v !== "string") return v; + if (map[v]) return map[v]; + const m = v.match(/^([A-Z]\d{5})_([MLS])$/); + return m && map[m[1]] ? `${map[m[1]]}_${m[2]}` : v; + }; + const fix = (o) => + ["id", "code", "refId", "machineId"].forEach((k) => k in o && (o[k] = sw(o[k]))); + ["resources", "machines", "sanItems", "ilwiItems", "boqItems"].forEach((k) => + (state[k] || []).forEach((o) => { + fix(o); + (o.lines || []).forEach(fix); + (o.smtRefs || []).forEach(fix); + }), + ); + } + + // 참조만 바꿈(내역 · 구성표 · 식) — 항목교체·코드변경 공통 + function remapRefs(env, map, opt) { + const d = env.d; + const o = Object.assign({ boq: true, comp: true, memo: true, items: true }, opt || {}); + const n = { boq: 0, comp: 0, memo: 0 }; + if (o.boq) + boqRowSets(d).forEach(([, rows]) => + rows.forEach((r) => + BDK.forEach((k) => { + const c = txt(r[k]); + if (!map[c]) return; + if (!r._bd0) r._bd0 = Object.fromEntries(BDK.map((x) => [x, r[x]])); + r[k] = map[c]; + n.boq += 1; + }), + ), + ); + if (o.comp) + (d.bdqty_rows || []).forEach((r) => { + const c = txt(r.CODE); + if (map[c]) ((r.CODE = map[c]), (n.comp += 1)); + }); + if (o.memo) { + env.memo = env.memo || {}; + (d.costn_rows || []).forEach((r) => { + const c = codeOf(r), + t = memoText(env, c); + if (!t) return; + const t2 = t.replace(REF_RE, (all, code, tail) => + map[code] ? `&${map[code]}${tail}&` : all, + ); + if (t2 !== t) ((env.memo[c] = t2), (n.memo += 1)); + }); + } + (env.state && env.state.boqItems ? env.state.boqItems : []).forEach((it) => { + if (o.boq && o.items && map[it.code]) + Object.assign(it, { code: map[it.code], refId: map[it.code] }); + }); + d.ui_dirty = true; + return n; + } + + /** + * 코드변경 검사 — 바뀔코드 = 같은 종류 글자 + 5자리 · 묶음 안 겹침 없음 · 남는 코드와 겹침 없음 + * map = { 현재코드: 바뀔코드 } (빈 칸·같은 코드는 뺌) + */ + function checkChange(rows, map) { + const errs = []; + const all = new Set((rows || []).map(codeOf)); + const pairs = Object.entries(map || {}).filter(([a, b]) => txt(b) && txt(a) !== txt(b)); + const moving = new Set(pairs.map(([a]) => a)); + const target = new Map(); + pairs.forEach(([a, b]) => { + if (!all.has(a)) errs.push(`${a}: 없는 코드`); + if (!CODE_RE.test(b)) errs.push(`${a} → ${b}: 코드 꼴(글자+5자리) 아님`); + else if (b[0] !== a[0]) + errs.push(`${a} → ${b}: 종류(${KIND_NAME[a[0]] || a[0]}) 글자가 다름`); + if (target.has(b)) errs.push(`${b}: ${target.get(b)}·${a} 둘이 같은 코드로`); + target.set(b, a); + if (all.has(b) && !moving.has(b)) + errs.push(`${b}: 이미 있는 코드(항목교체·코드병합을 쓰세요)`); + }); + return { pairs, errs }; + } + + /** 코드변경 실행(Act_Exc_Change) — COSTN 코드 · 메모 열쇠 · 모든 참조 · 계산 원천 */ + function changeCodes(env, map) { + const d = env.d; + const chk = checkChange(d.costn_rows, map); + if (chk.errs.length) return { ok: false, errors: chk.errs }; + const m = Object.fromEntries(chk.pairs); + if (!chk.pairs.length) return { ok: false, errors: ["바뀔코드가 없음"] }; + (d.costn_rows || []).forEach((r) => { + const c = codeOf(r); + if (m[c]) r.CODE = m[c]; + }); + (d.bdqty_rows || []).forEach((r) => { + const c = txt(r.BDCODE); + if (m[c]) r.BDCODE = m[c]; + }); + const memos = d.costn_memos || {}, + edits = (env.memo = env.memo || {}); + const oldM = Object.assign({}, memos), + oldE = Object.assign({}, edits); + chk.pairs.forEach(([a]) => (delete memos[a], delete edits[a])); + chk.pairs.forEach(([a, b]) => { + if (oldM[a]) memos[b] = Object.assign({}, oldM[a], { code: b }); + if (a in oldE) edits[b] = oldE[a]; + }); + const n = remapRefs(env, m, { items: false }); // 계산 원천은 아래 한 번에(연쇄 +α 겹침 방지) + stateRename(env.state, m); + return { ok: true, count: chk.pairs.length, refs: n, map: m }; + } + + /** 코드번호 변경(현재코드 + α) — 종류별 α 만큼 모든 코드를 밀어 냄 */ + function plusMap(rows, alpha) { + const map = {}; + (rows || []).forEach((r) => { + const c = codeOf(r), + a = parseInt((alpha || {})[c[0]], 10) || 0; + if (CODE_RE.test(c) && a) map[c] = mkCode(c[0], codeNum(c) + a); + }); + return map; + } + + /** 항목교체(Act_Exc_Replace) — 현재코드를 쓰던 자리(내역·구성·식)를 대상코드로 · 현재코드는 남음 */ + function replaceCodes(env, map) { + const d = env.d; + const all = new Set((d.costn_rows || []).map(codeOf)); + const errs = []; + const pairs = Object.entries(map || {}).filter(([a, b]) => txt(b) && a !== txt(b)); + pairs.forEach(([a, b]) => { + if (!all.has(b)) errs.push(`${a} → ${b}: 대상코드가 단가목록에 없음`); + if (map[b] && txt(map[b]) !== b) errs.push(`${b}: 대상코드가 다시 교체됨`); + }); + if (errs.length) return { ok: false, errors: errs }; + if (!pairs.length) return { ok: false, errors: ["대상코드가 없음"] }; + const m = Object.fromEntries(pairs); + const n = remapRefs(env, m); + // 계산 원천 — 내역 항목만 대상 단가로(단가 원천 자원은 그대로) + return { ok: true, count: pairs.length, refs: n, map: m }; + } + + /** 동일항목 대상코드 자동입력 — 묶음의 대표(첫 코드)로 */ + function autoReplaceMap(rows, kinds) { + const map = {}; + sameItems(rows, kinds).forEach((g) => g.slice(1).forEach((c) => (map[c] = g[0]))); + return map; + } + + // ── 지우기 ── + /** 코드일괄삭제 대상 — 사용표시 없는 코드(중기비 포함 여부) */ + function unusedCodes(env, withX) { + const used = usedSet(env); + return (env.d.costn_rows || []) + .map(codeOf) + .filter((c) => CODE_RE.test(c) && !used.has(c) && (withX || c[0] !== "X")); + } + // 사용안된 "천원" "인" 일위대가 + const unusedBUnit = (env) => { + const used = usedSet(env); + return (env.d.costn_rows || []) + .filter( + (r) => codeOf(r)[0] === "B" && !used.has(codeOf(r)) && /^(천원|인)$/.test(txt(r.UNIT)), + ) + .map(codeOf); + }; + + /** 선택코드 삭제실행 — 쓰이는 코드는 지우지 않음(원본 Chk_True_Del_1 자리) */ + function deleteCodes(env, codes) { + const d = env.d; + const want = new Set((codes || []).map(txt)); + const used = usedSet(env); + const kept = [...want].filter((c) => used.has(c)); + const kill = new Set([...want].filter((c) => !used.has(c))); + if (!kill.size) + return { + ok: false, + errors: kept.length ? [`쓰이는 코드라 못 지움: ${kept.join(", ")}`] : ["지울 코드 없음"], + }; + d.costn_deleted = d.costn_deleted || []; + d.costn_rows = (d.costn_rows || []).filter((r) => { + if (!kill.has(codeOf(r))) return true; + if (!r._new && r._recno != null) d.costn_deleted.push(Number(r._recno)); + return false; + }); + d.bdqty_rows = (d.bdqty_rows || []).filter((r) => !kill.has(txt(r.BDCODE))); + kill.forEach((c) => { + if (d.costn_memos) delete d.costn_memos[c]; + if (env.memo) delete env.memo[c]; + }); + const st = env.state; + if (st) { + const gone = (o) => kill.has(o.id) || kill.has(String(o.id || "").replace(/_[MLS]$/, "")); + ["resources", "machines", "sanItems", "ilwiItems"].forEach( + (k) => st[k] && (st[k] = st[k].filter((o) => !gone(o))), + ); + } + d.costn_count = d.costn_rows.length; + d.ui_dirty = true; + return { ok: true, count: kill.size, deleted: [...kill], kept }; + } + + // ── 신규등록 ── + function blankCostn(tmpl) { + const row = {}; + Object.keys(tmpl || {}).forEach((k) => { + if (k.startsWith("_") || k.endsWith("_HEX") || k === "IFORM") return; + row[k] = /AMT|^PG_\d$|NAGCD|^RXX$/.test(k) ? "0" : ""; + }); + return row; + } + + // 계산 원천 항목 하나 — 서버 convert_dbf_to_sim_state 와 같은 꼴(기초단가 자원 · 그 밖 일위대가류) + function stateEntry(state, r) { + const c = codeOf(r), + k = c[0]; + const name = txt(r.NAME) || c, + spec = txt(r.SIZE), + unit = txt(r.UNIT); + const t = num(r.TAMT1); + if ("MLS".includes(k)) { + const cands = [1, 2, 3, 4, 5].map((n) => num(r[`AMT_${n}1`])); + state.resources.push({ + id: c, + code: c, + name, + spec, + unit, + kind: k === "L" ? "노무비" : k === "S" ? "경비" : "재료비", + candidates: cands.some(Boolean) ? cands : [t, 0, 0, 0, 0], + adoptMode: "manual", + manualPrice: t, + convertHourly: false, + isUsd: false, + jdCode: txt(r.JDCODE), + uc: txt(r.UC) || "F", + }); + return; + } + const lines = []; + [ + ["M", "재료비", num(r.MAMT1)], + ["L", "노무비", num(r.LAMT1)], + ["S", "경비", num(r.SAMT1)], + ].forEach(([tag, kind, v]) => { + if (!(v > 0)) return; + const id = `${c}_${tag}`; + state.resources.push({ + id, + code: id, + name: `${name} (${kind})`, + spec, + unit, + kind, + candidates: [v, 0, 0, 0, 0], + adoptMode: "manual", + manualPrice: v, + convertHourly: false, + isUsd: false, + isSynthetic: true, + }); + lines.push({ refId: id, qty: 1.0, halPercent: 0, devd: false }); + }); + const base = { id: c, code: c, spec, unit, fixedM: num(r.MAMT1), fixedL: num(r.LAMT1) }; + Object.assign(base, { fixedS: num(r.SAMT1), fixedT: t }); + state.ilwiItems.push( + Object.assign({ name: `[${k}] ${name}`, rawName: name, prefix: k, lines }, base), + ); + } + + /** + * 신규등록(Act_CopyNew) — 선택 코드를 복사해 새 코드 · 구성표·산출근거 식도 복사 + * from 없으면 빈 행(종류 kind) · fields = { NAME, SIZE, UNIT, … } 덮어씀 + */ + function addCode(env, o) { + const d = env.d; + const rows = (d.costn_rows = d.costn_rows || []); + const src = o.from ? rows.find((r) => codeOf(r) === o.from) : null; + if (o.from && !src) return { ok: false, errors: [`복사할 코드 없음: ${o.from}`] }; + const kind = src ? codeOf(src)[0] : txt(o.kind).toUpperCase(); + if (!KIND_NAME[kind]) return { ok: false, errors: [`코드 종류 글자 아님: ${kind}`] }; + const all = new Set(rows.map(codeOf)); + const code = txt(o.code) || nextCode(rows, kind, all); + if (!CODE_RE.test(code) || code[0] !== kind) + return { ok: false, errors: [`코드 꼴 아님: ${code}`] }; + if (all.has(code)) return { ok: false, errors: [`이미 있는 코드: ${code}`] }; + const row = src + ? Object.fromEntries( + Object.entries(src).filter( + ([k]) => !k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM", + ), + ) + : blankCostn(rows[0]); + Object.assign(row, o.fields || {}, { CODE: code, _new: true, _deleted: false }); + if (src && src._recno != null) row._from = src._new ? src._from : src._recno; + const at = src ? rows.indexOf(src) + 1 : rows.length; + rows.splice(at, 0, row); + if (src) { + const comps = (d.bdqty_rows || []).filter((r) => txt(r.BDCODE) === codeOf(src)); + if (comps.length) { + const all2 = d.bdqty_rows; + const last = all2.lastIndexOf(comps[comps.length - 1]); + const copies = comps.map((r) => { + return Object.assign({}, r, { BDCODE: code }); + }); + all2.splice(last + 1, 0, ...copies); + } + const t = memoText(env, codeOf(src)); + if (t) { + env.memo = env.memo || {}; + env.memo[code] = t; + d.costn_memos = d.costn_memos || {}; + const m = d.costn_memos[codeOf(src)]; + d.costn_memos[code] = Object.assign({}, m || { blockNo: 1 }, { code, text: t }); + } + } + if (env.state) stateEntry(env.state, row); + d.costn_count = rows.length; + d.ui_dirty = true; + return { ok: true, code, row }; + } + + // ── 저장 묶음(api_ui_code.py /api/ui/save) ── + const EXTRA_COL = [ + "BQCODE", + "EQCODE", + "JDCODE", + "WQCODE", + "EDT_DATE", + "NAME2", + "SIZE2", + "PP", + "CONTCODE", + ]; + /** 구조 덧붙임 — 원래 행(코드·덧붙임 칸) · 새 행 · 지운 원본 · 다른 표 코드 · 구성표 */ + function structPayload(d) { + const cur = d.boq_table_name || ""; + const costn_struct = (d.costn_rows || []).map((r) => { + const u = { CODE: codeOf(r) }; + if (r._new) + Object.assign(u, { _new: true, _from: r._from, NAME: r.NAME, SIZE: r.SIZE, UNIT: r.UNIT }); + if (r._new && r._from == null) + u._vals = Object.fromEntries( + Object.entries(r).filter( + ([k, v]) => + !k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object", + ), + ); + else u._recno = r._recno; + EXTRA_COL.forEach((k) => k in r && (u[k] = r[k])); + return u; + }); + const boq_code_rows = []; + boqRowSets(d).forEach(([t, rows]) => { + if (t === cur || rows === d.boq_rows) return; + rows.forEach( + (r) => + r._bd0 && + boq_code_rows.push( + Object.assign( + { table: t, _recno: r._recno }, + Object.fromEntries(BDK.map((k) => [k, r[k]])), + ), + ), + ); + }); + // 구성표(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); + + global.STMATE_UI_CODE = { + CODE_RE, + KIND_NAME, + EXTRA_COL, + codeOf, + boqRowSets, + memoText, + memoRefs, + usedSet, + lastCodes, + nextCode, + mkCode, + codeNum, + sameItems, + stateRename, + stateEntry, + remapRefs, + checkChange, + changeCodes, + plusMap, + replaceCodes, + autoReplaceMap, + unusedCodes, + unusedBUnit, + deleteCodes, + addCode, + structPayload, + needsUiSave, + }; + if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_CODE; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/ui_code_amt.js b/tools/stmate-test/ui_code_amt.js new file mode 100644 index 000000000..b526792f1 --- /dev/null +++ b/tools/stmate-test/ui_code_amt.js @@ -0,0 +1,334 @@ +/** + * STMATE w 8.0 — 단가 일괄 손질 순수 계산 (ui_code_amt.js) · 화면·DOM 없음(node 시험 가능) + * 근거: DFM 원문(C:/경영정보(주)/System) — STmate.Cpm wM_Boxa(적용단가 일괄할증 · 기준단가[AMT_61] · 보호해제시 자동할증 + * · rg_FLT_M_PP/rg_FLT_B_PP 속성 추려보기 · 명칭/규격 추려보기 · 호환규정코드 검색·중복체크) · ST_Jabpum Edt_Hal_Amt/Edt_Hal_Rate + * · ST_Sys wM_Set_Init(분류번호·적용기준일·시설코드·수공분류코드·호환규정코드 초기화 × 비목 8종) + * · ST_EdiLWi wM_Edit_XRate(상각비·정비비·관리비 계수 · 계(10^-7) · 손료 적용율) — 시공량은 calc_xrate.js + * · DP_MinMsg mMinMsg_(특수문자 14종) · 메뉴 Act_1st_2nd/Act_2nd_1st(당초단가 ⇔ 변경단가) + * 일괄변경 세부 필터는 원본 미확정(08번 §3) — 차수·비목·코드 범위·0원 제외를 명시 선택으로 둠 + */ +(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 codeOf = (r) => txt(r && r.CODE); + const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" }; + + // 반올림·절사 — 소수 d 자리(음수 = 10^-d 원 단위) + function roundTo(v, d, mode) { + const f = Math.pow(10, Number(d) || 0); + const x = Number((v * f).toFixed(6)); + return (mode === "round" ? Math.round(x) : Math.trunc(x)) / f; + } + + const slotVals = (r, s) => ({ + M: num(r[`MAMT${s}`]), + L: num(r[`LAMT${s}`]), + S: num(r[`SAMT${s}`]), + }); + function writeSlot(r, s, v) { + ["M", "L", "S"].forEach((c) => (r[`${c}AMT${s}`] = String(v[c]))); + r[`TAMT${s}`] = String(v.M + v.L + v.S); + } + + /** + * 단가 일괄변경·물가변동(uM_Chg_Amt) — from 차수 단가 × rate% → to 차수 · 비목별 끝전 + * o = { kinds: "MLSW", from: 1, to: 1, rate: 100, digits: 0, mode: "trunc", skipZero: true, codes: null } + * 낙찰률 행(NAGCD>0)은 2차 칸이 100% 단가 자리라 건너뜀 · 결과 = [{ code, from:{T}, to:{T} }] + */ + function chgAmt(rows, o) { + const opt = Object.assign( + { kinds: "MLSW", from: 1, to: 1, rate: 100, digits: 0, mode: "trunc", skipZero: true }, + o || {}, + ); + const only = opt.codes ? new Set(opt.codes) : null; + const out = []; + (rows || []).forEach((r) => { + 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; + const src = slotVals(r, opt.from); + const T0 = src.M + src.L + src.S; + if (opt.skipZero && !(T0 > 0)) return; + const v = {}; + ["M", "L", "S"].forEach( + (k) => (v[k] = roundTo((src[k] * num(opt.rate)) / 100, opt.digits, opt.mode)), + ); + const before = slotVals(r, opt.to); + if (["M", "L", "S"].every((k) => before[k] === v[k])) return; + writeSlot(r, opt.to, v); + out.push({ code: c, from: before.M + before.L + before.S, to: v.M + v.L + v.S }); + }); + return out; + } + + /** + * 적용단가 일괄할증(Edt_Hal_Rate · Edt_Hal_Amt) — 기준단가(AMT_61, 없으면 지금 적용단가) 기준 + * how = "rate"(× (1 + v%)) · "amt"(+ v 원) · 같은 할증을 두 번 해도 결과 같음 + * 기초단가(M·L·S)는 자기 비목 한 칸 · 견적단가(W)는 비목 비율 그대로 나눔 + */ + function halAll(rows, o) { + const opt = Object.assign( + { kinds: "M", how: "rate", value: 0, digits: 0, mode: "trunc" }, + o || {}, + ); + const only = opt.codes ? new Set(opt.codes) : null; + const out = []; + (rows || []).forEach((r) => { + const c = codeOf(r); + if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return; + const cur = slotVals(r, 1); + const curT = cur.M + cur.L + cur.S; + const base = num(r.AMT_61) || curT; + if (!(base > 0)) return; + const tgt = roundTo( + opt.how === "amt" ? base + num(opt.value) : base * (1 + num(opt.value) / 100), + opt.digits, + opt.mode, + ); + if (!num(r.AMT_61)) r.AMT_61 = String(base); + let v; + if (OWN[c[0]]) v = Object.assign({ M: 0, L: 0, S: 0 }, { [c[0]]: tgt }); + else { + const k = tgt / base; + v = { + M: roundTo(cur.M * k, opt.digits, opt.mode), + L: roundTo(cur.L * k, opt.digits, opt.mode), + }; + v.S = tgt - v.M - v.L; + } + if (["M", "L", "S"].every((x) => cur[x] === v[x])) return; + writeSlot(r, 1, v); + out.push({ code: c, base, from: curT, to: tgt }); + }); + return out; + } + + // 프로젝트 계산 초기값(wM_Set_Init) — 칸 무리 × 비목 글자 + const INIT_COLS = { BQ: "BQCODE", ED: "EDT_DATE", EQ: "EQCODE", WQ: "WQCODE", JD: "JDCODE" }; + const INIT_KINDS = ["L", "M", "S", "W", "B", "X", "D", "A"]; + function initCols(rows, pick) { + const out = {}; + Object.entries(pick || {}).forEach(([g, kinds]) => { + const col = INIT_COLS[g]; + if (!col) return; + (rows || []).forEach((r) => { + const c = codeOf(r); + if (!c || !String(kinds).includes(c[0]) || !(col in r) || !txt(r[col])) return; + r[col] = ""; + out[col] = (out[col] || 0) + 1; + }); + }); + return out; + } + + // 속성(PP)·명칭/규격 추려보기(rg_FLT_*_PP · mBtn_Flt_*) — 원본 속성 목록(급수 공종) + const PP_M = [ + "10 : 새들분수전", + "11 : K.P 자재", + "12 : 엘보", + "13 : 직관", + "14 : 소켓", + "15 : S.T.S 자재", + ]; + PP_M.push( + "16 : 니플", + "17 : 죠인트", + "18 : 계량기보호통", + "19 : 계량기", + "20 : 사급자재", + "25 : 무선원격검침비", + ); + const PP_B = [ + "30 : 토공", + "31 : 포장공", + "32 : 포장깨기/절단", + "30~32 : 토공사", + "40 : 분기공", + "41 : 관접합", + ]; + PP_B.push( + "42 : 관연결", + "43 : 죠인트 접합", + "44 : 계량기 설치", + "45 : 보호통 설치", + "40~45 : 관공사", + ); + PP_B.push( + "50 : 부대공", + "60 : 폐기물( CON'C )", + "61 : 폐기물( ASP )", + "62 : 폐기물( BODO )", + "65 : 폐기물운반", + ); + function ppMatch(r, opt) { + const v = parseInt(txt(r.PP), 10); + const m = String(opt || "").match(/^(\d+)(?:~(\d+))?/); + if (!m) return true; + const a = Number(m[1]), + b = m[2] ? Number(m[2]) : a; + return v >= a && v <= b; + } + function nameList(rows, kind, field) { + const f = field === "SIZE" ? "SIZE" : "NAME"; + const seen = new Map(); + (rows || []).forEach((r) => { + const c = codeOf(r); + if (c && (!kind || kind.includes(c[0])) && txt(r[f])) + seen.set(txt(r[f]), (seen.get(txt(r[f])) || 0) + 1); + }); + return [...seen.entries()].sort((a, b) => a[0].localeCompare(b[0], "ko")); + } + + // 불러간 목록(wM_Box_H) — 이 프로젝트 내역서·구성표에 불려 간 코드만 + function calledCodes(d) { + const out = new Set(); + Object.values((d && d.boq_tables) || {}).forEach((rows) => + (rows || []).forEach((r) => txt(r.BDCODE1) && out.add(txt(r.BDCODE1))), + ); + ((d && d.boq_rows) || []).forEach((r) => txt(r.BDCODE1) && out.add(txt(r.BDCODE1))); + ((d && d.bdqty_rows) || []).forEach((r) => txt(r.CODE) && out.add(txt(r.CODE))); + return out; + } + + // 호환규정코드 찾기(wM_JDCode wCb_Base) — 기준(명칭 / 명칭·규격 / 분류번호 / 시설코드)이 같은 자료의 코드 + const JD_BASE = { + N: (r) => txt(r.NAME), + NS: (r) => `${txt(r.NAME)}|${txt(r.SIZE)}`, + BQ: (r) => txt(r.BQCODE), + EQ: (r) => txt(r.EQCODE), + }; + function jdCandidates(row, pool, base) { + const f = JD_BASE[base] || JD_BASE.NS; + const key = f(row); + const cnt = new Map(); + if (!key || key === "|") return []; + (pool || []).forEach((p) => { + if (!txt(p.JDCODE) || f(p) !== key) return; + const x = cnt.get(txt(p.JDCODE)) || { + jd: txt(p.JDCODE), + n: 0, + unit: txt(p.UNIT), + src: new Set(), + }; + x.n += 1; + if (p.project) x.src.add(p.project); + cnt.set(x.jd, x); + }); + return [...cnt.values()] + .map((x) => ({ jd: x.jd, n: x.n, unit: x.unit, src: [...x.src] })) + .sort((a, b) => b.n - a.n || a.jd.localeCompare(b.jd)); + } + // 호환규정코드 중복체크(Act_Chk_JDCode) — 한 호환코드를 둘 이상 코드가 씀 + function jdDup(rows) { + const g = new Map(); + (rows || []).forEach( + (r) => txt(r.JDCODE) && g.set(txt(r.JDCODE), (g.get(txt(r.JDCODE)) || []).concat(codeOf(r))), + ); + return [...g.entries()].filter(([, a]) => a.length > 1).map(([jd, codes]) => ({ jd, codes })); + } + + /** + * 시간당 손료계수(wM_Edit_XRate) — 계 = 상각비 + 정비비 + 관리비 계수(×10^-7) + * 구성 수량 = 계 × 10^-7 × 1,000(기계가격 천원 단위) × 적용율% · 「계 입력」이면 계만 + */ + const lossQty = (sum, ratePct) => + Number(((num(sum) * 1e-4 * num(ratePct === undefined ? 100 : ratePct)) / 100).toFixed(10)); + const lossSum = (qty, ratePct) => { + const r = num(ratePct === undefined ? 100 : ratePct); + return r ? Number(((num(qty) * 1e4 * 100) / r).toFixed(6)) : 0; + }; + // 시공량(기준·일당·적용 수량) 계산은 calc_xrate.js(STMATE_XRATE.compute) + + // 특수문자(mMinMsg_ ListBox1 14종) — KS X 1001 특수문자 줄(A1~AC) 대응 · 분수/첨자·특수외국어는 A8·A9 일부 + const SPECIAL = [ + ["기호1", [0xa1]], + ["기호2", [0xa2]], + ["영어/숫자", [0xa3]], + ["한글자모", [0xa4]], + ["그리스어", [0xa5]], + ["선문자", [0xa6]], + ["단위기호", [0xa7]], + ["원문자", [0xa8], [0xb1, 0xf8]], + ["괄호문자", [0xa9], [0xb1, 0xf6]], + ["히라가다", [0xaa]], + ["가다가나", [0xab]], + ["러시아어", [0xac]], + ["특수외국어", [0xa8, 0xa9], [0xa1, 0xb0]], + ["분수/첨자", [0xa8, 0xa9], [0xf6, 0xfe]], + ]; + function specialChars(i, decode) { + const [, leads, range] = SPECIAL[i] || SPECIAL[0]; + const [a, b] = range || [0xa1, 0xfe]; + const out = []; + leads.forEach((lead) => { + for (let t = a; t <= b; t++) { + const ch = decode(new Uint8Array([lead, t])); + if (ch && ch !== "\ufffd" && !/^\s$/.test(ch)) out.push(ch); + } + }); + return out; + } + + // 사용자문자(wM_Edit_USER) — 검색명칭 → 입력내용 · 적용 표시된 것만 + function userTextFind(list, kw) { + const k = txt(kw).toLowerCase(); + return (list || []).filter( + (x) => x.on !== false && (!k || txt(x.key).toLowerCase().includes(k)), + ); + } + + /** + * 실적단가 → 일식단가 변환(wM_Conv_A2W) — 줄마다 비목율(L·M·E %, calc_a2w.js bulkApply 결과)로 단가를 나눔 + * src = [{ CODE, NAME, SIZE, UNIT, AAMT1, L, M, E }] · start = 첫 W 코드 · 노무·재료 절사, 경비 = 나머지 + */ + function a2w(src, o) { + const opt = Object.assign({ digits: 0, start: "W00001", taken: null }, o || {}); + let n = parseInt(String(opt.start).slice(1), 10) || 1; + return (src || []).map((r) => { + const p = num(r.AAMT1 || r.TAMT1); + const L = roundTo((p * num(r.L)) / 100, opt.digits, "trunc"); + const M = roundTo((p * num(r.M)) / 100, opt.digits, "trunc"); + while (opt.taken && opt.taken.has(`W${String(n).padStart(5, "0")}`)) n += 1; + const code = `W${String(n++).padStart(5, "0")}`; + return { + from: codeOf(r), + CODE: code, + NAME: r.NAME, + SIZE: r.SIZE, + UNIT: r.UNIT, + M, + L, + S: p - L - M, + T: p, + }; + }); + } + + global.STMATE_UI_CODE_AMT = { + roundTo, + chgAmt, + halAll, + INIT_COLS, + INIT_KINDS, + initCols, + PP_M, + PP_B, + ppMatch, + nameList, + calledCodes, + jdCandidates, + jdDup, + lossQty, + lossSum, + SPECIAL, + specialChars, + userTextFind, + a2w, + }; + if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_CODE_AMT; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/tools/stmate-test/ui_dan.js b/tools/stmate-test/ui_dan.js index 8cc658be3..c56beec5e 100644 --- a/tools/stmate-test/ui_dan.js +++ b/tools/stmate-test/ui_dan.js @@ -3,7 +3,7 @@ * 근거: ui_form_catalog.txt wM_JabPum cBoxCode(10종)·cBox_IFORM · wM_Boxa RG_MComp(변동없음/1~5 단가/최소단가) * · 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) — 실자료 표본에는 없음 */ (function (global) { "use strict"; @@ -28,7 +28,7 @@ ["D", "단가산출", (p) => p === "D"], ["W", "견적단가", (p) => p === "W"], ["BD", "대가,산근", (p) => p === "B" || p === "D"], - ["G", "그림코드", () => false], + ["G", "그림코드", (p) => p === "I"], ]; const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" }; const isBase = (code) => /^[MLS]/.test(code); diff --git a/tools/stmate-test/ui_merge.js b/tools/stmate-test/ui_merge.js new file mode 100644 index 000000000..cd66de4bf --- /dev/null +++ b/tools/stmate-test/ui_merge.js @@ -0,0 +1,310 @@ +/** + * STMATE w 8.0 — 코드병합·데이터병합 순수 계산 (ui_merge.js) · 복사처(지금 프로젝트) ← 복사원(다른 프로젝트) + * 근거: DFM 원문 — ST_Merge_Code.CPM wM_Stmate_Merge_Code(rg_Set_Sys 4택: 선택코드만 / 선택코드+하위기초단가코드 / + * 모든코드(하위 산근,대가포함) / 코드병합 취소 · 「복사처에 등록되지 않은코드 : 코드번호 생성(병합)」) + * · ST_U_Merge.CPM uM_Merge(RdoL~RdoW: 복사처적용(동일항목 코드치환) / 복사원적용(동일항목 내용복사) / + * 신규등록(단가다른코드 신규생성) · Rdo_BD·Rdo_X 일식단가(W코드)로 생성 · Rdo_Sel_Con 동일항목 연결 3택 + * · 「동일항목 이란? - 명칭+규격+단위 가 복사처와 같은것으로 코드번호는 무관함」 · 메뉴 Act_Merge_C 코드번호로 인식) + */ +(function (global) { + "use strict"; + const UC = + global.STMATE_UI_CODE || (typeof require !== "undefined" ? require("./ui_code.js") : null); + const txt = (v) => String(v == null ? "" : v).trim(); + const num = (v) => Number(txt(v).replace(/,/g, "")) || 0; + const codeOf = (r) => txt(r && r.CODE); + const REF_RE = /&([A-Z]\d{5})([LMS]?(?::[LMS])?)&/g; + + // 복사원 자료 — 코드 → 행 · 구성 · 식 + function srcIndex(src) { + const by = new Map((src.costn_rows || []).map((r) => [codeOf(r), r])); + const comp = new Map(); + (src.bdqty_rows || []).forEach((r) => { + const p = txt(r.BDCODE); + comp.set(p, (comp.get(p) || []).concat(r)); + }); + const memo = (c) => ((src.costn_memos || {})[c] || {}).text || ""; + const kids = (c) => { + const a = (comp.get(c) || []).map((r) => txt(r.CODE)); + return a.concat([...memo(c).matchAll(REF_RE)].map((m) => m[1])).filter((k) => by.has(k)); + }; + return { by, comp, memo, kids }; + } + + /** 병합할 코드 — mode 0 선택코드만 · 1 +하위 기초단가(M·L·S) · 2 모든 하위(산근·대가·중기 포함) */ + function closure(src, codes, mode) { + const ix = srcIndex(src); + const out = new Set(); + const walk = (c, deep) => { + if (!ix.by.has(c) || (out.has(c) && deep)) return; + out.add(c); + if (mode === 0) return; + ix.kids(c).forEach((k) => { + if (/^[MLS]/.test(k)) out.add(k); + else if (mode === 2) walk(k, true); + }); + }; + (codes || []).forEach((c) => walk(c, true)); + return [...out]; + } + + // 동일항목 열쇠 — conn 0 「1차 호환, 2차 시설, 3차 명칭..단위」 · 1 「1차 호환, 2차 명칭+규격+단위」 · 2 명칭+규격+단위 · "code" 코드번호 + const nsu = (r) => [txt(r.NAME), txt(r.SIZE), txt(r.UNIT)].join("\u0001"); + function findSame(r, dest, conn) { + const k = codeOf(r)[0]; + const pool = dest.filter((x) => codeOf(x)[0] === k); + if (conn === "code") return pool.find((x) => codeOf(x) === codeOf(r)) || null; + const tries = []; + if (conn === 0 || conn === 1) + tries.push((x) => txt(r.JDCODE) && txt(x.JDCODE) === txt(r.JDCODE)); + if (conn === 0) tries.push((x) => txt(r.EQCODE) && txt(x.EQCODE) === txt(r.EQCODE)); + // 명칭+규격+단위 — 단가까지 같은 항목 먼저 + tries.push((x) => nsu(x) === nsu(r) && num(x.TAMT1) === num(r.TAMT1)); + tries.push((x) => nsu(x) === nsu(r)); + for (const f of tries) { + const hit = pool.find(f); + if (hit) return hit; + } + return null; + } + + const POLICY = ["복사처적용", "복사원적용", "신규등록"]; + const PRICE = ["TAMT1", "LAMT1", "MAMT1", "SAMT1", "TAMT2", "LAMT2", "MAMT2", "SAMT2"]; + const COPY_KEEP = [ + "NAME", + "SIZE", + "UNIT", + ...PRICE, + "AMT_11", + "AMT_21", + "AMT_31", + "AMT_41", + "AMT_51", + ]; + COPY_KEEP.push( + "AMT_61", + "PG_1", + "PG_11", + "PG_21", + "PG_31", + "PG_41", + "PG_51", + "JDCODE", + "EDT_DATE", + "BQCODE", + "EQCODE", + ); + + /** + * 병합 계획 — 코드마다 { src, act: "map"|"copy"|"new"|"w", dest } (자식 먼저) + * o = { codes, mode(0~2), conn(0~2|"code"), policy: {L,M,S,B,D,W: 0~2}, bdW, xW, start: {kind: 코드} } + */ + function plan(env, src, o) { + const opt = Object.assign( + { mode: 2, conn: 2, policy: {}, bdW: false, xW: false, start: {} }, + o || {}, + ); + const d = env.d; + const ix = srcIndex(src); + const want = new Set(closure(src, opt.codes, opt.mode)); + const order = []; + const seen = new Set(); + const visit = (c) => { + if (seen.has(c) || !want.has(c)) return; + seen.add(c); + ix.kids(c).forEach(visit); + order.push(c); + }; + [...want].forEach(visit); + const taken = new Set((d.costn_rows || []).map(codeOf)); + const counters = {}; + const newCode = (k) => { + const start = txt((opt.start || {})[k]); + if (counters[k] == null) + counters[k] = + start && UC.CODE_RE.test(start) + ? UC.codeNum(start) + : UC.codeNum(UC.nextCode(d.costn_rows, k, taken) || `${k}00001`); + while (taken.has(UC.mkCode(k, counters[k]))) counters[k] += 1; + const c = UC.mkCode(k, counters[k]); + taken.add(c); + return c; + }; + return order.map((c) => { + const r = ix.by.get(c); + let k = c[0]; + const toW = (opt.bdW && (k === "B" || k === "D")) || (opt.xW && k === "X"); + if (toW) k = "W"; + const pol = Number((opt.policy || {})[toW ? "W" : c[0]] || 0); + const same = toW ? null : findSame(r, d.costn_rows || [], opt.conn); + if (same) { + if (pol === 2 && num(same.TAMT1) !== num(r.TAMT1)) + return { src: c, act: "new", dest: newCode(k), same: codeOf(same) }; + return { src: c, act: pol === 1 ? "copy" : "map", dest: codeOf(same) }; + } + return { src: c, act: toW ? "w" : "new", dest: newCode(k) }; + }); + } + + /** 병합 실행 — 계획대로 새 코드 등록·내용 복사·코드 치환(구성표·식의 복사원 코드 → 복사처 코드) */ + function apply(env, src, steps) { + const d = env.d; + const ix = srcIndex(src); + const map = Object.fromEntries(steps.map((s) => [s.src, s.dest])); + const unresolved = new Set(); + // 계획 밖 하위 코드 — 복사처의 같은 명칭·규격·단위 항목, 없으면 그대로 두고 알림 + const resolve = (c) => { + if (map[c] || !ix.by.has(c)) return map[c] || c; + const same = findSame(ix.by.get(c), d.costn_rows || [], 2); + if (same) return (map[c] = codeOf(same)); + unresolved.add(c); + return c; + }; + env.memo = env.memo || {}; + d.costn_memos = d.costn_memos || {}; + d.bdqty_rows = d.bdqty_rows || []; + const remapText = (t) => + String(t || "").replace(REF_RE, (all, c, tail) => `&${resolve(c)}${tail}&`); + // 구성행 칸 — 복사처 구성표가 읽는 칸만(프로젝트마다 복호되는 칸이 다름) + const keys = new Set(Object.keys(d.bdqty_rows[0] || { BDCODE: 1, CODE: 1, QTY1: 1 })); + const putBody = (srcCode, dest) => { + d.bdqty_rows = d.bdqty_rows.filter((q) => txt(q.BDCODE) !== dest); + (ix.comp.get(srcCode) || []).forEach((q) => { + const x = Object.fromEntries( + Object.entries(q).filter(([k]) => keys.has(k) && !k.startsWith("_")), + ); + Object.assign(x, { BDCODE: dest, CODE: resolve(txt(q.CODE)) }); + d.bdqty_rows.push(x); + }); + const t = ix.memo(srcCode); + if (t) { + const t2 = remapText(t); + env.memo[dest] = t2; + d.costn_memos[dest] = Object.assign({}, d.costn_memos[dest] || { blockNo: 1 }, { + code: dest, + text: t2, + }); + } + }; + const n = { new: 0, copy: 0, map: 0, w: 0 }; + steps.forEach((s) => { + const r = ix.by.get(s.src); + n[s.act] += 1; + if (s.act === "map") return; + if (s.act === "copy") { + const t = d.costn_rows.find((x) => codeOf(x) === s.dest); + COPY_KEEP.forEach((k) => k in r && (t[k] = r[k])); + if (!/^[MLSW]/.test(s.dest)) putBody(s.src, s.dest); + return; + } + const row = Object.fromEntries( + Object.entries(r).filter( + ([k, v]) => + !k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object", + ), + ); + Object.assign(row, { CODE: s.dest, _new: true, _deleted: false }); + d.costn_rows.push(row); + if (s.act !== "w") putBody(s.src, s.dest); + if (env.state) UC.stateEntry(env.state, row); + }); + d.costn_count = d.costn_rows.length; + d.ui_dirty = true; + return { ok: true, map, count: n, unresolved: [...unresolved] }; + } + + /** 내역서 행 가져오기 — 복사원 내역 단가코드 행을 복사처 코드로 바꿔 새 행 값으로 */ + function boqRowsFrom(srcRows, map) { + return (srcRows || []).map((r) => { + const c = txt(r.BDCODE1); + return { + BDCODE1: map[c] || c, + NAME1: r.NAME1 || "", + SIZE1: r.SIZE1 || "", + UNIT1: r.UNIT1 || "", + QTY1: String(num(r.QTY1)), + ITNUM: r.ITNUM || "", + }; + }); + } + + /** + * 자료파일 업데이트(wM_Mrg_BSC) — 매칭 1~5차(cBx_Sel1~5) 차례로 복사원 같은 항목을 찾아 명칭·단가·조달코드 갱신 + * keys: "JD"(호환규정코드) · "EQ"(시설코드) · "NSU"(명칭/규격/단위) · "JNSU"(호환+명+규+단) · "NU"(명칭/단위) · ""(설정안함) + * o = { keys, name: 0|1, amt: 0|1, jd: 0 안함 | 1 복사원 | 2 복사처 우선(비었을 때만) | 3 복사원 우선(복사원에 있을 때) , kinds } + */ + const MK = { + JD: (r) => txt(r.JDCODE), + EQ: (r) => txt(r.EQCODE), + NSU: (r) => (txt(r.NAME) ? nsu(r) : ""), + JNSU: (r) => (txt(r.JDCODE) && txt(r.NAME) ? `${txt(r.JDCODE)}${nsu(r)}` : ""), + NU: (r) => (txt(r.NAME) ? `${txt(r.NAME)}${txt(r.UNIT)}` : ""), + }; + const AMT = [ + "TAMT1", + "LAMT1", + "MAMT1", + "SAMT1", + "AMT_11", + "AMT_21", + "AMT_31", + "AMT_41", + "AMT_51", + "AMT_61", + "PG_1", + ]; + function mrgBsc(dest, src, o) { + const opt = Object.assign( + { keys: ["JD", "NSU"], name: 0, amt: 1, jd: 0, kinds: "LMSW" }, + o || {}, + ); + const keys = opt.keys.filter((k) => MK[k]); + const idx = keys.map((k) => { + const m = new Map(); + (src || []).forEach((r) => { + const v = MK[k](r); + if (v && opt.kinds.includes(codeOf(r)[0]) && !m.has(`${codeOf(r)[0]}|${v}`)) + m.set(`${codeOf(r)[0]}|${v}`, r); + }); + return m; + }); + const out = []; + (dest || []).forEach((r) => { + const c = codeOf(r); + if (!c || !opt.kinds.includes(c[0])) return; + let hit = null, + by = ""; + keys.some((k, i) => { + const v = MK[k](r); + hit = v ? idx[i].get(`${c[0]}|${v}`) : null; + by = k; + return Boolean(hit); + }); + if (!hit) return; + const ch = []; + if (opt.name) + ["NAME", "SIZE", "UNIT"].forEach( + (k) => txt(r[k]) !== txt(hit[k]) && (ch.push(k), (r[k] = hit[k])), + ); + if (opt.amt) + AMT.forEach((k) => k in hit && num(r[k]) !== num(hit[k]) && (ch.push(k), (r[k] = hit[k]))); + const jd = txt(hit.JDCODE); + const takeJd = opt.jd === 1 || (opt.jd === 2 && !txt(r.JDCODE) && jd) || (opt.jd === 3 && jd); + if (takeJd && txt(r.JDCODE) !== jd) (ch.push("JDCODE"), (r.JDCODE = jd)); + if (ch.length) out.push({ code: c, from: codeOf(hit), by, fields: ch }); + }); + return out; + } + + global.STMATE_UI_MERGE = { + POLICY, + srcIndex, + closure, + findSame, + plan, + apply, + boqRowsFrom, + mrgBsc, + }; + if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_MERGE; +})(typeof window !== "undefined" ? window : globalThis);