From 4938101164a237d93e634590b7cf6e370862ba53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 13 Nov 2026 19:28:55 +0900 Subject: [PATCH] =?UTF-8?q?wip(stmate-test):=202=EC=B0=A8=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EC=A4=91=EB=8B=A8=20=EC=8B=9C=EC=A0=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=E2=80=94=20=EB=82=B4=EC=97=AD=20=EB=81=9D=EC=A0=84?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=C2=B7=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=C2=B7=EC=A0=80=EC=9E=A5=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?(=EC=8B=9C=ED=97=98=20355=20=ED=86=B5=EA=B3=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_stmate_kanjub_boq.cjs | 12 + .../tester/test_stmate_file_save_extra.py | 162 ++++++++++++ resources/tester/test_stmate_kanjub_boq.py | 92 +++++++ tools/stmate-test/calc.js | 77 +++++- tools/stmate-test/calc_a2w.js | 47 ++++ tools/stmate-test/calc_xrate.js | 56 ++++ tools/stmate-test/conv_xls_boq.py | 249 ++++++++++++++++++ tools/stmate-test/report_book.js | 2 +- tools/stmate-test/report_boq.js | 3 +- tools/stmate-test/report_coll.js | 246 +++++++++++++++++ tools/stmate-test/report_core.js | 2 +- tools/stmate-test/report_unit.js | 23 +- tools/stmate-test/server.py | 4 + tools/stmate-test/smt_engine.js | 24 +- tools/stmate-test/smt_eval.js | 47 +++- tools/stmate-test/stc_save.py | 16 +- tools/stmate-test/stc_save_extra.py | 171 ++++++++++++ tools/stmate-test/view_prj_boq.js | 4 + 18 files changed, 1196 insertions(+), 41 deletions(-) create mode 100644 resources/tester/helper_stmate_kanjub_boq.cjs create mode 100644 resources/tester/test_stmate_file_save_extra.py create mode 100644 resources/tester/test_stmate_kanjub_boq.py create mode 100644 tools/stmate-test/calc_a2w.js create mode 100644 tools/stmate-test/calc_xrate.js create mode 100644 tools/stmate-test/conv_xls_boq.py create mode 100644 tools/stmate-test/report_coll.js create mode 100644 tools/stmate-test/stc_save_extra.py diff --git a/resources/tester/helper_stmate_kanjub_boq.cjs b/resources/tester/helper_stmate_kanjub_boq.cjs new file mode 100644 index 000000000..453322dab --- /dev/null +++ b/resources/tester/helper_stmate_kanjub_boq.cjs @@ -0,0 +1,12 @@ +// STMATE 재현 앱 calc.js 를 상태 JSON(stdin)으로 돌려 차수별 내역 줄 금액(재·노·경)을 냄 +const C = require("../../tools/stmate-test/calc.js"); +let buf = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (d) => (buf += d)); +process.stdin.on("end", () => { + const res = C.calculateAll(JSON.parse(buf)); + const out = {}; + for (const [n, s] of Object.entries(res.stages)) + out[n] = s.boq.rows.map((r) => [r.id, r.code, r.mAmt, r.lAmt, r.sAmt]); + process.stdout.write(JSON.stringify(out)); +}); diff --git a/resources/tester/test_stmate_file_save_extra.py b/resources/tester/test_stmate_file_save_extra.py new file mode 100644 index 000000000..6513e446b --- /dev/null +++ b/resources/tester/test_stmate_file_save_extra.py @@ -0,0 +1,162 @@ +"""STMATE 재현 앱 저장 덧붙임 — COSTN 새 코드 행 · BDQTY 구성 고침 · QCODE · 제잡비 설정(PRJLST.SMT). + +정답은 원본 STC 자체 — 고치지 않으면 원래 바이트 · 고친 것만 바뀜 · 다시 읽으면 고친 값. +쓰기는 임시 폴더(server.WORK_DBF_ROOT 바꿈)에서만. +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/stmate-test")) +import server # noqa: E402 +from bdqty import decode_bdqty # noqa: E402 +from dbf_rw import DbfTable # noqa: E402 +from kanjub_cfg import build_kanjub_config, stage_slots # noqa: E402 +from qty_qcode import read_qcode # noqa: E402 +from smt_parser import parse_dbf_bytes, parse_prjlst_smt # noqa: E402 +from stc_archive import files_of, read_entries # noqa: E402 +from stc_save import apply_save # noqa: E402 + +STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC")) +IDS = [p.stem[:16] for p in STCS] + + +def _files(p): + return files_of(read_entries(p.read_bytes())) + + +def _kv(files): + _, prows, _ = parse_dbf_bytes(files["PRJLST.DBF"]) + return parse_prjlst_smt(files["PRJLST.SMT"], prows[0]["PRJ_MEMO_HEX"]) + + +@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_unchanged_extras_keep_bytes(stc): + f = _files(stc) + kv = _kv(f) + req = { + "kanjub_stages": {n: build_kanjub_config(kv, n) for n in stage_slots(kv)}, + "bdqty_rows": decode_bdqty(f.get("BDQTY.DBF", b"")), + "qcode_rows": read_qcode(f)["rows"], + } + out, log = apply_save(f, req) + assert log == [] and all(out[k] is f[k] for k in f) + + +def test_costn_new_code_row_and_nagcd(): + f = _files(STCS[0]) + ct = DbfTable(f["COSTN.DBF"]) + m_last = max(i for i in range(len(ct)) if ct.get(i, "CODE").startswith("M")) + some = ct.get(0, "CODE") + ups = [ + {"CODE": "M09991", "NAME": "새 자재(계약 변환)", "UNIT": "EA", "MAMT1": 1234, "TAMT1": 1234}, + {"CODE": some, "NAGCD": 1}, + ] + _, brows, _ = parse_dbf_bytes(f["BOQ11.DBF"]) + boq = [{"_recno": r["_recno"]} for r in brows] + [ + {"_new": True, "BDCODE1": "M09991", "NAME1": "새 자재(계약 변환)", "QTY1": 3} + ] + out, log = apply_save(f, {"costn_updates": ups, "boq_updates": boq}) + nt = DbfTable(out["COSTN.DBF"]) + assert len(nt) == len(ct) + 1 + assert nt.get(m_last + 1, "CODE") == "M09991" and nt.get(m_last + 1, "NAME") == "새 자재(계약 변환)" + assert float(nt.get(m_last + 1, "MAMT1")) == 1234 and float(nt.get(m_last + 1, "LAMT1")) == 0 + assert nt.raw(m_last + 1, "IFORM").strip() == b"" + for i in range(len(ct)): # 다른 행 바이트 그대로(NAGCD 고친 칸만) + j = i if i <= m_last else i + 1 + if ct.get(i, "CODE") != some: + assert nt.recs[j] == ct.recs[i] + assert float(nt.get(0, "NAGCD")) == 1 + bt = DbfTable(out["BOQ11.DBF"]) + k = len(bt) - 1 + assert bt.get(k, "BDCODE1") == "M09991" and float(bt.get(k, "MAMT1")) == 1234 + assert int(float(bt.get(k, "M_AMT1"))) == 3 * 1234 + assert any(x["field"] == "_insert" and x["table"] == "COSTN.DBF" for x in log) + + +@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3]) +def test_bdqty_edit_add_delete(stc): + f = _files(stc) + rows = decode_bdqty(f["BDQTY.DBF"]) + t0 = DbfTable(f["BDQTY.DBF"]) + codes = [] + for r in rows: + if r["BDCODE"] not in codes: + codes.append(r["BDCODE"]) + edit, drop = codes[0], codes[-1] + new = [dict(r) for r in rows if r["BDCODE"] != drop] + i0 = next(i for i, r in enumerate(new) if r["BDCODE"] == edit) + q = float(new[i0]["QTY1"]) + 0.25 + new[i0]["QTY1"] = q + extra = dict(next(r for r in rows if r["BDCODE"] != edit), BDCODE=edit, QTY1=2.5) + new.insert(i0 + 1, extra) + out, log = apply_save(f, {"bdqty_rows": new}) + got = decode_bdqty(out["BDQTY.DBF"]) + assert len(got) == len(new) + assert float(got[i0]["QTY1"]) == q + assert got[i0 + 1]["BDCODE"] == edit and got[i0 + 1]["CODE"] == extra["CODE"] + assert float(got[i0 + 1]["QTY1"]) == 2.5 + assert drop not in {r["BDCODE"] for r in got} + # 손대지 않은 호표 행은 원래 레코드 바이트 그대로 + nt = DbfTable(out["BDQTY.DBF"]) + kept = {bytes(r) for r in t0.recs} + same = [bytes(nt.recs[i]) in kept for i, r in enumerate(got) if r["BDCODE"] not in (edit,)] + assert all(same) + assert {x["table"] for x in log} == {"BDQTY.DBF"} + out2, log2 = apply_save(out, {"bdqty_rows": got}) # 다시 저장 — 그대로 + assert log2 == [] and out2["BDQTY.DBF"] is out["BDQTY.DBF"] + + +def test_qcode_rows_saved_and_loaded(work): + stc = STCS[0] + rows = [ + {"CODE": "Q00001", "NAME": "토사 절취량", "BQCODE": "", "QTY0": 12.5, "IFORM": "L*B*H", "XCODE": ""}, + {"CODE": "Q00002", "NAME": "암 절취량", "QTY0": 3, "IFORM": "", "XCODE": "A=1"}, + ] + res = server.api_save(server.SaveDbfReq(source_path=str(stc), target_folder_name="qc", qcode_rows=rows)) + assert set(res["changed_files"]) == {"QCODE.DBF", "QCODE.SMT"} + load = server.api_load(server.LoadReq(source_path=res["saved_stc"])) + got = load["qcode_rows"] + assert [r["CODE"] for r in got] == ["Q00001", "Q00002"] + assert float(got[0]["QTY0"]) == 12.5 and got[0]["IFORM"] == "L*B*H" and got[1]["XCODE"] == "A=1" + # 불러온 행 그대로 다시 저장 — 수량코드 파일은 바뀌지 않음 + res2 = server.api_save( + server.SaveDbfReq(source_path=res["saved_stc"], target_folder_name="qc2", qcode_rows=got) + ) + assert res2["change_count"] == 0 + with pytest.raises(Exception): + server.api_save( + server.SaveDbfReq(source_path=str(stc), target_folder_name="qc3", qcode_rows=[{"CODE": "X1"}]) + ) + + +@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3]) +def test_kanjub_stage_change_writes_only_changed_keys(stc): + f = _files(stc) + kv = _kv(f) + st = {n: build_kanjub_config(kv, n) for n in stage_slots(kv)} + key = next(k for k in st[1]["rates"] if f"1_R_{'T_KITA' if k == 'KITA' else k}" in kv) + st[1]["rates"][key] += 0.5 + st[1]["addRows"].append( + {"group": 4, "name": "시험 추가금액", "unit": "식", "bigo": "", "amts": [1000, 0, 0, 0, 0]} + ) + # 화면 옛 칸(smt_updates)이 원래 값을 보내도 설정 쪽 고침이 이김 + sk = f"1_R_{'T_KITA' if key == 'KITA' else key}" + out, log = apply_save(f, {"kanjub_stages": st, "smt_updates": {sk: kv[sk]}}) + kv2 = _kv(out) + assert float(kv2[sk]) == pytest.approx(float(kv[sk]) + 0.5) + g4 = int(float(kv.get("ADD_CNT_4", 0) or 0)) + 1 + assert kv2["ADD_CNT_4"] == str(g4) and kv2[f"ADD_NAME_4_{g4:02d}"] == "시험 추가금액" + assert kv2[f"ADD_AMT_4_{g4:02d}_1"] == "1000" + changed = {x["field"] for x in log if x["table"] == "PRJLST.SMT"} + assert sk in changed and all(k == sk or k.startswith("ADD_") for k in changed) + assert {k: v for k, v in kv2.items() if k not in changed} == {k: v for k, v in kv.items() if k not in changed} diff --git a/resources/tester/test_stmate_kanjub_boq.py b/resources/tester/test_stmate_kanjub_boq.py new file mode 100644 index 000000000..a5c43df76 --- /dev/null +++ b/resources/tester/test_stmate_kanjub_boq.py @@ -0,0 +1,92 @@ +"""STMATE 재현 앱 내역서 줄 금액 — 비목우선/총액우선(CHNNM · OPTNM) · 비율 줄(PR). + +정답 = 실무 STC 7건 BOQ 저장 줄 금액(M_AMT·L_AMT·S_AMT, 차수별) · 7건 모두 CHNNM=1(비목우선). +비목우선 = 성분별 단가 × 수량 원 미만 절사 · 비율 줄 = 표시(PRPT) 줄 밑수 × 율 ÷ 100 절사. +총액우선 = REA 15 OPTIMIZ(산근·일위대가와 같은 규칙) — 실무 표본 없음 · 손셈 대조. +""" + +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")) +sys.path.insert(0, str(Path(__file__).parent)) +import server # noqa: E402 +from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402 + +HELPER = Path(__file__).with_name("helper_stmate_kanjub_boq.cjs") + + +def _run(state): + out = subprocess.check_output( + ["node", str(HELPER)], + input=json.dumps(state, ensure_ascii=False), + text=True, + encoding="utf-8", + ) + return json.loads(out) + + +def _with_pr(loaded): + """비율 줄 칸(PRPT·PRIN·PROUT·차수별 율) — 불러오기가 아직 안 넘기면 BOQ 원행에서 채움.""" + st, rows = loaded["sim_state"], loaded["boq_rows"] + for it in st["boqItems"]: + r = rows[int(it["id"].split("_")[1]) - 1] + for k in ("PRPT", "PRIN", "PROUT"): + it.setdefault(k, r.get(k)) + it.setdefault("prQtys", [float(r.get(f"QTY{n}") or 0) for n in range(1, 5)]) + return st + + +@pytest.mark.parametrize("name", list(STCS)) +def test_boq_rows_match_stc_bimok(name): + loaded = server.api_load(server.LoadReq(source_path=str(DOCS / STCS[name]))) + assert str(loaded["start_ini"].get("CHNNM", "1")) == "1" + rows = loaded["boq_rows"] + out = _run(_with_pr(loaded)) + bad = [] + for n, items in out.items(): + for rid, code, m, l, s in items: + r = rows[int(rid.split("_")[1]) - 1] + exp = [float(r.get(f"{c}_AMT{n}") or 0) for c in "MLS"] + if [m, l, s] != exp: + bad.append((n, code, [m, l, s], exp)) + assert not bad, f"{name} 줄 금액 불일치 {bad[:5]}" + + +def _one(units, qty, mode, order="SML"): + state = { + "sysConfig": {"chnMode": mode, "optOrder": order}, + "kanjubConfig": {}, + "kanjubStages": {1: {}}, + "resources": [], + "machines": [], + "sanItems": [], + "ilwiItems": [], + "boqItems": [ + { + "id": "BOQ_1", + "code": "B1", + "refId": "B1", + "qty": qty, + "fixedUnit": dict(zip("MLS", units)), + } + ], + } + return _run(state)["1"][0][2:] + + +def test_total_first_optimiz_by_hand(): + # 920·5802·278 × 2.12 = 1950.4 · 12300.24 · 589.36 → 총액 14840 · 성분 절사 합 14839 → 소수부 큰 재료비 +1 + assert _one([920, 5802, 278], 2.12, "bimok") == [1950, 12300, 589] + assert _one([920, 5802, 278], 2.12, "total") == [1951, 12300, 589] + # 1741·13033·3528 × 1.6 = 2785.6 · 20852.8 · 5644.8 → 남는 2원 = 노무·경비 동률 0.8 → OPTNM 순서(S 먼저) + assert _one([1741, 13033, 3528], 1.6, "total") == [2785, 20853, 5645] + assert _one([1741, 13033, 3528], 1.6, "total", "LMS") == [2785, 20853, 5645] + # 동률 셋 — 남는 1원은 순서 앞 성분 + assert _one([1, 1, 1], 0.5, "total", "MLS") == [1, 0, 0] + assert _one([1, 1, 1], 0.5, "total", "LSM") == [0, 1, 0] diff --git a/tools/stmate-test/calc.js b/tools/stmate-test/calc.js index e11598246..93f912304 100644 --- a/tools/stmate-test/calc.js +++ b/tools/stmate-test/calc.js @@ -264,6 +264,46 @@ const StmateCalc = (() => { return { list, map }; } + // 총액우선 끝전 배분(OPTIMIZ) — 성분 절사 · 총액 절사와의 차 1원씩 소수부 큰 성분부터(동률 = 순서 앞) + function optimize(raw, rawT, order) { + const out = { M: roundDown(raw.M, 0), L: roundDown(raw.L, 0), S: roundDown(raw.S, 0) }; + const keys = String(order || "SML") + .toUpperCase() + .split("") + .filter((k) => "MLS".includes(k)); + const frac = Object.fromEntries(keys.map((k) => [k, roundHalfUp(raw[k] - out[k], 6)])); + let rest = roundDown(rawT, 0) - out.M - out.L - out.S; + while (rest > 0) { + let pick = null; + for (const k of keys) if (frac[k] < 1 && (pick === null || frac[k] > frac[pick])) pick = k; + if (pick === null) break; + ((out[pick] += 1), (frac[pick] += 1), (rest -= 1)); + } + return out; + } + + // 내역 비율 줄(PR · 조달수수료 등) — 밑수 비목(PRIN) 누적 × 율(QTY, %) → 대상 비목(PROUT) 원 미만 절사 + // 단가 칸 = 밑수 · 수량 칸 = 율 (원본 BOQ MAMT1 · QTY1 · M_AMT1 저장 꼴) · PRIN 없는 줄은 일반 줄 + const PR_COMP = { 1: "M", 2: "L", 3: "S", 4: "T" }; + function prRow(row, slot, base) { + if (String(row.code || "").trim() !== "PR") return null; + const bk = PR_COMP[String(row.PRIN ?? "").trim()], + ok = PR_COMP[String(row.PROUT ?? "").trim()]; + if (!bk || !ok) return null; + const pct = num((row.prQtys || [])[slot - 1]); + const bv = bk === "T" ? base.M + base.L + base.S : base[bk]; + const to = ok === "T" ? "M" : ok; + const a = { M: 0, L: 0, S: 0 }, + u = { M: 0, L: 0, S: 0 }; + ((a[to] = roundDown((bv * pct) / 100, 0)), (u[to] = bv)); + const T = a.M + a.L + a.S; + const unitInfo = { M: u.M, L: u.L, S: u.S, T: bv, name: row.name, code: "PR", spec: "", unit: "%" }; + const out = { unitInfo, appliedQty: pct, qty: pct, mUnit: u.M, lUnit: u.L, sUnit: u.S, tUnit: bv }; + Object.assign(out, { mAmt: a.M, lAmt: a.L, sAmt: a.S, tAmt: T, M: a.M, L: a.L, S: a.S, T }); + Object.assign(out, { optimizDiff: 0, optimizTarget: "", aggM: a.M, aggL: a.L, aggS: a.S, aggT: T }); + return { M: a.M, L: a.L, S: a.S, T, out }; + } + function calcBoq(data, resMap, machMap, sanMap, ilwiMap, useChangedQty = false) { const slot = typeof useChangedQty === "number" ? useChangedQty : useChangedQty ? 2 : 1; const sys = data.sysConfig || {}, @@ -292,7 +332,17 @@ const StmateCalc = (() => { directS = 0, govRawTotal = 0, govFeeTotal = 0; + // 내역 비율 줄(PR) 밑수 — 표시(PRPT) 줄 금액 누적 · PR 줄을 지나면 비움(영월 조달수수료 4줄 대조) + let prBase = { M: 0, L: 0, S: 0 }; const rows = (data.boqItems || []).map((row) => { + const pr = prRow(row, slot, prBase); + if (pr) { + prBase = { M: 0, L: 0, S: 0 }; + if (row.rowType === "gov_mat") govRawTotal += pr.T; + else if (row.rowType === "gov_fee") govFeeTotal += pr.T; + else ((directM += pr.M), (directL += pr.L), (directS += pr.S)); + return Object.assign({}, row, pr.out); + } const su = slot > 1 && row.units && row.units[slot]; const u = su ? Object.assign({}, lookupUnit(row.refId), { M: su[0], L: su[1], S: su[2] }) @@ -326,25 +376,24 @@ const StmateCalc = (() => { let optimizDiff = 0, optimizTarget = ""; if (chnMode === "total") { - const targetT = roundDown(rawT, 0); - optimizDiff = targetT - (M + L + S); - if (optimizDiff !== 0) { - const fracMap = { S: rawS - S, M: rawM - M, L: rawL - L }; - let bestKey = "S", - bestFrac = -1; - for (const k of optOrder.split("").filter((c) => ["S", "M", "L"].includes(c))) { - if (fracMap[k] > bestFrac) ((bestFrac = fracMap[k]), (bestKey = k)); - } - if (bestKey === "S") S += optimizDiff; - else if (bestKey === "M") M += optimizDiff; - else L += optimizDiff; - T = targetT; - optimizTarget = `${bestKey}(+${optimizDiff})`; + // 총액우선(CHNNM=0) — REA 15 OPTIMIZ: 총액 절사 후 남는 1원을 소수부 큰 성분부터 하나씩 + // (동률은 OPTNM 순서 앞쪽) · 산근·일위대가(smt_eval finish)와 같은 규칙 + const got = optimize({ M: rawM, L: rawL, S: rawS }, rawT, optOrder); + optimizDiff = got.M + got.L + got.S - (M + L + S); + if (optimizDiff) { + optimizTarget = "MLS" + .split("") + .filter((k) => got[k] !== { M, L, S }[k]) + .map((k) => `${k}(+${got[k] - { M, L, S }[k]})`) + .join(""); + ((M = got.M), (L = got.L), (S = got.S), (T = M + L + S)); } } const aggM = roundHalfUp(rawM, 0), aggL = roundHalfUp(rawL, 0), aggS = roundHalfUp(rawS, 0); + if (String(row.PRPT ?? "0").trim() && String(row.PRPT ?? "0").trim() !== "0") + ((prBase.M += M), (prBase.L += L), (prBase.S += S)); if (row.rowType === "gov_mat") govRawTotal += T; else if (row.rowType === "gov_fee") govFeeTotal += T; else ((directM += M), (directL += L), (directS += S)); diff --git a/tools/stmate-test/calc_a2w.js b/tools/stmate-test/calc_a2w.js new file mode 100644 index 000000000..c41d6ec02 --- /dev/null +++ b/tools/stmate-test/calc_a2w.js @@ -0,0 +1,47 @@ +/* -*- coding: utf-8 -*- */ +/** + * 실적단가 → 일식단가 변환등록(wM_Conv_A2W) 비목율 계산 — 화면 없이 숫자만 (calc_a2w.js) + * 근거: REA 27 conversion_component_rate_rules.json(0x4e37dc 한 줄 · 0x4e40f8 입력 정리 · 0x4e3df0 비목율 일괄적용) + * 율은 백분율 · 노무 L · 재료 M · 경비 E = 100 − (L + M) · 반올림 없음 + * 금액 나눔(총액 × 율)은 근거 없음 — 율만 다룸 + */ +const StmateA2W = (() => { + const n = (v) => Number(v) || 0; + + // 입력칸 정리(0x4e40f8) — L·M 각 100 넘으면 100 · 합이 100 넘으면 고친 칸(sender)을 지키고 다른 칸 = 100 − 그 값 + function normalizeEditor(L, M, sender = "L") { + let l = Math.min(n(L), 100), + m = Math.min(n(M), 100); + if (l + m > 100) sender === "M" ? (l = 100 - m) : (m = 100 - l); + return { L: l, M: m, E: 100 - (l + m) }; + } + + // 한 줄 고침(0x4e37dc) — 합이 100 넘으면 재료 칸(10)을 고친 때만 L = 100 − M · 그 밖은 M = 100 − L + function normalizeRow(L, M, editedCol) { + let l = n(L), + m = n(M); + if (l + m > 100) editedCol === 10 ? (l = 100 - m) : (m = 100 - l); + return { L: l, M: m, E: 100 - (l + m) }; + } + + /** + * 비목율 일괄적용(0x4e3df0) — rows: [{ sel, rateL 개별노무비율 }] · editor: { L, M, sender } + * opts.individual = 「노무비율ː개별노무비율 · 나머지ː재료비우선」 표시(전역 조건 1 과 함께일 때만) + * 고른 줄: L = 편집 L(개별이 켜졌고 개별 ≠ 0 이면 개별) · M = L + 편집 M > 100 이면 100 − L · E = 나머지 + * 안 고른 줄: 세 칸 모두 0(옛 값 지키지 않음) + */ + function bulkApply(rows, editor = {}, opts = {}) { + const ed = normalizeEditor(editor.L, editor.M, editor.sender); + return (rows || []).map((r) => { + if (!r.sel) return Object.assign({}, r, { L: 0, M: 0, E: 0 }); + const L = opts.individual && n(r.rateL) !== 0 ? n(r.rateL) : ed.L; + const M = L + ed.M > 100 ? 100 - L : ed.M; + return Object.assign({}, r, { L, M, E: 100 - L - M }); + }); + } + + return { normalizeEditor, normalizeRow, bulkApply }; +})(); + +if (typeof window !== "undefined") window.STMATE_A2W = StmateA2W; +if (typeof module !== "undefined" && module.exports) module.exports = StmateA2W; diff --git a/tools/stmate-test/calc_xrate.js b/tools/stmate-test/calc_xrate.js new file mode 100644 index 000000000..f458611f6 --- /dev/null +++ b/tools/stmate-test/calc_xrate.js @@ -0,0 +1,56 @@ +/* -*- coding: utf-8 -*- */ +/** + * 노무·장비 수량 계산기(wM_Edit_XRate · 손료계수 수정) 계산 — 화면 없이 숫자만 (calc_xrate.js) + * 근거: REA 30 ST_EdiLWi labor_equipment_quantity_rules.json(0x442f97 · 0x442fec · 0x4434b4 · 0x443634) + * 일일수량 = Q11(기준수량 × 조건계수 × 적용율 ÷ 100) + * 적용수량 = 일일수량 0 이면 0 · 아니면 Qp(조수량 × (노무 1 · 장비 8) ÷ 일일수량) + * 자동 자리 = 기준수량 정수부 자릿수 + 1 (2~6 사이로 묶음) · 일일수량이 아니라 기준수량을 봄 + * 조건식 = 빈칸 걷은 뒤 * + - . / 0~9 ^ 만 허용(괄호 없음) · 빈 식 = 1 + * Q_d = DP_Common.MROUNDUD(x, d, 1) — 반올림 방향 미확정(REA 15 · 14) · 여기서는 반올림으로 둠(가정) + */ +const StmateXRate = (() => { + const EV = () => + (typeof window !== "undefined" && window.STMATE_SMT_EVAL) || + (typeof require !== "undefined" ? require("./smt_eval.js") : null); + + // 반올림(가정) — 이진 오차를 십진 자리로 맞춤 + function q(v, d) { + const f = 10 ** d, + r = Math.floor(Math.abs(Number(v) || 0) * f + 0.5 + 1e-9) / f; + return v < 0 ? -r : r; + } + + // 조건계수 — 허용 글자만 · 괄호 금지 · 계산은 식 계산기(CALC_STR_URC 대응) + function condFactor(text) { + const s = String(text ?? "").replace(/\s+/g, ""); + if (!s) return { ok: true, value: 1 }; + if (!/^[0-9.+\-*/^]+$/.test(s)) return { ok: false, value: 1, error: "조건식에 쓸 수 없는 글자" }; + const v = Number(EV().evalExpr(s, {})); + return Number.isFinite(v) ? { ok: true, value: v } : { ok: false, value: 1, error: "조건식 오류" }; + } + + // 자동 자리 — 기준수량 정수부 자릿수 + 1, 2~6 + function autoPoint(qty1) { + const n = String(Math.trunc(Math.abs(Number(qty1) || 0))).length; + return Math.min(6, Math.max(2, n + 1)); + } + + /** + * inp: { qty1 기준수량, cond 조건식, hrat 적용율(%), lqty 조수량, labor 참=노무, point 자리, auto 자동자리 } + * 돌려줌: { ok, cond, qday 일일수량, point, cqty 적용수량 } + */ + function compute(inp = {}) { + const c = condFactor(inp.cond); + const qty1 = Number(inp.qty1) || 0; + const qday = q(qty1 * c.value * (Number(inp.hrat) || 0) / 100, 11); + const point = inp.auto ? autoPoint(qty1) : Math.round(Number(inp.point) || 0); + const per = inp.labor ? 1 : 8; + const cqty = qday === 0 ? 0 : q(((Number(inp.lqty) || 0) * per) / qday, point); + return { ok: c.ok, error: c.error, cond: c.value, qday, point, cqty }; + } + + return { condFactor, autoPoint, compute, round: q }; +})(); + +if (typeof window !== "undefined") window.STMATE_XRATE = StmateXRate; +if (typeof module !== "undefined" && module.exports) module.exports = StmateXRate; diff --git a/tools/stmate-test/conv_xls_boq.py b/tools/stmate-test/conv_xls_boq.py new file mode 100644 index 000000000..44d2242a9 --- /dev/null +++ b/tools/stmate-test/conv_xls_boq.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +"""엑셀 → 내역 불러오기 (wM_From_XLS · CONV_XLS_2_ST) — STMATE 가 내보낸 xlsx 의 「〓 INITIAL 〓」 메타로 내역 행 복원. + +근거: XLSX_INITIAL_구조.md · 실무문서/_숨김탭분석.md §E · 24_모듈구조_전수.md §3(CONV_XLS_2_ST · XLS_2_BDREC) · + 16_골든셋_회귀_결과.md §4(SORTCODE↔ENDCODE 짝 · C* 제외 구역) · 34번 wKongJong(소계·계·합계·총계·총합계·총대계) +INITIAL 시트(veryHidden): + [Sheet정보] 목차줄마다 시트 이름 · 열 번호(ITNAME NAME SIZE QTY UNIT SPOS EPOS TAMT TAMOUNT MAMT MAMOUNT + LAMT LAMOUNT SAMT SAMOUNT AAMT AAMOUNT NAMT NAMOUNT INFO) — 1부터 셈 + [공사정보] [초기값] [인쇄설정] [PREPARE] — 키 · 값(여러 칸) +내역 시트 INFO 칸(숨은 열) = 0x07 `키|값 0x05 … — COD(코드) · QTYn|n(차수 줄) · EXI · ITT · END(그 묶음이 끝나는 엑셀 줄) · + IPR · BLA · KWN · EQC · PRI · PRO · UNI · RCU · PSKP(이 줄 뒤 페이지 넘김) +복원 규칙(실무 4건 STC BOQ 와 대조 · resources/tester/test_stmate_conv_xls.py): + - 공종 머리 줄 COD = 닫는 코드(Ek · Ck) → 여는 행 Tk + 닫는 행 Ek/Ck(END 줄 앞에서 안쪽부터 닫음) + - 닫는 행 이름: Ek = 공종번호 칸에 단계 이름 · Ck = 명칭 칸에 「단계 이름(제외금액)」 + - 여는 행·닫는 행 금액 = 머리 줄 금액(묶음 합) · SORTCODE = 256 × 차례 · 짝끼리 ENDCODE = 상대 SORTCODE + - PSKP 줄 뒤(그 자리 닫는 행 다음) ':' 페이지 넘김 행 · COD 빈 줄 = 빈 행 + - 차수 줄(QTYn|n, n ≥ 2)은 바로 앞 행의 n차 칸 +""" + +from __future__ import annotations + +import io +import re +import warnings + +LEVEL_NAME = {1: "소계", 2: "계", 3: "합계", 4: "총계", 5: "총합계", 6: "총대계"} +COLS = ( + "ITNAME NAME SIZE QTY UNIT SPOS EPOS TAMT TAMOUNT MAMT MAMOUNT " + "LAMT LAMOUNT SAMT SAMOUNT AAMT AAMOUNT NAMT NAMOUNT INFO" +).split() +# INFO 키 → BOQ 칸 +INFO_FIELD = { + "EQC": "EQCODE", + "PRI": "PRIN", + "PRO": "PROUT", + "UNI": "UNIT1", + "RCU": "RCU", + "KWN": "KWNGP", + "BLA": "BL_AMT", +} +INITIAL = "INITIAL" + + +def load_book(src): + """경로 또는 바이트 → openpyxl 통합문서(계산된 값).""" + import openpyxl + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if isinstance(src, (bytes, bytearray)): + return openpyxl.load_workbook(io.BytesIO(src), data_only=True) + return openpyxl.load_workbook(src, data_only=True) + + +def _unx(s: str) -> str: + return re.sub(r"_x([0-9A-Fa-f]{4})_", lambda m: chr(int(m.group(1), 16)), s) + + +def parse_info(s) -> dict[str, str]: + """숨은 INFO 글 → {키: 값} (같은 키가 두 번이면 앞의 것).""" + out: dict[str, str] = {} + if not isinstance(s, str): + return out + for k, v in re.findall(r"`(\w+)\|([^\x05\x07`]*)", _unx(s)): + out.setdefault(k, v) + return out + + +def initial_sheet(wb): + return next((w for w in wb.worksheets if INITIAL in w.title), None) + + +def read_initial(wb) -> dict: + """INITIAL 시트 → {sheets: {목차: {sheet, cols}}, sections: {이름: {키: [값…]}}}.""" + ws = initial_sheet(wb) + if ws is None: + raise ValueError("〓 INITIAL 〓 시트 없음 — STMATE 가 내보낸 엑셀이 아님") + sheets, sections, sec = {}, {}, "" + for r in ws.iter_rows(values_only=True): + if len(r) < 2 or r[1] is None: + continue + key = str(r[1]).strip() + if key.startswith("[") and key.endswith("]"): + sec = key[1:-1] + continue + if sec == "Sheet정보": + if key == "목차" or not r[2]: + continue + vals = list(r[4:24]) + [None] * 20 + cols = {c: int(v) for c, v in zip(COLS, vals) if isinstance(v, (int, float))} + sheets[key] = {"sheet": str(r[2]), "cols": cols} + elif sec: + sections.setdefault(sec, {})[key] = [v for v in r[2:] if v is not None] + return {"sheets": sheets, "sections": sections} + + +def find_sheet(info: dict, kind: str) -> dict | None: + """목차 이름 끝말(예 '내역서' · '일위대가표')로 시트 찾기 — '11. 내역서' ↔ '12. 총괄내역서' 가름.""" + for k, v in info["sheets"].items(): + if re.sub(r"^\d+\.\s*", "", k).strip() == kind: + return v + return None + + +def _cell(row: tuple, col: int | None): + if not col or col - 1 >= len(row): + return None + return row[col - 1] + + +def _txt(v) -> str: + return "" if v is None else str(v).rstrip() + + +def _numv(v) -> float: + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + +def _amounts(row: tuple, cols: dict, n: int) -> dict: + """한 줄의 단가·금액 → n차 칸.""" + out = {} + for c in "TMLS": + out[f"{c}AMT{n}"] = _numv(_cell(row, cols.get(f"{c}AMT"))) + out[f"{c}_AMT{n}"] = _numv(_cell(row, cols.get(f"{c}AMOUNT"))) + return out + + +LABELS = {"공종": "ITNAME", "명칭": "NAME", "규격": "SIZE", "수량": "QTY", "단위": "UNIT"} +BIMOK = {"합계": "T", "노무비": "L", "재료비": "M", "경비": "S"} + + +def label_cols(ws, cols: dict) -> dict: + """머리 줄 글(공종·명칭·…·합계/노무비/재료비/경비 + 단가/금액)로 열 번호를 다시 잡음. + 변경 내역서는 INITIAL 열 번호가 시트와 어긋남(영덕 변경 실측) — 글이 다 있으면 글 쪽을 씀.""" + spos = cols.get("SPOS", 1) + found: dict = {} + for r in ws.iter_rows(min_row=max(1, spos - 3), max_row=max(1, spos - 1), values_only=True): + for j, v in enumerate(r, start=1): + t = re.sub(r"\s+", "", str(v or "")) + if t in LABELS: + found.setdefault(LABELS[t], j) + elif t in BIMOK: + found.setdefault(f"{BIMOK[t]}AMT", j) + found.setdefault(f"{BIMOK[t]}AMOUNT", j + 1) + if all(f"{c}AMT" in found for c in "TLMS") and "NAME" in found: + return {**cols, **found} + return cols + + +def boq_rows_from_sheet(ws, cols: dict) -> list[dict]: + """내역 시트 → BOQ 행 목록(DBF 칸 이름 · 숫자는 float · 글은 str).""" + cols = label_cols(ws, cols) + spos, epos = cols.get("SPOS", 1), cols.get("EPOS", ws.max_row) + out: list[dict] = [] + stack: list[dict] = [] # 열린 묶음 {code, end, head} + pskp = False + last = None + + def close_until(line: int): + while stack and stack[-1]["end"] <= line: + g = stack.pop() + lev = int(g["code"][1:]) if g["code"][1:].isdigit() else 0 + name = LEVEL_NAME.get(lev, "") + row = {"BDCODE1": g["code"], "EXIST1": 1 if g["code"][0] == "C" else 0} + row.update({k: v for k, v in g["head"].items() if "AMT" in k}) + if g["code"][0] == "C": + row.update(ITNUM="", NAME1=f"{name}(제외금액)") + else: + row.update(ITNUM=name, NAME1="") + row["_pair"] = g["open"] + out[g["open"]]["_pair"] = len(out) + out.append(row) + + for line, row in enumerate( + ws.iter_rows(min_row=spos, max_row=epos, values_only=True), start=spos + ): + close_until(line) + if pskp: + out.append({"BDCODE1": ":", "NAME1": "페이지 넘김", "EXIST1": 0}) + pskp = False + meta = parse_info(_cell(row, cols.get("INFO"))) + if "COD" not in meta: + continue + slot = next((int(v) for k, v in meta.items() if re.fullmatch(r"QTY\d", k)), 1) + if slot >= 2 and last is not None: # 차수 줄 — 앞 행의 n차 칸 + last[f"QTY{slot}"] = _numv(_cell(row, cols.get("QTY"))) + last.update(_amounts(row, cols, slot)) + continue + code = meta["COD"].strip() + base = { + "ITNUM": _txt(_cell(row, cols.get("ITNAME"))), + "NAME1": _txt(_cell(row, cols.get("NAME"))), + "SIZE1": _txt(_cell(row, cols.get("SIZE"))), + "UNIT1": _txt(_cell(row, cols.get("UNIT"))).strip(), + "QTY1": _numv(_cell(row, cols.get("QTY"))), + } + base.update(_amounts(row, cols, 1)) + for k, f in INFO_FIELD.items(): + if k in meta: + base[f] = meta[k] + if "END" in meta and re.fullmatch(r"[EC]\d", code): # 공종 머리 + base.update(BDCODE1="T" + code[1:], EXIST1=1, QTY1=0.0) + stack.append({"code": code, "end": int(meta["END"]), "head": base, "open": len(out)}) + out.append(base) + elif not code: # 빈 줄(설명 줄) + base.update(BDCODE1="", EXIST1=1) + out.append(base) + else: + base.update(BDCODE1=code, EXIST1=int(_numv(meta.get("EXI"))) if code == "PR" else 0) + out.append(base) + last = out[-1] + pskp = meta.get("PSKP") == "1" + close_until(10**9) + for i, r in enumerate(out): + r["SORTCODE"] = 256.0 * (i + 1) + for r in out: + r["ENDCODE"] = out[r["_pair"]]["SORTCODE"] if "_pair" in r else 0.0 + r.pop("_pair", None) + return out + + +def fill_from_costn(rows: list[dict], costn_rows: list[dict]) -> int: + """코드 행의 BQCODE · EQCODE · 단위 = 그 코드의 COSTN 값(실무 6표 전 행이 같음) — 채운 행 수. + 엑셀 단위 칸은 'M%' 를 '%' 로 보이므로 COSTN 단위가 원래 값.""" + cm = {str(r.get("CODE", "")).strip(): r for r in costn_rows or []} + n = 0 + for r in rows: + c = cm.get(str(r.get("BDCODE1", "")).strip()) + if not c: + continue + for f, g in (("BQCODE", "BQCODE"), ("EQCODE", "EQCODE"), ("UNIT1", "UNIT")): + if g in c: + r[f] = str(c[g]).strip() + n += 1 + return n + + +def boq_from_xlsx(src, kind: str = "내역서") -> dict: + """xlsx → {rows, sheet, info} — kind = 목차 이름(내역서 · 총괄내역서 …).""" + wb = load_book(src) + info = read_initial(wb) + spec = find_sheet(info, kind) + if not spec or spec["sheet"] not in wb.sheetnames: + raise ValueError(f"INITIAL 목차에 '{kind}' 시트 없음") + rows = boq_rows_from_sheet(wb[spec["sheet"]], spec["cols"]) + return {"rows": rows, "sheet": spec["sheet"], "info": info} diff --git a/tools/stmate-test/report_book.js b/tools/stmate-test/report_book.js index 85f2667ec..066feeb32 100644 --- a/tools/stmate-test/report_book.js +++ b/tools/stmate-test/report_book.js @@ -50,7 +50,7 @@ const StmateReportBook = (() => { cells.O = p.M + p.L + p.S; const s = num(r.PG_1); if (s >= 1 && s <= 5) cells.P = str(ini[`JMK${s}0`] || "").trim(); - const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim(); + const rm = U.remarkOf(po, c, lbl, r); if (rm) cells.Q = rm; C.push(sh, cells); }); diff --git a/tools/stmate-test/report_boq.js b/tools/stmate-test/report_boq.js index 9bb68b015..1ca0c3a5d 100644 --- a/tools/stmate-test/report_boq.js +++ b/tools/stmate-test/report_boq.js @@ -105,7 +105,7 @@ const StmateReportBoq = (() => { } // 품목 비고 — 코드 모드(SH_TYP1=0) 코드 · 호표 모드 = 대가·산근·중기는 「산근 n호표」 · 자원은 가격지 위치(BQCODE) - // (SH_TYP3=1 이면 자원도 「자재 n」) · 호표 번호 = 종류별 코드순 자리 + // (SH_TYP3=1 이면 자원도 「자재 n」 · 0 이면 자원은 코드) · 호표 번호 = 종류별 코드순 자리 function itemRemark(dbf, idx, b) { const ini = (dbf && dbf.start_ini) || {}; if (b === "PR") return ""; @@ -116,6 +116,7 @@ const StmateReportBoq = (() => { const no = idx.ofPrefix(b[0]).findIndex((x) => C.code(x) === b) + 1; if ("BDX".includes(b[0])) return `${kind}${String(no).padStart(4)}호표`; if (String(ini.SH_TYP3 ?? "2") === "1") return `${kind}${String(no).padStart(5)}`; + if (String(ini.SH_TYP3 ?? "2") === "0") return b; return str(r.BQCODE).trim(); } diff --git a/tools/stmate-test/report_coll.js b/tools/stmate-test/report_coll.js new file mode 100644 index 000000000..8ebad785e --- /dev/null +++ b/tools/stmate-test/report_coll.js @@ -0,0 +1,246 @@ +/* -*- coding: utf-8 -*- */ +/** + * 수량금액 집계 (report_coll.js) — 투입 수량 전개 · 수량금액집계표(0501~0508) · 일식·표준시장 목록표(0104 · 0106) + * 근거: 지식DB STmate/old/20_분석 06 §3 · 18 §2(집계표 금액 = 사사오입) · 20 §3(SET_REP) · old/30_원자료/print_form_map.txt + * 전개 = 내역 품목 수량 → 일위대가(B) 구성 수량 곱 → 산근(D) 식 줄 계수(참조 단가 1 로 셈) · 중기(X)는 시간에서 멈춤 + * 산근 안 같은 코드의 비목 줄(노·재·경)은 한 벌 — 비목별 계수 합의 최대 · (-=)·(-==)·「->」 제외 구간은 뺌 + * 정답 대조: resources/tester/test_stmate_report_coll.py (원본 STMATE 출력 엑셀) + */ +const StmateReportColl = (() => { + const g = typeof globalThis !== "undefined" ? globalThis : {}; + const C = g.STMATE_REPORT_CORE || require("./report_core.js"); + const U = g.STMATE_REPORT_UNIT || require("./report_unit.js"); + const { num, str, code, half } = C; + const CODE_RE = /^([A-Z])(\d{1,5})$/; + const padCode = (t) => { + const m = String(t).match(CODE_RE); + return m ? m[1] + m[2].padStart(5, "0") : t; + }; + + /** + * 산근 줄 계수 — 참조 단가를 1 로 두고 같은 식을 셈(줄 끝수 자르기 없음) + * 돌려줌: { 코드: {M,L,S} } 비목별 계수 합 · 제외 구간 줄은 빠짐 + */ + function sanFactors(dbf, d, memoText) { + const SE = g.STMATE_SMT_EVAL; + const EN = g.STMATE_ENGINE; + const ctx = { dbfData: dbf, state: {}, uiState: {} }; + const { lookup } = EN.costnLookup(ctx); + const one = (k) => { + const r = lookup(k); + if (!r) return null; + // 자원 = 제 비목 칸에만 1(「&L00006L&」 같은 비목 지정 참조도 1) · 그 밖 = 세 비목 모두 1 + const own = k[0] === "L" ? "L" : k[0] === "S" ? "S" : "M"; + const unit = /^[MLSW]/.test(k) ? { M: 0, L: 0, S: 0, [own]: 1 } : { M: 1, L: 1, S: 1 }; + return Object.assign({}, r, unit); + }; + const opts = Object.assign(EN.smtOptions(ctx, d), { lineDigits: 14, adj: null, sbmok: "" }); + opts.totalFirst = false; + const ev = SE.evaluate(memoText, one, opts); + const amountLines = []; + let pending = []; + for (const ln of ev.lines) { + if (ln.kind === "amount") { + const ok = !ln.excluded; + const item = { ln, ok }; + amountLines.push(item); + pending.push(item); + } else if (ln.kind === "section") pending = []; + else if (ln.kind === "exclude") { + // (-=) 지금 구간 · (-==) 이전 전부 + const hit = ln.level === 1 ? pending : amountLines; + for (const it of hit) it.ok = false; + pending = []; + } + } + const out = {}; + for (const { ln, ok } of amountLines) { + if (!ok) continue; + const m = String(ln.ref).match(/^([A-Z]\d{1,5})([LMS])?$/); + if (!m) continue; + const c = padCode(m[1]); + const r = lookup(c); + if (!r || /%$/.test(String(r.unit || "").trim())) continue; + const f = (out[c] = out[c] || { M: 0, L: 0, S: 0 }); + for (const k of "MLS") f[k] += half(ln.amounts[k], 11); // 계수 = 소수 11자리(BDQTY 저장 자리) + } + return out; + } + + /** + * 투입 수량 전개 — 차수 내역 품목(비율 줄 빼고) 전부 + * 돌려줌: { qty:{코드: 수량}, by:{코드: 품목 묶음 → 수량} } (by = 공종별 집계표 0675~0677 용) + */ + function explode(dbf, items, opts = {}) { + const memos = (dbf && dbf.costn_memos) || {}; + const edited = opts.editedSmt || {}; + const idx = C.costnIndex(dbf); + const groups = {}; + for (const r of (dbf && dbf.bdqty_rows) || []) + if (/^[BX]/.test(String(r.BDCODE || ""))) (groups[r.BDCODE] = groups[r.BDCODE] || []).push(r); + const fcache = {}; + const children = (k) => { + if (fcache[k]) return fcache[k]; + const out = []; + if (k[0] === "B" && groups[k]) { + for (const r of groups[k]) { + const c = String(r.CODE || "").trim(); + const ref = idx.by[c]; + if (!ref || /%$/.test(str(ref.UNIT).trim())) continue; + out.push([c, num(r.QTY1)]); + } + } else if (k[0] === "D") { + const text = edited[k] || (memos[k] && memos[k].text) || ""; + if (text.trim()) { + const f = sanFactors(dbf, k, text); + for (const [c, v] of Object.entries(f)) + out.push([c, /^[MLSW]/.test(c) ? v.M + v.L + v.S : Math.max(v.M, v.L, v.S)]); + } + } + return (fcache[k] = out); + }; + const qty = {}; + const by = {}; + const walk = (k, q, bag, depth) => { + qty[k] = (qty[k] || 0) + q; + if (bag) bag[k] = (bag[k] || 0) + q; + if (depth > 30 || !q) return; + for (const [c, f] of children(k)) walk(c, q * f, bag, depth + 1); + }; + for (const it of items) { + if (!it.code || it.pct || !idx.by[it.code]) continue; + const bag = it.group != null ? (by[it.group] = by[it.group] || {}) : null; + walk(it.code, it.qty, bag, 0); + } + return { qty, by }; + } + + // 차수 내역 품목 — 설계내역서 나무(report_boq boqTree) 품목 줄 · 제외금액 구역(C 로 닫힌 묶음 · 관급 등) 안은 뺌 + const inExcluded = (n) => { + for (let p = n.parent; p; p = p.parent) if (p.excluded) return true; + return false; + }; + function stageItems(tree) { + return tree.out + .filter((n) => n.kind === "item" && !inExcluded(n)) + .map((n) => ({ code: n.code, qty: n.qty, pct: n.pct, node: n })); + } + + // 수량 끝수 — 소수 둘째 자리 사사오입(원본 집계표 수량 칸) + const q2 = (v) => half(v, 2); + + // 호표 글 — 대가·산근(B·D)은 네 자리 맞춤 · 그 밖은 자리 맞춤(CH_PRN_COD=1)일 때만 + const hopyoText = (po, prefix, n) => + po.padNo || "BD".includes(prefix) ? String(n).padStart(4) : String(n); + + /** + * 수량금액집계표 한 장 — 쓰인 코드만(코드순) · 금액 = 수량(끝수 맞춘 값) × 단가 사사오입 + * 자원(0501~0504 · 0508) = 단가·금액 한 칸 / 비목(0505 중기 · 0506 대가 · 0507 산근) = 비목 4칸(BMKNM 차례) + * 호표 번호 = 자리 맞춤이면 코드 호표 번호 · 그 밖 = 쓰인 코드 차례 + * exs 둘(당초·변경)이면 변경 양식 — 코드마다 당초 줄 + 변경 줄 · changeOnly 면 수량이 바뀐 코드만 + */ + function collSheet(u, exs, name, title, prefix, opts = {}) { + const { dbf, idx, ini, order, po, hopyoNo } = u; + const bimok = "XBD".includes(prefix); + const sh = C.newSheet(name, title, C.projectName(dbf), bimok ? "K" : "I"); + const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" }; + Object.assign(head, { D: "수 량", E: "단위", _k: "head" }); + const cols = ["F", "G", "H", "I"]; + if (bimok) { + head.J = "비 고"; + order.forEach((k, i) => (head[cols[i]] = C.bimokLabel(ini, k))); + } else Object.assign(head, { F: "단 가", G: "금 액", H: "비 고" }); + C.push(sh, head); + const money = (q, p) => { + if (!bimok) { + const unit = C.fix(p.M + p.L + p.S); + return { F: unit, G: half(q * unit) }; + } + const v = { M: half(q * p.M), L: half(q * p.L), S: half(q * p.S) }; + v.T = v.M + v.L + v.S; + const out = {}; + order.forEach((k, i) => (out[cols[i]] = v[k])); + return out; + }; + let seq = 0; + for (const r of idx.ofPrefix(prefix)) { + const c = code(r); + if (!exs.some((e) => e.qty[c])) continue; + seq += 1; + const qs = exs.map((e) => q2(e.qty[c] || 0)); + if (opts.changeOnly && qs.length > 1 && qs[0] === qs[1]) continue; + const p = idx.price(c, 1); // 1칸 = 낙찰률 적용 단가(내역 단가와 같음) + const lbl = po.codeMode ? c : hopyoText(po, prefix, po.padNo ? hopyoNo[c] : seq); + const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: qs[0], E: C.unitText(r.UNIT) }; + Object.assign(cells, money(qs[0], p), { _k: "item", _code: c }); + const rm = U.remarkOf(po, c, lbl, r); + if (rm) cells[bimok ? "J" : "H"] = rm; + C.push(sh, cells); + if (qs.length > 1) C.push(sh, Object.assign({ D: qs[1], _k: "item2", _code: c }, money(qs[1], p))); + } + return sh; + } + + /** + * 일식·견적(W · 0104) · 표준시장(P · 0106) 목록표 — 비목 4칸 단가 + */ + function wListSheet(u, name, title, prefix) { + const { dbf, idx, ini, order, po, hopyoNo } = u; + const sh = C.newSheet(name, title, C.projectName(dbf), "J"); + const cols = ["E", "F", "G", "H"]; + const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" }; + Object.assign(head, { D: "단위", I: "비 고", _k: "head" }); + order.forEach((k, i) => (head[cols[i]] = C.bimokLabel(ini, k))); + C.push(sh, head); + let seq = 0; + for (const r of idx.ofPrefix(prefix)) { + const c = code(r); + seq += 1; + const p = idx.price(c, 1); + const v = { M: p.M, L: p.L, S: p.S, T: p.M + p.L + p.S }; + const lbl = po.codeMode ? c : hopyoText(po, prefix, po.padNo ? hopyoNo[c] : seq); + const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) }; + Object.assign(cells, { _k: "item", _code: c }); + order.forEach((k, i) => (cells[cols[i]] = v[k])); + const rm = U.remarkOf(po, c, lbl, r); + if (rm) cells.I = rm; + C.push(sh, cells); + } + return sh; + } + + // 집계표 차례 — [시트 id, 제목, 코드 종류, SET_REP 서식] · W·P 는 코드가 있을 때만 + const COLL = [ + ["재료비수량금액집계표", "재료비 수량,금액 집계표", "M", "0501"], + ["노무비수량금액집계표", "노무비 수량,금액 집계표", "L", "0502"], + ["경비수량금액집계표", "경 비 수량,금액 집계표", "S", "0503"], + ["일식견적수량금액집계표", "일식/견적 수량,금액 집계표", "W", "0504"], + ["표준시장수량금액집계표", "표준시장/시장시공 수량,금액 집계표", "P", "0508"], + ["중기시간금액집계표", "중기 시간,금액 집계표", "X", "0505"], + ["일위대가수량금액집계표", "일위대가 수량,금액 집계표", "B", "0506"], + ["단가산출근거수량금액집계표", "단가산출근거 수량,금액 집계표", "D", "0507"], + ]; + + /** + * 집계 묶음 — u = report_unit unitContext · tree = 차수 설계내역 나무 + * opts.baseTree(당초 차수 나무)를 주면 변경 양식(당초·변경 두 줄) · opts.changeOnly = 바뀐 코드만 + */ + function buildCollSheets(u, tree, opts = {}) { + const ex = explode(u.dbf, stageItems(tree), opts); + const exs = opts.baseTree ? [explode(u.dbf, stageItems(opts.baseTree), opts), ex] : [ex]; + const F = (sh, indx) => C.applyForm(sh, u.dbf, indx); + const has = (p) => u.idx.ofPrefix(p).length > 0; + const out = { _explode: ex }; + if (has("W")) out.일식견적목록표 = F(wListSheet(u, "일식견적목록표", "일식/견적 목록표", "W"), "0104"); + if (has("P")) + out.표준시장목록표 = F(wListSheet(u, "표준시장목록표", "표준시장/시장시공 목록표", "P"), "0106"); + for (const [id, title, p, indx] of COLL) + if ("WP".includes(p) ? has(p) : true) out[id] = F(collSheet(u, exs, id, title, p, opts), indx); + return out; + } + + return { buildCollSheets, explode, sanFactors, stageItems, padCode }; +})(); + +if (typeof window !== "undefined") window.STMATE_REPORT_COLL = StmateReportColl; +if (typeof module !== "undefined" && module.exports) module.exports = StmateReportColl; diff --git a/tools/stmate-test/report_core.js b/tools/stmate-test/report_core.js index e63edabad..4c56a35a3 100644 --- a/tools/stmate-test/report_core.js +++ b/tools/stmate-test/report_core.js @@ -234,7 +234,7 @@ const StmateReportCore = (() => { const r = ((dbf && dbf.set_rep_rows) || []).find((x) => String(x.INDX).trim() === indx); const title = r ? String(r.HEADTITLE || "").trim() : ""; if (!sh || !title) return sh; - const name = title.replace(/\s+/g, ""); + const name = title.replace(/[\s,/]+/g, ""); // 시트 이름 = 제목에서 빈칸·쉼표·빗금 뺌 sh.title = title; sh.indx = indx; if (sh.rows[0] && sh.rows[0]._k === "title") sh.rows[0].A = title; diff --git a/tools/stmate-test/report_unit.js b/tools/stmate-test/report_unit.js index 26795df0f..ef148aa14 100644 --- a/tools/stmate-test/report_unit.js +++ b/tools/stmate-test/report_unit.js @@ -54,7 +54,7 @@ const StmateReportUnit = (() => { /** * 출력 설정 — Start8x.Ini - * SH_TYP1 = 0 이면 첫 칸 코드 · 그 밖 호표 번호 / SH_TYP3 = 1 이면 비고에 호표 · 그 밖 가격지 위치(BQCODE) + * SH_TYP1 = 0 이면 첫 칸 코드 · 그 밖 호표 번호 / SH_TYP3 = 0 이면 비고에 코드 · 1 이면 호표 · 그 밖 가격지 위치(BQCODE) * CH_PRN_COD = 1 이면 번호를 자리 맞춤(중기 「제 n호표」 · 자원 「 n」) * 줄 비고 종류 이름 = COD_FMT_1~6(노무·자재·경비·대가·중기·산근 · 지식DB 20 §2.4) */ @@ -64,11 +64,15 @@ const StmateReportUnit = (() => { return { codeMode: String(ini.SH_TYP1 ?? "0") === "0", remarkHopyo: String(ini.SH_TYP3 ?? "2") === "1", + remarkCode: String(ini.SH_TYP3 ?? "2") === "0", padNo: String(ini.CH_PRN_COD ?? "0") === "1", kind, }; } const pad = (n, w) => String(n).padStart(w); + // 목록 비고 — 코드(SH_TYP3=0) · 호표 글(SH_TYP3=1 · 호표 모드) · 가격지 위치(BQCODE) + const remarkOf = (po, c, lbl, r) => + po.remarkCode ? c : po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim(); // 호표 비고 글 — 대가·산근 「대가 n호표」 · 그 밖 「노무 n」 const kindNo = (po, p, n) => p === "B" || p === "D" ? `${po.kind[p]}${pad(n, 4)}호표` : `${po.kind[p] || ""}${pad(n, 5)}`; @@ -95,7 +99,7 @@ const StmateReportUnit = (() => { Object.assign(cells, { _k: "item", _code: c }); const v = { M: tot.M, L: tot.L, S: tot.S, T: tot.M + tot.L + tot.S }; order.forEach((k, j) => (cells[LIST_COLS[j]] = v[k])); - const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim(); + const rm = remarkOf(po, c, lbl, r); if (rm) cells.I = rm; C.push(sh, cells); }); @@ -114,7 +118,7 @@ const StmateReportUnit = (() => { const lbl = po.codeMode ? c : hopyoLabel(po, prefix, i + 1); const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) }; Object.assign(cells, { E: fix(p.M + p.L + p.S), _k: "item", _code: c }); - const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim(); + const rm = remarkOf(po, c, lbl, r); if (rm) cells.F = rm; C.push(sh, cells); }); @@ -125,7 +129,7 @@ const StmateReportUnit = (() => { function lineRemark(ctx, ref, c, pct) { const { po, hopyoNo } = ctx; const zero = !pct && ref && ref.M + ref.L + ref.S === 0 ? "별산" : ""; - if (po.codeMode) return `${zero}${c} `; + if (po.codeMode || po.remarkCode) return `${zero}${c} `; if (po.remarkHopyo) return `${zero}${kindNo(po, c[0], hopyoNo[c] || "")} `; return ref && ref.row ? str(ref.row.BQCODE).trim() : ""; } @@ -162,9 +166,10 @@ const StmateReportUnit = (() => { const wide = isB || po.padNo; C.push(sh, { A: wide ? ` 제${pad(no, 5)} 호표` : `제 ${no} 호표`, _k: "hopyo" }); const hd = { A: str(r.NAME), B: str(r.SIZE), D: C.unitText(r.UNIT), _k: "parent", _code: c }; - const rm = po.codeMode - ? c - : po.remarkHopyo + const rm = + po.codeMode || po.remarkCode + ? c + : po.remarkHopyo ? `${po.kind[prefix]}${pad(no, 5)}` : str(r.BQCODE).trim(); if (rm) hd.M = rm; @@ -176,7 +181,7 @@ const StmateReportUnit = (() => { C.push(sh, { C: 0, _k: "text", - M: po.remarkHopyo && !po.codeMode ? " " : undefined, + M: (po.remarkHopyo || po.remarkCode) && !po.codeMode ? " " : undefined, }); continue; } @@ -315,7 +320,7 @@ const StmateReportUnit = (() => { }; } - return { buildUnitSheets, unitContext, compOptions, LIST_COLS, PAIR_COLS }; + return { buildUnitSheets, unitContext, compOptions, remarkOf, LIST_COLS, PAIR_COLS }; })(); if (typeof window !== "undefined") window.STMATE_REPORT_UNIT = StmateReportUnit; diff --git a/tools/stmate-test/server.py b/tools/stmate-test/server.py index 254272606..9cf406ee4 100644 --- a/tools/stmate-test/server.py +++ b/tools/stmate-test/server.py @@ -389,8 +389,12 @@ class SaveDbfReq(BaseModel): smt_updates: dict = {} costn_memo_updates: dict[str, str] = {} boq_table: str = "" + boq_deleted: list[int] = [] start_ini: dict = {} prjlst_rows: list = [] + kanjub_stages: dict = {} # 제잡비 차수별 설정 → PRJLST.SMT(바뀐 키만) + bdqty_rows: list[dict] | None = None # 구성표 행 전체(decode_bdqty 꼴) → BDQTY.DBF(바뀐 호표만) + qcode_rows: list[dict] | None = None # 수량코드 행 전체 → QCODE.DBF·SMT(바뀌었을 때만) @app.get("/api/sources") diff --git a/tools/stmate-test/smt_engine.js b/tools/stmate-test/smt_engine.js index 0029eba55..1da32d9d7 100644 --- a/tools/stmate-test/smt_engine.js +++ b/tools/stmate-test/smt_engine.js @@ -57,7 +57,21 @@ }; } - // 산근 계산 설정 — Start8x.Ini(CHGNM · OPTNM · ZCDNM) · COSTN(SBMOK · S_ADJ) + // 소수 자리 설정 — Start8x.Ini 0·1·2 자리(22 §3.4) · 비거나 이상하면 1(실무 7건 값) + const digitOf = (v, def = 1) => (/^[0-2]$/.test(String(v ?? "").trim()) ? Number(v) : def); + + // 외산장비 환율 — 단가의 조사시점(ARENM 1 당초 · 2 변경)의 EXLNM·EXHNM·EXEUR·EXYEN(REA 14 cmBx_ARE) + function fxOf(ini) { + const n = String(ini.ARENM || "1").trim() === "2" ? 2 : 1; + return { + usd: num(ini[`EXLNM${n}`]), + usdHigh: num(ini[`EXHNM${n}`]), + eur: num(ini[`EXEUR${n}`]), + yen: num(ini[`EXYEN${n}`]), + }; + } + + // 산근 계산 설정 — Start8x.Ini(CHGNM · OPTNM · ZCDNM · ACDNM) · COSTN(SBMOK · S_ADJ) function smtOptions(ctx, code) { const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {}; const row = costnLookup(ctx).byCode[code] || {}; @@ -65,6 +79,7 @@ totalFirst: String(ini.CHGNM ?? "1") === "0", order: ini.OPTNM || "SML", assignDigits: ini.ZCDNM !== undefined && ini.ZCDNM !== "" ? num(ini.ZCDNM) : 2, + lineDigits: digitOf(ini.ACDNM), sbmok: String(row.SBMOK || "").trim(), adj: adjOf(row), qty: qtyResolver(ctx), @@ -117,7 +132,7 @@ return m; } - // 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CON*D · RX*NM · EXLNM1) · COSTN RXX + // 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CON*D · RX*NM · 환율 · 자리) · COSTN RXX // 손료 갈래(E0 주간 · E1 야간 · E2 2교대 · E3 3교대, 30 §4) — 계수는 사용자 입력(sysConfig.shiftFactors) function compositionOptions(ctx, code, row) { const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {}; @@ -131,6 +146,11 @@ labor: code[0] === "X" ? laborMap(ini, row.RXX, sys.rxFormula) : null, rx: ini.RXXNM || sys.rxFormula, usdRate: num(ini.EXLNM1), + fx: fxOf(ini), + // 줄 금액 자리 = 중기 KCD · 일위대가 BPN · 중기 노임 단가 JCD · $장비 단가 ECD(22 §3.4 · REA 14 안내문) + lineDigits: digitOf(code[0] === "X" ? ini.KCDNM : ini.BPNNM), + wageDigits: digitOf(ini.JCDNM, 0), + fxDigits: digitOf(ini.ECDNM, 0), shiftFactor: code[0] === "X" && shift !== "E0" ? num((sys.shiftFactors || {})[shift]) || 1 : 1, }; diff --git a/tools/stmate-test/smt_eval.js b/tools/stmate-test/smt_eval.js index 65f6465aa..295fb91ea 100644 --- a/tools/stmate-test/smt_eval.js +++ b/tools/stmate-test/smt_eval.js @@ -382,14 +382,32 @@ const StmateSmtEval = (() => { // 낙찰률 단가 — 원단가(2칸) × 낙찰률, 성분별 원 미만 버림 const bidRate = (v, pct) => trunc(((Number(v) || 0) * (Number(pct) || 0)) / 100); - // 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 원 미만 버림(Start8x.Ini RXXNM) - function hourlyWage(daily, expr) { + // 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 JCD 자리 미만 버림(Start8x.Ini RXXNM · JCDNM) + function hourlyWage(daily, expr, digits = 0) { let acc = Number(daily) || 0; for (const [, op, n] of String(expr || "") .replace(/\s+/g, "") .matchAll(/([*/])?(\d+(?:\.\d+)?)/g)) acc = op === "/" ? acc / Number(n) : acc * Number(n); - return trunc(acc); + return trunc(acc, digits); + } + + // 외산장비 환율 — 단위 $ · € · ¥(₩/100¥) · $ 는 10만$ 이상이면 고액 환율(EXHNM) · 단가 천원 단위(÷1000) + // 근거: REA 14 TWM_SET_SYS(mBtn_EXL · ed_EXH · mBtn_EXEU · mBtn_EXJP 안내문) · 자리 = ECD(「X」의 $장비 단가 소수점) + // opts.fx = { usd, usdHigh, eur, yen } · 옛 opts.usdRate 는 usd 로 봄 + function foreignUnit(unit, price, opts) { + const fx = opts.fx || { usd: opts.usdRate }; + const rate = + unit === "$" + ? Number(price) >= 100000 && Number(fx.usdHigh) > 0 + ? fx.usdHigh + : fx.usd + : unit === "€" + ? fx.eur + : unit === "¥" || unit === "¥" + ? (Number(fx.yen) || 0) / 100 + : null; + return rate === null ? null : Number(rate) || 0; } // 나누기수량(DEVD) — 저장값의 역수 · 0 은 0(REA 30 PR_RR · divisor_to_ordinary) @@ -403,12 +421,14 @@ const StmateSmtEval = (() => { /** * 일위대가(B)·중기(X) 구성 조립 — BDQTY 구성행(bdqty.py 복호) 차례대로 * rows: [{ CODE, QTY1, DEVD1, PRPT, PRIN, PROUT }] · lookup(code) → { M, L, S, unit } - * opts: { totalFirst, order, sbmok, adj, labor: Set(시간 환산 직종) 또는 Map(직종 → 환산식), rx, usdRate } + * opts: { totalFirst, order, sbmok, adj, labor: Set(시간 환산 직종) 또는 Map(직종 → 환산식), rx, usdRate · fx } + * 자리(22 §3.4 · REA 14 안내문): lineDigits = 줄 금액(중기 KCD · 일위대가 BPN, 기본 1) · wageDigits = JCD · fxDigits = ECD * 근거: 지식DB 17(성분 절사 · 중기 손료·운전원·연료·잡품) · 실무 STC 대조 */ function evaluateComposition(rows, lookup, opts = {}) { const s = { M: 0, L: 0, S: 0, T: 0 }, - base = zero(); + base = zero(), + ld = opts.lineDigits ?? 1; const add = (k, v) => ((s[k === "T" ? "M" : k] += v), (s.T += v)); let closed = false; // C* 줄 뒤의 % 자원 = 앞 금액 전체를 그 비율로(예: 켜쌓기 적용 90%) for (const row of rows) { @@ -422,7 +442,7 @@ const StmateSmtEval = (() => { if (!bk || !ok) continue; const bv = bk === "T" ? base.M + base.L + base.S : base[bk]; const coef = isDevd(row) || code === "RR" ? q : null; - add(ok, coef === null ? trunc((bv * q) / 100, 1) : trunc(bv * coef, 1)); + add(ok, coef === null ? trunc((bv * q) / 100, ld) : trunc(bv * coef, ld)); continue; } if (/^C\d$/.test(code)) closed = true; @@ -432,28 +452,31 @@ const StmateSmtEval = (() => { const own = code[0] === "L" ? "L" : code[0] === "S" ? "S" : "M"; const pct = unit.match(/^([MLST])%$/); if (pct && closed) { - for (const k of "MLST") s[k] = trunc((s[k] * q) / 100, 1); + for (const k of "MLST") s[k] = trunc((s[k] * q) / 100, ld); continue; } if (pct) { const bv = pct[1] === "T" ? s.M + s.L + s.S : s[pct[1]]; - add(own, trunc((bv * q) / 100, 1)); + add(own, trunc((bv * q) / 100, ld)); continue; } let p = { M: r.M, L: r.L, S: r.S }; const lab = opts.labor; if (lab && lab.has(code)) { const rx = lab instanceof Map ? lab.get(code) : opts.rx; - p = { M: 0, L: hourlyWage(r.L, rx), S: 0 }; + p = { M: 0, L: hourlyWage(r.L, rx, Number(opts.wageDigits) || 0), S: 0 }; } - if (unit === "$") for (const k of "MLS") p[k] = (p[k] * (Number(opts.usdRate) || 0)) / 1000; + const fxRate = foreignUnit(unit, r.M + r.L + r.S, opts); + if (fxRate !== null) + for (const k of "MLS") + p[k] = opts.fxDigits == null ? (p[k] * fxRate) / 1000 : trunc((p[k] * fxRate) / 1000, Number(opts.fxDigits) + 3); // 손료 갈래(E1~E3) — 손료 행(경비 · 단위 천원)에 사용자 계수 const sf = opts.shiftFactor; const qq = sf && sf !== 1 && code[0] === "S" && unit === "천원" ? q * sf : q; - s.T += trunc(qq * (p.M + p.L + p.S), 1); + s.T += trunc(qq * (p.M + p.L + p.S), ld); const marked = String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== ""; for (const k of "MLS") { - const a = trunc(qq * p[k], 1); + const a = trunc(qq * p[k], ld); s[k] += a; if (marked) base[k] += a; } diff --git a/tools/stmate-test/stc_save.py b/tools/stmate-test/stc_save.py index b89dd2ce9..1fc5f6854 100644 --- a/tools/stmate-test/stc_save.py +++ b/tools/stmate-test/stc_save.py @@ -5,6 +5,7 @@ 16_골든셋_회귀_결과.md §4(행 금액 = ROUNDDOWN(수량×단가) · 계층 집계 · C* 제외 구역 · TCOSTn = 직접비) 요청 키(SaveDbfReq): costn_updates[{CODE, 칸…}] · costn_memo_updates{CODE: 글} · boq_table · boq_updates[{_recno | _new+_after, 칸…}] · prjlst_updates{칸} · smt_updates{키: 값} + · kanjub_stages{차수: 설정} · bdqty_rows[구성행] · qcode_rows[수량코드행](stc_save_extra — 바뀐 것만) 파생 규칙(실무 STC 7건 전 행에서 다시 셈 = 저장값 확인): - 단가 전파: COSTN n차 단가가 바뀐 코드 → 그 코드 내역 행 중 옛 단가가 COSTN 옛 단가와 같던 행만 새 단가 - 행 금액: 그 행 n차 수량·단가가 바뀌었을 때만 ROUNDDOWN(QTYn × ?AMTn) · T_AMTn = 재+노+경 @@ -20,6 +21,7 @@ from decimal import ROUND_DOWN, Decimal, InvalidOperation from dbf_memo import SixMemo, ini_update, memo_text_bytes, same_memo from dbf_rw import DbfTable, find_rows from smt_parser import parse_ini_bytes +from stc_save_extra import insert_costn_rows, kanjub_smt, save_bdqty, save_qcode STRUCT_RE = re.compile(r"^(T\d|E\d|C\d|:)$") SLOTS = ("1", "2", "3", "4", "5") @@ -268,6 +270,8 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str price_cols = [f"{c}AMT{s}" for s in SLOTS for c in "TMLS" if f"{c}AMT{s}" in ct.fields] for code, i in rows.items(): costn_old[code] = {c: ct.get(i, c) for c in price_cols} + if insert_costn_rows(ct, list(req.get("costn_updates") or []), log): + rows = find_rows(ct, "CODE") for u in req.get("costn_updates") or []: code = str(u.get("CODE", "")).strip() if code not in rows: @@ -317,10 +321,12 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str upd[f"{c}{s}"] = v _apply_fields(pt, "PRJLST.DBF", pi, upd, log) smt_upd = req.get("smt_updates") or {} - if smt_upd and "PRJ_MEMO" in pt.fields: + stages = req.get("kanjub_stages") or {} + if (smt_upd or stages) and "PRJ_MEMO" in pt.fields: memo = SixMemo(files.get("PRJLST.SMT")) ptr = pt.raw(pi, "PRJ_MEMO") text = memo.read(ptr).decode("cp949", "replace") + smt_upd = kanjub_smt(text, stages, smt_upd) if stages else smt_upd new_text, keys = ini_update(text, smt_upd) if keys: new_ptr = memo.write(ptr, memo_text_bytes(new_text)) @@ -333,6 +339,14 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str if pt.dirty: out["PRJLST.DBF"] = pt.to_bytes(today) + # 구성표(BDQTY · 암호) · 수량코드(QCODE) — 화면 행이 불러온 행과 다를 때만 + if req.get("bdqty_rows") is not None and "BDQTY.DBF" in files: + new_b = save_bdqty(files["BDQTY.DBF"], list(req["bdqty_rows"]), log) + if new_b is not None: + out["BDQTY.DBF"] = new_b + if req.get("qcode_rows") is not None: + out.update(save_qcode(files, list(req["qcode_rows"]), log)) + # 초기값(Start8x.Ini · STini.MEM) — 있는 키 중 바뀐 값만 그 줄 자리에서 고침 ini_req = req.get("start_ini") or {} for name in ("START8X.INI", "STINI.MEM") if ini_req else (): diff --git a/tools/stmate-test/stc_save_extra.py b/tools/stmate-test/stc_save_extra.py new file mode 100644 index 000000000..3a909a87a --- /dev/null +++ b/tools/stmate-test/stc_save_extra.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +"""저장 덧붙임 — COSTN 새 코드 행 · BDQTY 구성 고침(암호 그대로) · QCODE 수량코드 · 제잡비 설정(PRJLST.SMT). + +근거: STC_왕복검증.md §3(알려진 칸만 갱신) · STC_난독화_상태.md(BDQTY 자리별 변환) · 29번 · + 02번(QCODE 15필드) · 25번 §1(저장키) · kanjub_cfg.smt_updates(설정 → 저장키) +규칙: + - 화면이 보낸 값이 불러온 값과 뜻으로 같으면 그 파일은 원래 바이트 그대로 + - COSTN 새 코드: 같은 머리글자 마지막 행 뒤에 끼움 · 숫자 칸 0 · 메모 칸 빈 포인터 + - BDQTY: 바뀐 호표(BDCODE 묶음)만 다시 늘어놓음 · 원래 행은 원래 바이트에서 바뀐 칸만 암호로 씀 + · 새 행 틀 = 같은 구성 코드 행(이름·규격 암호 칸을 그대로 옮김) > 같은 호표 첫 행 > 첫 행 + · 복호가 깨진 값(U+FFFD 포함)은 쓰지 않음 — 틀 행의 원래 바이트 유지 + - QCODE: 행이 바뀌었을 때만 qty_qcode.build_qcode 로 두 파일을 다시 씀 + - 제잡비: 차수별 설정 → smt_updates · 원래 값과 같은 키는 버림 · 없던 키는 빈 값·0 이 아닐 때만 +""" + +from __future__ import annotations + +import re + +from bdqty import decode_bdqty +from dbf_crypt import BdqtyCodec, covers +from dbf_memo import _same_ini_value, ini_items +from dbf_rw import DbfTable +from kanjub_cfg import smt_updates +from qty_qcode import build_qcode, read_qcode + +CODE_RE = re.compile(r"^[A-Z][0-9A-Z]{5}$") + + +def _num(v) -> float | None: + try: + return float(str(v).strip().replace(",", "")) + except (TypeError, ValueError): + return None + + +def same_val(a, b) -> bool: + """뜻 비교 — 둘 다 숫자로 읽히면 숫자로 · 아니면 앞뒤 공백·줄바꿈 꼴 무시.""" + na, nb = _num(a), _num(b) + if na is not None and nb is not None: + return abs(na - nb) < 1e-9 + norm = lambda x: str("" if x is None else x).replace("\r\n", "\n").strip() # noqa: E731 + return norm(a) == norm(b) + + +def same_row(old: dict, new: dict) -> bool: + """old 의 칸 중 new 에 있는 칸이 모두 같은가(new 에 없는 칸은 안 바뀐 것으로 봄).""" + return all(same_val(v, new[k]) for k, v in old.items() if k in new and not k.startswith("_")) + + +# ── COSTN 새 코드 행 ── +def insert_costn_rows(ct: DbfTable, ups: list[dict], log) -> list[str]: + """요청 중 COSTN 에 없는 코드를 새 행으로 끼움 — 끼운 코드 목록.""" + have = {ct.get(i, "CODE") for i in range(len(ct))} + added = [] + for u in ups: + code = str(u.get("CODE", "")).strip().upper() + if code in have or not CODE_RE.match(code): + continue + last = [i for i in range(len(ct)) if ct.get(i, "CODE")[:1] == code[:1]] + pos = (last[-1] + 1) if last else len(ct) + rec = bytearray(b" " * ct.rlen) + for f in ct.fields.values(): + if f.type in ("N", "F"): + rec[f.off : f.off + f.len] = ct.encode_value(f.name, 0) + ct.recs.insert(pos, rec) + ct.dirty = True + vals = {k: v for k, v in u.items() if not k.startswith("_")} + vals["CODE"] = code + for k, v in vals.items(): + if k in ct.fields and ct.fields[k].type != "M": + ct.set_raw(pos, k, ct.encode_value(k, v)) + log.add("COSTN.DBF", pos, "_insert", "", code) + have.add(code) + added.append(code) + return added + + +# ── BDQTY 구성 ── +def _groups(rows: list[dict]) -> dict[str, list[int]]: + out: dict[str, list[int]] = {} + for i, r in enumerate(rows): + out.setdefault(str(r.get("BDCODE", "")).strip(), []).append(i) + return out + + +def save_bdqty(b: bytes, new_rows: list[dict], log) -> bytes | None: + """화면 구성행(decode_bdqty 꼴) → 새 BDQTY 바이트 · 바뀐 것이 없으면 None.""" + old = decode_bdqty(b) + if len(old) == len(new_rows) and all(same_row(o, n) for o, n in zip(old, new_rows)): + return None + t = DbfTable(b, codec=BdqtyCodec()) + g_old, g_new = _groups(old), _groups(new_rows) + by_comp: dict[str, int] = {} + for i, r in enumerate(old): + by_comp.setdefault(str(r.get("CODE", "")).strip(), i) + recs, plan = [], [] # plan = (새 자리, 비교 기준 옛 행, 새 행) + for code, idx in g_new.items(): + olds = g_old.get(code, []) + news = [new_rows[i] for i in idx] + if len(olds) == len(news) and all(same_row(old[o], n) for o, n in zip(olds, news)): + recs += [bytearray(t.recs[o]) for o in olds] + continue + for k, n in enumerate(news): + if k < len(olds): + base = olds[k] + else: + comp = str(n.get("CODE", "")).strip() + base = by_comp.get(comp, olds[0] if olds else 0) + log.add("BDQTY.DBF", len(recs), "_insert", old[base].get("CODE", ""), comp) + plan.append((len(recs), base, n)) + recs.append(bytearray(t.recs[base])) + for code in g_old: + if code not in g_new: + log.add("BDQTY.DBF", g_old[code][0], "_delete", code, "") + t.recs, t.dirty = recs, True + for i, base, n in plan: + ref = old[base] + for k, v in n.items(): + if k.startswith("_") or k not in t.fields or same_val(ref.get(k, ""), v): + continue + if "�" in str(v): # 복호가 깨진 칸(표 밖 글자)에서 온 값 — 옮겨 온 원래 바이트 유지 + continue + if t.fields[k].type in ("N", "F") and _num(v) is None and str(v).strip(): + continue # 숫자로 읽히지 않는 복호값(자리 표가 덜 맞는 칸)도 같음 + f = t.fields[k] + if not covers(f.off, f.len): + raise ValueError(f"BDQTY {k} 칸은 암호 표 밖이라 쓸 수 없음") + t.set_raw(i, k, t.encode_value(k, v)) + log.add("BDQTY.DBF", i, k, str(ref.get(k, "")), str(v)) + return t.to_bytes() + + +# ── QCODE ── +def save_qcode(files: dict[str, bytes], rows: list[dict], log) -> dict[str, bytes]: + """수량코드 행이 바뀌었을 때만 {QCODE.DBF, QCODE.SMT}.""" + cur = read_qcode(files)["rows"] + if len(cur) == len(rows) and all(same_row(o, n) for o, n in zip(cur, rows)): + return {} + bad = [r.get("CODE") for r in rows if not re.fullmatch(r"Q\d{5}", str(r.get("CODE", "")).strip())] + if bad: + raise ValueError(f"수량코드 형식 오류: {bad[:5]}") + clean = [{k: v for k, v in r.items() if not k.startswith("_")} for r in rows] + dbf, smt = build_qcode(clean, files) + log.add("QCODE.DBF", 0, "_rows", str(len(cur)), str(len(rows))) + return {"QCODE.DBF": dbf, "QCODE.SMT": smt} + + +# ── 제잡비 설정 → PRJLST.SMT 키 ── +def kanjub_smt(text: str, stages: dict, explicit: dict) -> dict: + """차수별 설정(kanjubStages)과 화면이 직접 준 키를 합친 갱신 사전 — 원래 값과 같은 키는 뺌.""" + orig = {k: v for _s, k, v in ini_items(text)} + derived: dict = {} + for slot, cfg in sorted((stages or {}).items(), key=lambda x: int(x[0])): + if not isinstance(cfg, dict): + continue + upd = smt_updates(cfg, int(slot)) + if int(slot) != 1: + upd = {k: v for k, v in upd.items() if not k.startswith("ADD_")} + derived.update(upd) + keep = { + k: v + for k, v in derived.items() + if (k in orig and not _same_ini_value(orig[k], v)) + or (k not in orig and str(v).strip() not in ("", "0")) + } + out = dict(explicit or {}) + for k, v in keep.items(): + if k not in out or (k in orig and _same_ini_value(orig[k], out[k])): + out[k] = v + return out diff --git a/tools/stmate-test/view_prj_boq.js b/tools/stmate-test/view_prj_boq.js index f0ff237e3..3d10990fe 100644 --- a/tools/stmate-test/view_prj_boq.js +++ b/tools/stmate-test/view_prj_boq.js @@ -678,6 +678,10 @@ boq_deleted: d.boq_deleted || ctx.uiState.boqDeleted || [], prjlst_updates: prjUpd, smt_updates: smtUpd, + // 서버(stc_save_extra)가 불러온 값과 견줘 바뀐 것만 씀 — 제잡비 설정 · 구성표 · 수량코드 + kanjub_stages: ctx.state.kanjubStages || {}, + bdqty_rows: d.bdqty_rows || null, + qcode_rows: d.qcode_rows || null, }; }