diff --git a/resources/tester/test_stmate_conv_api.py b/resources/tester/test_stmate_conv_api.py
new file mode 100644
index 000000000..f50758551
--- /dev/null
+++ b/resources/tester/test_stmate_conv_api.py
@@ -0,0 +1,77 @@
+"""STMATE 재현 앱 변환 API(api_conv.py) — 화면이 부르는 길로 한 바퀴 · 쓰기는 임시 폴더(server.WORK_DBF_ROOT 바꿈).
+
+정답 = 원본 STC 저장값(PRJLST 직접비 · 행 수). 코드에 값을 박지 않음.
+"""
+
+import base64
+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_conv as A # noqa: E402
+import server # noqa: E402
+from dbf_rw import DbfTable # noqa: E402
+from stc_read import Source # noqa: E402
+
+DOCS = ROOT / "resources/knowledge/original/실무문서"
+BH = DOCS / "04.산불 봉화/01. 내역서"
+STC = BH / "13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC"
+XLSX = BH / "2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx"
+
+
+@pytest.fixture
+def work(tmp_path, monkeypatch):
+ monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
+ return tmp_path
+
+
+def _prj(path):
+ return DbfTable(Source(path).files["PRJLST.DBF"])
+
+
+def test_xls_boq_and_register_api(work):
+ b64 = base64.b64encode(XLSX.read_bytes()).decode()
+ r = A.api_xls_boq(
+ A.XlsBoqReq(source_path=str(STC), xlsx=A.FileIn(b64=b64), target_folder_name="xb")
+ )
+ assert r["report"]["row_count"] == len(DbfTable(Source(str(STC)).files["BOQ11.DBF"]))
+ assert Path(r["saved_stc"]).parent == work
+ r2 = A.api_xls_register(
+ A.XlsRegReq(template_path=str(STC), xlsx=A.FileIn(path=str(XLSX)), target_folder_name="xr")
+ )
+ a, b = _prj(str(STC)), _prj(r2["saved_stc"])
+ assert b.get(0, "TCOST1") == a.get(0, "TCOST1") and b.get(0, "PNAME") == a.get(0, "PNAME")
+
+
+def test_boq_xlsx_download_reimports(work):
+ resp = A.api_boq_xlsx(A.SrcReq(source_path=str(STC)))
+ assert resp.media_type == A.XLSX
+ b64 = base64.b64encode(resp.body).decode()
+ r = A.api_xls_boq(
+ A.XlsBoqReq(source_path=str(STC), xlsx=A.FileIn(b64=b64), target_folder_name="own")
+ )
+ assert Source(r["saved_stc"]).files["PRJLST.DBF"] == Source(str(STC)).files["PRJLST.DBF"]
+
+
+def test_code_and_split_and_jaje_api(work):
+ r = A.api_code_change(
+ A.CodeChgReq(source_path=str(STC), mapping={"M00001": "M99991"}, target_folder_name="cc")
+ )
+ assert r["change_count"] > 0
+ codes = {DbfTable(Source(r["saved_stc"]).files["COSTN.DBF"]).get(i, "CODE") for i in range(3)}
+ assert "M00001" not in codes or "M99991" in codes
+ sp = A.api_split_change(A.SplitReq(source_path=str(STC), target_folder_name="sp"))
+ p = _prj(sp["saved_stc"])
+ assert p.get(0, "K_ORD") == "1" and p.get(0, "TCOST2") == p.get(0, "TCOST1") == _prj(
+ str(STC)
+ ).get(0, "TCOST1")
+ x = A.api_jaje_xlsx(A.SrcReq(source_path=str(STC))).body
+ r3 = A.api_jaje_import(
+ A.JajeImportReq(source_path=str(STC), xlsx=A.FileIn(b64=base64.b64encode(x).decode()))
+ )
+ assert r3["count"] == 0
+ with pytest.raises(Exception):
+ A.api_jb_read(A.JbReq(path=str(STC))) # 설치 폴더 JB_* 만
diff --git a/resources/tester/test_stmate_conv_change.py b/resources/tester/test_stmate_conv_change.py
new file mode 100644
index 000000000..5a5a45281
--- /dev/null
+++ b/resources/tester/test_stmate_conv_change.py
@@ -0,0 +1,173 @@
+"""STMATE 재현 앱 분리형 변경 STC(차수 인계) · 코드 일괄 변경 · JB_* 기준 제비율표 · STC 검사.
+
+정답: 소광 원설계 STC(BOQ11·BOQ12) → 실제 「변경 1차분」 STC(16번 §4.6) · 원본 STC 바이트(코드 되돌림) ·
+ 10번 §4 문서의 RATE ↔ JB 일치 사례(설치 폴더에 JB_* 가 있을 때만).
+"""
+
+import collections
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+from bdqty import decode_bdqty # noqa: E402
+from conv_jb import JB_DIR, list_jb, match_project # noqa: E402
+from dbf_rw import DbfTable # noqa: E402
+from smt_parser import extract_costn_memos, parse_dbf_bytes, parse_prjlst_smt # noqa: E402
+from stc_archive import build_stc, check_stc # noqa: E402
+from stc_change import split_change # noqa: E402
+from stc_codechg import change_codes # noqa: E402
+from stc_read import Source # noqa: E402
+from stc_save import _leaf, apply_save # noqa: E402
+
+DOCS = ROOT / "resources/knowledge/original/실무문서"
+SO = DOCS / "25년 산불진화임도(기번8)(울진소광)"
+ORIG = SO / "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC"
+CHG1 = SO / "변경 1차분-2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC"
+STCS = sorted(DOCS.rglob("*.STC"))
+
+
+def _kv(files):
+ _, rows, _ = parse_dbf_bytes(files["PRJLST.DBF"])
+ return parse_prjlst_smt(files["PRJLST.SMT"], rows[0]["PRJ_MEMO_HEX"])
+
+
+def test_split_change_matches_real_next_file():
+ a, b = Source(str(ORIG)).files, Source(str(CHG1)).files
+ out, rep = split_change(a)
+ assert rep["from_pcode"] == "12" # 통합형 — BOQ12 가 현재안
+ assert set(k for k in out if k.startswith("BOQ1")) == {"BOQ11.DBF"}
+ pg, pb = DbfTable(out["PRJLST.DBF"]), DbfTable(b["PRJLST.DBF"])
+ assert len(pg) == 1
+ for f in ("PNAME", "PCODE", "K_ORD", "MCOST1", "LCOST1", "SCOST1", "TCOST1", "COSTW1"):
+ assert pg.get(0, f) == pb.get(0, f), f
+ g, t = DbfTable(out["BOQ11.DBF"]), DbfTable(b["BOQ11.DBF"])
+
+ def key(tb, i, n):
+ return (
+ tb.get(i, "NAME1"),
+ tb.get(i, "SIZE1"),
+ round(float(tb.get(i, f"QTY{n}") or 0), 4),
+ int(float(tb.get(i, f"M_AMT{n}") or 0)),
+ int(float(tb.get(i, f"L_AMT{n}") or 0)),
+ int(float(tb.get(i, f"S_AMT{n}") or 0)),
+ )
+
+ mine = collections.Counter(key(g, i, 1) for i in range(len(g)) if _leaf(g.get(i, "BDCODE1")))
+ real = collections.Counter(
+ key(t, i, 1)
+ for i in range(len(t))
+ if _leaf(t.get(i, "BDCODE1")) and float(t.get(i, "QTY1") or 0)
+ )
+ assert mine == real
+ assert all(key(g, i, 1) == key(g, i, 2) for i in range(len(g))) # 차수2 = 출발점(차수1 복사)
+ kv1, kv2 = _kv(out), _kv(b)
+ rk = [k for k in kv2 if k.startswith("1_R_")]
+ assert rk and all(kv1.get(k) == kv2[k] for k in rk)
+ assert any(k.startswith("2_R_") for k in kv1)
+ assert check_stc(build_stc(Source(str(ORIG)).entries, out))["ok"]
+
+
+@pytest.mark.parametrize("stc", STCS, ids=[p.stem[:16] for p in STCS])
+def test_code_change_everywhere_and_back(stc):
+ f = Source(str(stc)).files
+ memos = extract_costn_memos(f["COSTN.DBF"], f["COSTN.SMT"])
+ xc = next(r["code"] for m in memos.values() for r in m["refs"] if r["code"].startswith("X"))
+ out, log = change_codes(f, {xc: "X99999"})
+ assert xc not in {r["CODE"].strip() for r in parse_dbf_bytes(out["COSTN.DBF"])[1]}
+ dq = decode_bdqty(out["BDQTY.DBF"])
+ assert not any(xc in (r["CODE"], r["BDCODE"]) for r in dq) and any(
+ "X99999" in (r["CODE"], r["BDCODE"]) for r in dq
+ )
+ m2 = extract_costn_memos(out["COSTN.DBF"], out["COSTN.SMT"])
+ assert not any(f"&{xc}" in m["text"] for m in m2.values()) and any(
+ "&X99999" in m["text"] for m in m2.values()
+ )
+ back, _ = change_codes(out, {"X99999": xc})
+ assert all(back[k][32:] == f[k][32:] for k in f) # 머리 날짜 밖 원래 바이트
+ with pytest.raises(ValueError):
+ change_codes(
+ f,
+ {
+ xc: next(
+ r["CODE"].strip()
+ for r in parse_dbf_bytes(f["COSTN.DBF"])[1]
+ if r["CODE"].strip() != xc
+ )
+ },
+ )
+
+
+@pytest.mark.parametrize("stc", STCS, ids=[p.stem[:16] for p in STCS])
+def test_check_stc_rules(stc):
+ r = check_stc(stc.read_bytes())
+ assert r["ok"] and r["warnings"] == [], (r["problems"], r["warnings"])
+
+
+def test_prjlst_rows_edit_other_project_row():
+ f = Source(str(ORIG)).files
+ rows = parse_dbf_bytes(f["PRJLST.DBF"])[1]
+ assert len(rows) == 2
+ edited = [dict(r) for r in rows]
+ edited[1]["PNAME"] = "둘째 공사 이름 고침"
+ out, log = apply_save(f, {"prjlst_rows": edited})
+ assert [(x["recno"], x["field"]) for x in log] == [(1, "PNAME")]
+ assert DbfTable(out["PRJLST.DBF"]).get(1, "PNAME") == "둘째 공사 이름 고침"
+ out2, log2 = apply_save(f, {"prjlst_rows": rows})
+ assert log2 == [] and out2["PRJLST.DBF"] is f["PRJLST.DBF"]
+
+
+needs_jb = pytest.mark.skipif(not JB_DIR.exists(), reason="STMATE 설치 폴더 JB_* 없음")
+
+
+@needs_jb
+def test_jb_files_shapes():
+ files = list_jb()
+ assert len(files) >= 43 and all(f["shape_ok"] for f in files)
+ kinds = collections.Counter(f["kind"] for f in files)
+ assert (
+ kinds["JB_1"] >= 1 and kinds["JB_M"] >= 1 and kinds["JB_R"] >= 27 and kinds["JB_RA"] >= 14
+ )
+ jr = {f["date"]: f["cols"] for f in files if f["kind"] == "JB_R"}
+ assert jr["2020-01-02"] == 20 and jr["2020-05-27"] == 35 # 2020-05-27 확장(10번 §4)
+
+
+@needs_jb
+@pytest.mark.parametrize(
+ "rel,table,date",
+ [
+ (
+ "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC",
+ "RATE_R.DBF",
+ "2023-01-02",
+ ),
+ (
+ "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC",
+ "RATE.DBF",
+ "2011-01-12",
+ ),
+ (
+ "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC",
+ "RATE_R.DBF",
+ "2024-03-15",
+ ),
+ (
+ "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC",
+ "RATE_R.DBF",
+ "2020-05-27",
+ ),
+ ],
+)
+def test_jb_match_documented_cases(rel, table, date):
+ m = match_project(Source(str(DOCS / rel)).files)
+ assert m[table]["exact"] and m[table]["date"] == date
+
+
+@needs_jb
+def test_jb_sogwang_five_cells():
+ m = match_project(Source(str(ORIG)).files)
+ assert (
+ m["RATE_R.DBF"]["date"] == "2025-01-01" and m["RATE_R.DBF"]["diff_cells"] == 5
+ ) # 10번 §4 「5개 셀만 다름」
diff --git a/resources/tester/test_stmate_conv_xls.py b/resources/tester/test_stmate_conv_xls.py
new file mode 100644
index 000000000..419b2519b
--- /dev/null
+++ b/resources/tester/test_stmate_conv_xls.py
@@ -0,0 +1,216 @@
+"""STMATE 재현 앱 엑셀 가져오기 — 원본 STMATE 가 내보낸 xlsx(숨은 「〓 INITIAL 〓」·INFO 열)로 STC 를 되살림.
+
+정답 = 같은 공사의 실무 STC(BOQ · COSTN · COSTN.SMT · BDQTY 복호 · PRJLST). 코드에 값을 박지 않음.
+울진 = 신설사업 xlsx(착공내역서) ↔ 신설사업 STC 의 BOQ12(현재안 · 16번 §4.5).
+쓰기는 임시 폴더에서만.
+"""
+
+import collections
+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 conv_xls_apply import import_boq, register # noqa: E402
+from conv_xls_boq import boq_from_xlsx, fill_from_costn, load_book, read_initial # noqa: E402
+from conv_xls_out import ( # noqa: E402
+ export_boq_xlsx,
+ export_jaje_xlsx,
+ import_jaje_xlsx,
+)
+from conv_xls_res import comp_from_san, comp_from_sheets, costn_from_lists, memos_from_sheet # noqa: E402
+from dbf_rw import DbfTable # noqa: E402
+from smt_parser import extract_costn_memos, parse_dbf_bytes # noqa: E402
+from stc_project import write_output # noqa: E402
+from stc_read import Source # noqa: E402
+from stc_save import apply_save # noqa: E402
+
+DOCS = ROOT / "resources/knowledge/original/실무문서"
+SO = (
+ "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외)"
+)
+YD = "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경)"
+PAIRS = { # 이름: (STC, xlsx, 비교 표)
+ "봉화": (
+ "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC",
+ "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
+ "BOQ11.DBF",
+ ),
+ "영월": (
+ "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC",
+ "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
+ "BOQ11.DBF",
+ ),
+ "대흥": (
+ "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC",
+ "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
+ "BOQ11.DBF",
+ ),
+ "울진": (SO + ".STC", SO + ".xlsx", "BOQ12.DBF"),
+ "영덕변경": (YD + ".STC", YD + ".xlsx", "BOQ11.DBF"),
+}
+F = (
+ "BDCODE1 ITNUM NAME1 SIZE1 UNIT1 QTY1 TAMT1 LAMT1 MAMT1 SAMT1 T_AMT1 L_AMT1 M_AMT1 S_AMT1 "
+ "EXIST1 SORTCODE ENDCODE EQCODE PRIN PROUT RCU KWNGP BL_AMT BQCODE"
+).split()
+F2 = "QTY2 TAMT2 LAMT2 MAMT2 SAMT2 T_AMT2 L_AMT2 M_AMT2 S_AMT2".split()
+
+
+def same(a, b) -> bool:
+ try:
+ return abs(float(a or 0) - float(b or 0)) < 1e-6
+ except (TypeError, ValueError):
+ sa, sb = ("" if a is None else str(a)), ("" if b is None else str(b))
+ return sa.rstrip() == sb.rstrip() or {sa.strip(), sb.strip()} <= {"", "F"}
+
+
+def _pair(name):
+ stc, xl, tb = PAIRS[name]
+ return Source(str(DOCS / stc)).files, DOCS / xl, tb
+
+
+def _diff(rows, got, fields):
+ bad = [
+ (i, f, a.get(f), b.get(f))
+ for i, (a, b) in enumerate(zip(rows, got))
+ for f in fields
+ if f in a and not same(a.get(f), b.get(f))
+ ]
+ return bad
+
+
+@pytest.mark.parametrize("name", list(PAIRS))
+def test_xlsx_boq_rows_reproduce_stc(name):
+ files, xl, tb = _pair(name)
+ got = boq_from_xlsx(xl)["rows"]
+ fill_from_costn(got, parse_dbf_bytes(files["COSTN.DBF"])[1])
+ rows = parse_dbf_bytes(files[tb])[1]
+ assert len(got) == len(rows)
+ fields = F + (F2 if name == "영덕변경" else []) # 변경 내역서만 2차 줄이 있음
+ bad = _diff(rows, got, fields)
+ assert not bad, f"{name} {len(bad)}칸 {bad[:5]}"
+
+
+def _sem(t: DbfTable, i: int, f: str):
+ v = t.get(i, f)
+ if t.fields[f].type in ("N", "F"):
+ return round(float(v or 0), 6)
+ return (v or "F") if t.fields[f].type == "L" else v
+
+
+@pytest.mark.parametrize("name", list(PAIRS))
+def test_import_boq_writes_same_table_and_direct_cost(name):
+ files, xl, tb = _pair(name)
+ out, rep = import_boq(files, xl, tb)
+ assert rep["unknown_codes"] == []
+ a, b = DbfTable(files[tb]), DbfTable(out[tb])
+ assert len(a) == len(b)
+ skip = {"QTY2"} if name == "울진" else set() # 착공 엑셀에 없는 2차 수량
+ bad = [
+ (i, f)
+ for i in range(len(a))
+ for f in a.order
+ if a.fields[f].type != "M" and f not in skip and _sem(a, i, f) != _sem(b, i, f)
+ ]
+ assert not bad, f"{name} {len(bad)}칸 {bad[:5]}"
+ assert out["PRJLST.DBF"] == files["PRJLST.DBF"] # 소계 다시 셈 = 저장 직접비(?COSTn) 그대로
+
+
+@pytest.mark.parametrize("name", list(PAIRS))
+def test_lists_memos_and_composition(name):
+ files, xl, _tb = _pair(name)
+ wb = load_book(xl)
+ info = read_initial(wb)
+ cm = {r["CODE"].strip(): r for r in parse_dbf_bytes(files["COSTN.DBF"])[1]}
+ got = costn_from_lists(wb, info)
+ assert got and all(r["CODE"] in cm for r in got)
+ bad = [(r["CODE"], k) for r in got for k, v in r.items() if not same(cm[r["CODE"]].get(k), v)]
+ assert not bad, bad[:5]
+ memo = extract_costn_memos(files["COSTN.DBF"], files["COSTN.SMT"])
+ mm = memos_from_sheet(wb, info)
+ nl = lambda t: t.replace("\r\n", "\n").rstrip() # noqa: E731
+ assert mm and all(nl(memo[c]["text"]) == nl(t) for c, t in mm.items())
+ gd = collections.defaultdict(list)
+ for r in decode_bdqty(files["BDQTY.DBF"]):
+ gd[r["BDCODE"]].append(r)
+ gc = collections.defaultdict(list)
+ for r in comp_from_sheets(wb, info) + comp_from_san(wb, info):
+ gc[r["BDCODE"]].append(r)
+ assert gc
+ for c, rows in gc.items():
+ assert [(r["CODE"], round(float(r["QTY1"]), 9)) for r in gd[c]] == [
+ (r["CODE"], round(float(r["QTY1"]), 9)) for r in rows
+ ], c
+ for a, b in zip(gd[c], rows):
+ for k in ("DEVD1", "BL_AMT"):
+ if k in b:
+ assert (a.get(k) or "F") == (b[k] or "F"), (c, a["CODE"], k)
+
+
+@pytest.mark.parametrize("name", ["봉화", "영월", "대흥"])
+def test_register_rebuilds_project(name, tmp_path):
+ files, xl, tb = _pair(name)
+ out, rep = register(files, xl)
+ pa, pb = DbfTable(files["PRJLST.DBF"]), DbfTable(out["PRJLST.DBF"])
+ for f in ("PNAME", "MCOST1", "LCOST1", "SCOST1", "TCOST1"):
+ assert pb.get(0, f) == pa.get(0, f), f
+ assert DbfTable(out["COSTN.DBF"]).recs == DbfTable(files["COSTN.DBF"]).recs or rep[
+ "costn_count"
+ ] == len(DbfTable(files["COSTN.DBF"]))
+ d0 = collections.Counter(
+ (r["BDCODE"], r["CODE"], r["QTY1"]) for r in decode_bdqty(files["BDQTY.DBF"])
+ )
+ d1 = collections.Counter(
+ (r["BDCODE"], r["CODE"], r["QTY1"]) for r in decode_bdqty(out["BDQTY.DBF"])
+ )
+ assert d0 == d1
+ m0 = extract_costn_memos(files["COSTN.DBF"], files["COSTN.SMT"])
+ m1 = extract_costn_memos(out["COSTN.DBF"], out["COSTN.SMT"])
+ assert {c: v["text"].rstrip() for c, v in m0.items()} == {
+ c: v["text"].rstrip() for c, v in m1.items()
+ }
+ s = Source(str(DOCS / PAIRS[name][0]))
+ res = write_output(tmp_path, "reg", out, s.names, s.entries)
+ load = server.api_load(server.LoadReq(source_path=res["saved_stc"]))
+ assert (
+ load["boq_count"] == len(DbfTable(files[tb])) and load["costn_count"] == rep["costn_count"]
+ )
+
+
+STCS = sorted(DOCS.rglob("*.STC"))
+
+
+@pytest.mark.parametrize("stc", STCS, ids=[p.stem[:16] for p in STCS])
+def test_own_boq_xlsx_roundtrip(stc):
+ files = Source(str(stc)).files
+ for tb in [k for k in ("BOQ11.DBF", "BOQ12.DBF") if k in files]:
+ got = boq_from_xlsx(export_boq_xlsx(files, tb))["rows"]
+ fill_from_costn(got, parse_dbf_bytes(files["COSTN.DBF"])[1])
+ rows = parse_dbf_bytes(files[tb])[1]
+ assert len(got) == len(rows)
+ bad = _diff(rows, got, F)
+ assert not bad, f"{tb} {bad[:5]}"
+
+
+def test_jaje_xlsx_roundtrip_and_edit(tmp_path):
+ import openpyxl
+
+ files, _xl, _tb = _pair("봉화")
+ x = export_jaje_xlsx(files)
+ assert import_jaje_xlsx(files, x) == []
+ p = tmp_path / "j.xlsx"
+ p.write_bytes(x)
+ wb = openpyxl.load_workbook(p)
+ ws = wb.worksheets[0]
+ code = ws.cell(2, 1).value
+ old = float(ws.cell(2, 10).value or 0)
+ ws.cell(2, 10).value = old + 100
+ wb.save(p)
+ ups = import_jaje_xlsx(files, p.read_bytes())
+ assert ups == [{"CODE": code, "AMT_61": old + 100, "MAMT1": old + 100, "TAMT1": old + 100}]
+ out, log = apply_save(files, {"costn_updates": ups})
+ assert {x["field"] for x in log if x["table"] == "COSTN.DBF"} <= {"AMT_61", "MAMT1", "TAMT1"}
diff --git a/resources/tester/test_stmate_conv_xml.py b/resources/tester/test_stmate_conv_xml.py
new file mode 100644
index 000000000..63eeacf63
--- /dev/null
+++ b/resources/tester/test_stmate_conv_xml.py
@@ -0,0 +1,143 @@
+"""STMATE 재현 앱 조달청 XML 가져오기 — 실무 STC → report_pps 내보내기 → 가져오기 왕복 · XML 변환 설정.
+
+정답 = 원본 STC(말단 내역 행 · PRJLST 차수 직접비 · 총공사비 · COSTN 코드). 코드에 값을 박지 않음.
+설정 선택지·칸 차례는 공식 XSD(V1.0.5)에서 읽은 값과 견줌.
+"""
+
+import collections
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "tools/stmate-test"))
+import server # noqa: E402
+from conv_xml import read_xml, xml_to_project # noqa: E402
+from conv_xml_cfg import apply_settings, building_fields, options, validate # noqa: E402
+from dbf_rw import DbfTable # noqa: E402
+from stc_project import write_output # noqa: E402
+from stc_read import Source # noqa: E402
+from stc_save import _leaf # noqa: E402
+
+HELPER = Path(__file__).with_name("helper_stmate_report.cjs")
+DOCS = ROOT / "resources/knowledge/original/실무문서"
+STCS = {
+ "봉화2024": (
+ "04.산불 봉화/01. 내역서/13-2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.STC",
+ 1,
+ ),
+ "영월2024": ("05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.STC", 1),
+ "대흥2024": (
+ "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC",
+ 1,
+ ),
+ "울진2025": (
+ "25년 산불진화임도(기번8)(울진소광)/2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).STC",
+ 1,
+ ),
+ "영덕변경": (
+ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).STC",
+ 2,
+ ),
+}
+_cache: dict = {}
+
+
+def _xml(name):
+ if name not in _cache:
+ rel, n = STCS[name]
+ load = server.api_load(server.LoadReq(source_path=str(DOCS / rel)))
+ out = subprocess.check_output(
+ ["node", str(HELPER)],
+ input=json.dumps({"load": load, "stage": n, "mode": "pps"}, ensure_ascii=False),
+ text=True,
+ encoding="utf-8",
+ )
+ _cache[name] = json.loads(out)["xml"]
+ return _cache[name]
+
+
+@pytest.mark.parametrize("name", list(STCS))
+def test_xml_roundtrip_reproduces_boq_and_costs(name, tmp_path):
+ rel, n = STCS[name]
+ s = Source(str(DOCS / rel))
+ xml = _xml(name)
+ data = read_xml(xml)
+ bt = DbfTable(s.files["BOQ11.DBF"])
+
+ def key(code, q, m, lab, sv):
+ return (
+ code,
+ round(float(q or 0), 4),
+ int(float(m or 0)),
+ int(float(lab or 0)),
+ int(float(sv or 0)),
+ )
+
+ want = collections.Counter(
+ key(
+ bt.get(i, "BDCODE1"),
+ *(bt.get(i, f"{c}{n}") for c in ("QTY", "M_AMT", "L_AMT", "S_AMT")),
+ )
+ for i in range(len(bt))
+ if _leaf(bt.get(i, "BDCODE1")) and float(bt.get(i, f"QTY{n}") or 0) != 0
+ )
+ got = collections.Counter(
+ key(r["BDCODE1"], r["QTY1"], r["M_AMT1"], r["L_AMT1"], r["S_AMT1"])
+ for r in data["boq"]
+ if _leaf(str(r["BDCODE1"])) and float(r["QTY1"] or 0) != 0
+ )
+ assert want == got, (
+ f"{name} 말단 행 다름 {list((want - got).items())[:3]} / {list((got - want).items())[:3]}"
+ )
+ codes = {
+ DbfTable(s.files["COSTN.DBF"]).get(i, "CODE")
+ for i in range(len(DbfTable(s.files["COSTN.DBF"])))
+ }
+ assert {r["CODE"] for r in data["costn"]} <= codes
+ out, rep = xml_to_project(s.files, xml)
+ pa, pb = DbfTable(s.files["PRJLST.DBF"]), DbfTable(out["PRJLST.DBF"])
+ for c in ("MCOST", "LCOST", "SCOST", "TCOST"):
+ assert pb.get(0, f"{c}1") == pa.get(0, f"{c}{n}"), c
+ assert pb.get(0, "COSTW1") == pa.get(0, f"COSTW{n}")
+ assert pb.get(0, "PNAME") == pa.get(0, "PNAME")
+ res = write_output(tmp_path, "xml", out, s.names, s.entries)
+ load = server.api_load(server.LoadReq(source_path=res["saved_stc"]))
+ assert load["costn_count"] == rep["costn_count"] and load["boq_count"] == rep["boq_count"]
+
+
+def test_xml_settings_from_xsd():
+ o = options()
+ assert (
+ len(o["prj_types_xsd"]) == 9
+ and "국가유산수리공사" in o["prj_types"]
+ and len(o["prj_types"]) == 8
+ )
+ names = [f["name"] for f in building_fields()]
+ assert len(names) == 36 and names[0] == "연면적" and "공사현장지역코드" in names
+ region = next(f for f in building_fields() if f["name"] == "공사현장지역코드")["enum"]
+ assert len(region) > 200 and {s["code"] for s in o["sido"]} == {c[:2] for c in region}
+ xml = _xml("봉화2024")
+ y = apply_settings(xml, {"prj_type": "조경공사"})
+ assert (
+ validate(y)["ok"]
+ and "<주공사>조경공사주공사>" in y
+ and "<공사구분>조경공사공사구분>" in y
+ )
+ z = apply_settings(
+ xml,
+ {
+ "prj_type": "건축공사",
+ "building": {"연면적": 1234.5, "공사현장지역코드": region[5], "공사현장주소": "시험"},
+ },
+ )
+ v = validate(z)
+ assert v["ok"], v["errors"][:2]
+ assert "<건축공사사업내용작성여부>true" in z and "<연면적>1234.5연면적>" in z
+ with pytest.raises(ValueError):
+ apply_settings(xml, {"prj_type": "건축공사", "building": {"공사현장지역코드": "99999"}})
+ with pytest.raises(ValueError):
+ apply_settings(xml, {"prj_type": "임도공사"})
diff --git a/resources/tester/test_stmate_file_save_extra.py b/resources/tester/test_stmate_file_save_extra.py
index 6513e446b..7f2074cdb 100644
--- a/resources/tester/test_stmate_file_save_extra.py
+++ b/resources/tester/test_stmate_file_save_extra.py
@@ -58,7 +58,13 @@ def test_costn_new_code_row_and_nagcd():
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": "M09991",
+ "NAME": "새 자재(계약 변환)",
+ "UNIT": "EA",
+ "MAMT1": 1234,
+ "TAMT1": 1234,
+ },
{"CODE": some, "NAGCD": 1},
]
_, brows, _ = parse_dbf_bytes(f["BOQ11.DBF"])
@@ -68,7 +74,10 @@ def test_costn_new_code_row_and_nagcd():
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 (
+ 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 고친 칸만)
@@ -119,10 +128,19 @@ def test_bdqty_edit_add_delete(stc):
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": "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))
+ 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"]
@@ -135,7 +153,9 @@ def test_qcode_rows_saved_and_loaded(work):
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"}])
+ server.SaveDbfReq(
+ source_path=str(stc), target_folder_name="qc3", qcode_rows=[{"CODE": "X1"}]
+ )
)
@@ -159,4 +179,6 @@ def test_kanjub_stage_change_writes_only_changed_keys(stc):
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}
+ 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/tools/stmate-test/PLAN.md b/tools/stmate-test/PLAN.md
index a3a22c042..340e6b726 100644
--- a/tools/stmate-test/PLAN.md
+++ b/tools/stmate-test/PLAN.md
@@ -27,7 +27,7 @@
## 진행률
-- 합계: 전체 246 · 구현 142 · 부분 78 · 없음 26 → 진행률 73.6%((142 + 78×0.5) / 246) · 구현만 세면 57.7%
+- 합계: 전체 246 · 구현 149 · 부분 78 · 없음 19 → 진행률 76.4%((149 + 78×0.5) / 246) · 구현만 세면 60.6%
### G1 화면·메뉴 (구현 16 · 부분 23 · 없음 15) — 50.9%
@@ -72,7 +72,7 @@
- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 없음
- [x] F1-40 사용처 찾기 — ST_FindUse wM_FindUse · 현재: 구현(단가 찾기·사용처 찾기)
- [ ] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 없음
-- [ ] F1-42 자료파일 업데이트(매칭 5단계·명칭/단가/조달코드) — wM_Mrg_BSC · 현재: 없음
+- [ ] F1-42 자료파일 업데이트(매칭 5단계·명칭/단가/조달코드) — wM_Mrg_BSC · 현재: 없음(기준자료(BSC) 암호 ZIP·난독 DBF 로 못 읽음)
- [ ] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 없음
- [x] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 구현(초기값 화면 전체 항목 · 저장 = Start8x.Ini 바뀐 키)
- [ ] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 없음
@@ -182,11 +182,11 @@
- [x] F3-28 내역↔BDQTY 수량 연결(CON_QTY) — ST_U_ConBD · 현재: 구현(코드 종류별 수량 · 내역↔BDQTY 자원 집계(test_qty_by_code_and_rollup_on_real_boq))
- [x] F3-29 수량코드(QCODE) 등록·검사 — 28번 §4 · 현재: 구현(수량코드 검사(test_qcode_check_and_next_code_and_file_signature))
-### G4 파일 입출력 (구현 20 · 부분 2 · 없음 8) — 70.0%
+### G4 파일 입출력 (구현 26 · 부분 2 · 없음 2) — 90.0%
- [x] F4-01 STC(ZIP) 열기 — ST_U_Archive · 09번 §5 · 현재: 구현(server.py 92)
- [x] F4-02 STC 저장(압축) — ST_U_Archive ComP_Data · 현재: 구현(stc_archive · 같은 파일은 원본 바이트 · 덮기 전 백업 · 7건 왕복 바이트 동일)
-- [ ] F4-03 STC 버전 검사 — Chk_STC_Ver · 현재: 부분(stc_read 필수 파일·머리·메모 서명 검사 · 원본 검사 규칙 근거 없음)
+- [x] F4-03 STC 버전 검사 — Chk_STC_Ver · 현재: 구현(stc_read 헤더 첫 바이트·코드 카운터·설정 미러 검사 · 원본 검사 함수 내부 미확인)
- [x] F4-04 DBF 읽기(03/E5/06 변형) — 09번 §5 · 현재: 구현(smt_parser 27)
- [x] F4-05 DBF 다시 쓰기 무손실 — 09번 §5 · 현재: 구현(dbf_rw 고친 칸만 다시 씀 · 한글·공백·삭제 레코드·머리 유지 · 7건 왕복)
- [x] F4-06 PRJLST.DBF 읽기 — 02번 · 현재: 구현
@@ -198,21 +198,21 @@
- [x] F4-12 COSTA.DBF 읽기 — 02번 · 현재: 구현(읽기(실무 표본 0건))
- [x] F4-13 BOQ1n 읽기·쓰기 — 02번 · 16번 · 현재: 구현(고친 칸만 · 2·3칸 보존 · 소계·TCOST 다시 셈 · 행 끼우기·지우기)
- [x] F4-14 RATE*.DBF 읽기 — 06번 §1 · 현재: 구현(server.py 474)
-- [ ] F4-15 JB_* 기준요율표 43종 읽기(구형·2020 확장) — 10번 · 12번 §9 · 현재: 부분(JB_R 고정 셀만)
+- [x] F4-15 JB_* 기준요율표 43종 읽기(구형·2020 확장) — 10번 · 12번 §9 · 현재: 구현(conv_jb JB 요율표 판·모양 문서 일치 · 문서 일치 사례 재현)
- [x] F4-16 KONGJONG.DBF 공종 분류 — 02번 · 현재: 구현(읽기(실무 표본 공종 1건 내외))
- [x] F4-17 SET_REP.DBF 서식 카탈로그 읽기 — 20번 §3 · 현재: 구현(표시)
- [x] F4-18 QCODE.DBF·QCODE.SMT 읽기 — 02번 · 28번 · 현재: 구현(읽기(실무 표본 0건))
- [x] F4-19 Start8x.Ini 설치 설정 읽기 — 20번 §2 · 현재: 구현(server start_ini · 산근·구성 계산 설정에 사용)
- [x] F4-20 조달청 호환규정 XML 내보내기 — 30번 · uM_XML_Info · 현재: 구현(report_pps · 공식 XSD V1.0.5 검증 5건 · 총공사비·직접비 저장값 1원(test_stmate_report_pps))
-- [ ] F4-21 조달청 XML·조달 형식 가져오기(Conv_XML_ST8·Conv_Jodal_ST8) — 28번 §3 · 현재: 없음
-- [ ] F4-22 XML 변환 설정(공사유형 8·지역·건축 사업내용) — uM_XML_Info · uM_ConstType · 현재: 없음
+- [x] F4-21 조달청 XML·조달 형식 가져오기(Conv_XML_ST8·Conv_Jodal_ST8) — 28번 §3 · 현재: 구현(conv_xml · 5건 내보내기 → 가져오기 왕복 말단 행·차수 직접비·총공사비 일치(test_stmate_conv_xml) · 옛 조달 형식 근거 없음)
+- [x] F4-22 XML 변환 설정(공사유형 8·지역·건축 사업내용) — uM_XML_Info · uM_ConstType · 현재: 구현(conv_xml_cfg 공사유형·지역·건축 사업내용 XSD 에서 읽음 · XSD 검증)
- [ ] F4-23 JDL 내보내기·검사 — frm_JDLAnno · JDLCHK.*.dll · 현재: 없음(근거 없는 '|' 줄 형식 app.js 569)
-- [ ] F4-24 엑셀 → 내역 불러오기 — wM_From_XLS · 현재: 없음
-- [ ] F4-25 엑셀 역등록(CONV_XLS_2_ST·INITIAL)·엑셀 → BDQTY — 24번 §3 · 현재: 없음
+- [x] F4-24 엑셀 → 내역 불러오기 — wM_From_XLS · 현재: 구현(conv_xls_boq · 원본 엑셀 5권 → STC 내역 행 전 칸 일치(test_stmate_conv_xls))
+- [ ] F4-25 엑셀 역등록(CONV_XLS_2_ST·INITIAL)·엑셀 → BDQTY — 24번 §3 · 현재: 부분(단가 목록·산출식·구성표 코드·수량 일치 · 3건 새 공사 일치 · 중기 구성 PRPT·PRIN 뜻·이름 칸 미확정)
- [x] F4-30 BDQTY.DBF 구성표 복호 읽기 — STC_난독화_상태 · 현재: 구현(bdqty.py 자리별 회전·더하기 표 283자리 · 사용자 승인 · 글자 칸 제외)
-- [ ] F4-26 자재 기준자료 XLS 입출력 — wM_JajeXLS · 현재: 없음
-- [ ] F4-27 기준 BOQ·코드 변경 엑셀 왕복 — 24번 §3 · ST_U_ChgCode · 현재: 없음
-- [ ] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 없음
+- [ ] F4-26 자재 기준자료 XLS 입출력 — wM_JajeXLS · 현재: 부분(자재 기준자료 엑셀 왕복 · 원본 화면·방향 미확인)
+- [x] F4-27 기준 BOQ·코드 변경 엑셀 왕복 — 24번 §3 · ST_U_ChgCode · 현재: 구현(stc_codechg 내역 8표 엑셀 왕복 · 코드 변경 되돌리면 7건 원래 바이트)
+- [ ] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 없음(구버전 변환 근거 없음)
- [x] F4-29 자동백업·자료 복원 — wM_D_Restore · wM_ST_Restore · 현재: 구현(api_file 자동 백업·복원)
### G5 보고서·내보내기 (구현 19 · 부분 17 · 없음 0) — 76.4%
@@ -254,11 +254,11 @@
- [ ] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 부분(금액 한글 표기 · 문서 예시 1건 확인)
- [x] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 구현(SET_REP 서식 제목·시트 이름 적용 · 보고서 시험 포함)
-### G6 설계변경·계약·실행·기성 (구현 13 · 부분 22 · 없음 1) — 66.7%
+### G6 설계변경·계약·실행·기성 (구현 14 · 부분 22 · 없음 0) — 69.4%
- [ ] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 부분(계산 = 차수별 수량·단가·원가계산서 · 저장은 2·3 칸 덮어씀 view_prj_boq 560)
- [ ] F6-02 통합형 BOQ11+BOQ12에서 BOQ12 현재안 — 16번 §4.5 · 현재: 부분(울진 BOQ12 = 계약내역(STMATE 착공내역서) 판명 · contract_calc 로 BOQ11 에서 1원 재현)
-- [ ] F6-03 분리형 변경 STC 생성·차수 인계 — 16번 §4.6 · 현재: 없음
+- [x] F6-03 분리형 변경 STC 생성·차수 인계 — 16번 §4.6 · 현재: 구현(stc_change 소광 원설계 → 실제 변경1차 차수1 말단 47행·직접비·요율 41키 일치(test_stmate_conv_change))
- [x] F6-04 변경 수량 입력 — 설계변경 · 현재: 구현(change_calc · 영덕·소광 변경 BOQ11 1·2차수 수량·단가·금액 일치)
- [x] F6-05 변경 단가·신규 공종·삭제 항목(0 유지) — 08번 §2 · 현재: 구현(change_calc 신규·삭제(0 유지) · 변경 2건 대조)
- [x] F6-06 변경 직접비 당초·변경·증감 — 19번 §6.5 · 현재: 구현(calc.js 661~670)
diff --git a/tools/stmate-test/api_conv.py b/tools/stmate-test/api_conv.py
new file mode 100644
index 000000000..efbf31849
--- /dev/null
+++ b/tools/stmate-test/api_conv.py
@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+"""변환·가져오기 끼워 넣기 API — 조달청 XML 가져오기·변환 설정 · 엑셀 내역/역등록 · 기준 BOQ·자재·코드 엑셀 왕복 ·
+분리형 변경 STC · JB_* 기준 제비율표.
+
+근거: 28번 §3(ST_U_Convert) · 30번(호환규정 XSD) · 24번 §3(ST_P_XLS CONV_XLS_2_ST · XLS_2_BDREC · MK_BSCBOQ_EXL) ·
+ 16번 §4.6(차수 인계) · 10번 §4(JB_* ↔ RATE*) · 10번(ST_U_ChgCode)
+쓰기는 tools/stmate-test/work_dbf/ 아래만(server.WORK_DBF_ROOT) · 엑셀·XML 원본은 읽기만.
+파일 받기: 경로(서버 PC) 또는 b64(화면에서 고른 파일).
+"""
+
+from __future__ import annotations
+
+import base64
+import pathlib
+
+from conv_jb import JB_DIR, list_jb, match_project, read_jb
+from conv_xls_apply import import_boq, register
+from conv_xls_out import export_boq_xlsx, export_code_xlsx, export_jaje_xlsx, import_code_xlsx
+from conv_xls_out import import_jaje_xlsx
+from conv_xml import xml_to_project
+from conv_xml_cfg import apply_settings, options, validate
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import Response
+from pydantic import BaseModel
+from stc_change import split_change
+from stc_codechg import change_codes
+from stc_project import safe_name, write_output
+from stc_read import Source
+
+router = APIRouter()
+XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+
+
+def _root() -> pathlib.Path:
+ import server
+
+ return server.WORK_DBF_ROOT
+
+
+def _src(path: str) -> Source:
+ try:
+ return Source(path)
+ except FileNotFoundError:
+ raise HTTPException(404, f"경로 없음: {path}")
+ except ValueError as e:
+ raise HTTPException(400, str(e))
+
+
+def _blob(path: str = "", b64: str = "", text: str = ""):
+ """경로 · base64 · 글 중 하나 → bytes/str."""
+ if text:
+ return text
+ if b64:
+ return base64.b64decode(b64.split(",", 1)[-1])
+ p = pathlib.Path(path or "")
+ if not path or not p.is_file():
+ raise HTTPException(404, f"파일 없음: {path}")
+ return p.read_bytes()
+
+
+def _guard(fn, *a, **k):
+ try:
+ return fn(*a, **k)
+ except (ValueError, KeyError) as e:
+ raise HTTPException(400, str(e))
+
+
+def _xlsx(data: bytes, name: str) -> Response:
+ from urllib.parse import quote
+
+ disp = f"attachment; filename*=UTF-8''{quote(name)}"
+ return Response(content=data, media_type=XLSX, headers={"Content-Disposition": disp})
+
+
+class FileIn(BaseModel):
+ path: str = ""
+ b64: str = ""
+ text: str = ""
+
+
+class XmlImportReq(BaseModel):
+ template_path: str
+ xml: FileIn
+ pname: str = ""
+ target_folder_name: str = ""
+
+
+class XmlCfgReq(BaseModel):
+ xml_text: str
+ settings: dict = {}
+
+
+class XlsBoqReq(BaseModel):
+ source_path: str
+ xlsx: FileIn
+ table: str = ""
+ kind: str = "내역서"
+ target_folder_name: str = ""
+
+
+class XlsRegReq(BaseModel):
+ template_path: str
+ xlsx: FileIn
+ pname: str = ""
+ target_folder_name: str = ""
+
+
+class SrcReq(BaseModel):
+ source_path: str
+ table: str = ""
+
+
+class JajeImportReq(BaseModel):
+ source_path: str
+ xlsx: FileIn
+
+
+class CodeChgReq(BaseModel):
+ source_path: str
+ mapping: dict[str, str] = {}
+ xlsx: FileIn | None = None
+ target_folder_name: str = ""
+
+
+class SplitReq(BaseModel):
+ source_path: str
+ pcode: str = ""
+ target_folder_name: str = ""
+
+
+class JbReq(BaseModel):
+ path: str
+
+
+def _save(target: str, files: dict, s: Source, extra: dict) -> dict:
+ res = write_output(_root(), safe_name(target), files, s.names, s.entries)
+ return {**res, **extra}
+
+
+# ── 조달청 XML ──
+@router.post("/api/conv/xml-import")
+def api_xml_import(req: XmlImportReq):
+ """XML → 새 공사(틀 = template_path 의 요율·서식·설정) — work_dbf/<이름>/ 와 STC."""
+ t = _src(req.template_path)
+ files, rep = _guard(xml_to_project, t.files, _blob(**req.xml.model_dump()), req.pname)
+ return _save(
+ req.target_folder_name or rep["info"]["pname"] or "XML가져옴", files, t, {"report": rep}
+ )
+
+
+@router.get("/api/conv/xml-options")
+def api_xml_options():
+ return options()
+
+
+@router.post("/api/conv/xml-settings")
+def api_xml_settings(req: XmlCfgReq):
+ """내보낸 XML 에 공사유형·건축 사업내용 반영 + XSD 검증."""
+ xml = _guard(apply_settings, req.xml_text, req.settings)
+ return {"xml": xml, "validation": validate(xml)}
+
+
+# ── 엑셀 → 내역 · 역등록 ──
+@router.post("/api/conv/xls-boq")
+def api_xls_boq(req: XlsBoqReq):
+ """STMATE 엑셀 내역서 → 지금 공사의 BOQ 표를 바꿔 작업본으로 씀(wM_From_XLS)."""
+ s = _src(req.source_path)
+ files, rep = _guard(import_boq, s.files, _blob(**req.xlsx.model_dump()), req.table, req.kind)
+ return _save(req.target_folder_name or f"{s.path.stem}_엑셀내역", files, s, {"report": rep})
+
+
+@router.post("/api/conv/xls-register")
+def api_xls_register(req: XlsRegReq):
+ """STMATE 엑셀 한 권 → 새 공사(CONV_XLS_2_ST · XLS_2_BDREC)."""
+ t = _src(req.template_path)
+ files, rep = _guard(register, t.files, _blob(**req.xlsx.model_dump()), req.pname)
+ return _save(req.target_folder_name or rep["pname"], files, t, {"report": rep})
+
+
+# ── 엑셀 내보내기 짝 ──
+@router.post("/api/conv/boq-xlsx")
+def api_boq_xlsx(req: SrcReq):
+ s = _src(req.source_path)
+ return _xlsx(_guard(export_boq_xlsx, s.files, req.table), f"{s.path.stem}_기준BOQ.xlsx")
+
+
+@router.post("/api/conv/jaje-xlsx")
+def api_jaje_xlsx(req: SrcReq):
+ s = _src(req.source_path)
+ return _xlsx(_guard(export_jaje_xlsx, s.files), f"{s.path.stem}_자재기준자료.xlsx")
+
+
+@router.post("/api/conv/jaje-import")
+def api_jaje_import(req: JajeImportReq):
+ """자재 기준자료 엑셀 → costn_updates(바뀐 칸만) — 화면이 /api/save 로 넘김."""
+ s = _src(req.source_path)
+ ups = _guard(import_jaje_xlsx, s.files, _blob(**req.xlsx.model_dump()))
+ return {"costn_updates": ups, "count": len(ups)}
+
+
+@router.post("/api/conv/code-xlsx")
+def api_code_xlsx(req: SrcReq):
+ s = _src(req.source_path)
+ return _xlsx(_guard(export_code_xlsx, s.files), f"{s.path.stem}_코드변경표.xlsx")
+
+
+@router.post("/api/conv/code-change")
+def api_code_change(req: CodeChgReq):
+ """코드 일괄 변경 — mapping 또는 코드 변경표 엑셀(새 코드 칸)."""
+ s = _src(req.source_path)
+ mapping = dict(req.mapping)
+ if req.xlsx and (req.xlsx.path or req.xlsx.b64):
+ mapping.update(_guard(import_code_xlsx, _blob(**req.xlsx.model_dump())))
+ files, log = _guard(change_codes, s.files, mapping)
+ return _save(
+ req.target_folder_name or f"{s.path.stem}_코드변경",
+ files,
+ s,
+ {"mapping": mapping, "change_count": len(log)},
+ )
+
+
+# ── 분리형 변경 STC ──
+@router.post("/api/conv/split-change")
+def api_split_change(req: SplitReq):
+ """앞 파일의 현재안 → 다음 변경 차수 STC(BOQ11 차수1 = 현재안 · 차수2 = 출발점)."""
+ s = _src(req.source_path)
+ files, rep = _guard(split_change, s.files, req.pcode)
+ return _save(
+ req.target_folder_name or f"{s.path.stem}_변경{rep['k_ord']}차", files, s, {"report": rep}
+ )
+
+
+# ── JB_* 기준 제비율표(읽기 전용) ──
+@router.get("/api/conv/jb-list")
+def api_jb_list():
+ return {"dir": str(JB_DIR), "files": list_jb()}
+
+
+@router.post("/api/conv/jb-read")
+def api_jb_read(req: JbReq):
+ p = pathlib.Path(req.path).resolve()
+ if JB_DIR.resolve() not in p.parents or not p.is_file():
+ raise HTTPException(400, "설치 폴더 ST_BSC 의 JB_* 만 읽음")
+ return read_jb(p)
+
+
+@router.post("/api/conv/jb-match")
+def api_jb_match(req: SrcReq):
+ """프로젝트 RATE* 표 ↔ 기준 JB 파일(셀 모두 같은 것 · 아니면 가장 가까운 것)."""
+ return match_project(_src(req.source_path).files)
diff --git a/tools/stmate-test/conv_jb.py b/tools/stmate-test/conv_jb.py
new file mode 100644
index 000000000..7907fde03
--- /dev/null
+++ b/tools/stmate-test/conv_jb.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+"""기준 제비율표 JB_* 43종 읽기(읽기 전용) — 설치 폴더 System\\ST_BSC\\*.JB_1·JB_M·JB_R·JB_RA.
+
+근거: 10_System_186개_파일분석장부.md §4(배포파일 ↔ DBF 구조 ↔ 프로젝트 표) · 12번 §9(5행 묶음 · 열 1~5 간접노무 ·
+ 6~10 기타경비 · 2020-05-27 확장 = 뒤에 15열 덧붙임) · 파일 이름 '2026(상_0413)_제비율.JB_R' = 기준일
+판(edition): JB_1 41×10 · JB_M 25×10 · JB_R 구형 50×20 · JB_R 2020 확장 50×35 · JB_RA 50×20
+프로젝트 짝: JB_1 → RATE.DBF · JB_M → RATE_M.DBF · JB_R → RATE_R.DBF · JB_RA → RATE_RA.DBF
+"""
+
+from __future__ import annotations
+
+import pathlib
+import re
+
+from dbf_rw import DbfTable
+
+JB_DIR = pathlib.Path(r"C:\경영정보(주)\System\ST_BSC")
+TABLE_OF = {"JB_1": "RATE.DBF", "JB_M": "RATE_M.DBF", "JB_R": "RATE_R.DBF", "JB_RA": "RATE_RA.DBF"}
+SHAPES = {"JB_1": {(41, 10)}, "JB_M": {(25, 10)}, "JB_R": {(50, 20), (50, 35)}, "JB_RA": {(50, 20)}}
+
+
+def _num(s: str) -> float:
+ try:
+ return float(s.strip() or 0)
+ except ValueError:
+ return 0.0
+
+
+def matrix_of(b: bytes) -> tuple[list[str], list[list[float]]]:
+ t = DbfTable(b)
+ return t.order, [[_num(t.get(i, f)) for f in t.order] for i in range(len(t))]
+
+
+def base_date(name: str) -> str:
+ """'2026(상_0413,표준시장)' → '2026-04-13' · 월일 없으면 연도만."""
+ m = re.match(r"^(\d{4})\([^_]*_?(\d{4})?", name)
+ if not m:
+ return ""
+ y, md = m.group(1), m.group(2)
+ return f"{y}-{md[:2]}-{md[2:]}" if md else y
+
+
+def edition(kind: str, rows: int, cols: int) -> str:
+ if kind == "JB_R":
+ return "2020 확장(35열 · 국가유산 열)" if cols >= 35 else "구형(20열)"
+ return "단일판"
+
+
+def read_jb(path) -> dict:
+ p = pathlib.Path(path)
+ kind = p.suffix.upper().lstrip(".")
+ fields, m = matrix_of(p.read_bytes())
+ shape = (len(m), len(fields))
+ return {
+ "name": p.name,
+ "path": str(p),
+ "kind": kind,
+ "table": TABLE_OF.get(kind, ""),
+ "date": base_date(p.stem),
+ "actual": "실적" in p.stem or "표준시장" in p.stem,
+ "rows": shape[0],
+ "cols": shape[1],
+ "edition": edition(kind, *shape),
+ "shape_ok": shape in SHAPES.get(kind, set()),
+ "fields": fields,
+ "matrix": m,
+ }
+
+
+def list_jb(root: pathlib.Path | None = None) -> list[dict]:
+ """설치 폴더 JB_* 전부(행렬 제외 요약) — 종류·기준일 차례."""
+ d = pathlib.Path(root or JB_DIR)
+ if not d.exists():
+ return []
+ out = []
+ for p in sorted(d.glob("*.JB_*")):
+ r = read_jb(p)
+ r.pop("matrix")
+ out.append(r)
+ return sorted(out, key=lambda r: (r["kind"], r["date"], r["name"]))
+
+
+def match_project(files: dict, root: pathlib.Path | None = None) -> dict:
+ """프로젝트 RATE*.DBF 마다 셀이 모두 같은 기준 JB 파일(없으면 가장 적게 다른 것과 다른 셀 수)."""
+ d = pathlib.Path(root or JB_DIR)
+ cands = [read_jb(p) for p in sorted(d.glob("*.JB_*"))] if d.exists() else []
+ out = {}
+ for kind, table in TABLE_OF.items():
+ if table not in files:
+ continue
+ _f, pm = matrix_of(files[table])
+ best = None
+ for c in (x for x in cands if x["kind"] == kind):
+ if len(c["matrix"]) != len(pm):
+ continue
+ diff = sum(
+ 1
+ for a, b in zip(c["matrix"], pm)
+ for j in range(max(len(a), len(b)))
+ if abs((a[j] if j < len(a) else 0.0) - (b[j] if j < len(b) else 0.0)) > 1e-9
+ )
+ if best is None or diff < best[0]:
+ best = (diff, c["name"], c["date"])
+ if best:
+ out[table] = {
+ "jb": best[1],
+ "date": best[2],
+ "diff_cells": best[0],
+ "exact": best[0] == 0,
+ }
+ return out
diff --git a/tools/stmate-test/conv_xls_apply.py b/tools/stmate-test/conv_xls_apply.py
new file mode 100644
index 000000000..ac8161088
--- /dev/null
+++ b/tools/stmate-test/conv_xls_apply.py
@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+"""엑셀 → 프로젝트 반영 — 내역 불러오기(wM_From_XLS) · 역등록(CONV_XLS_2_ST · XLS_2_BDREC).
+
+근거: conv_xls_boq.py · conv_xls_res.py(INITIAL 메타 복원 규칙) · STC_왕복검증.md §3(원본을 틀로) ·
+ 16번 §4(소계·직접비 = PRJLST ?COSTn) · stc_project.new_project(빈 틀)
+ - 표 쓰기: 틀 DBF 의 필드 그대로 · 새 레코드 = 숫자 칸 0 · 논리 칸 빈칸 · 메모 칸 빈 포인터 · 주어진 칸만 씀
+ - 내역 불러오기: 고른 BOQ 표를 엑셀 행으로 통째로 바꿈 · BQCODE·EQCODE·단위는 그 프로젝트 COSTN 값 ·
+ PRJLST 직접비(?COSTn)는 소계 다시 셈(stc_save.recompute_boq) 결과
+ - 역등록: 틀 프로젝트(요율·서식·설정) + 목록표 COSTN + 산출근거 COSTN.SMT + 구성표 BDQTY + 내역 BOQ11 ·
+ PRJLST 공사명 = [공사정보] PNAME
+ - BDQTY 새 행 = 틀 BDQTY 의 같은 구성 코드 레코드를 옮겨 바뀐 칸만 암호로(stc_save_extra.save_bdqty)
+"""
+
+from __future__ import annotations
+
+from conv_xls_boq import (
+ boq_from_xlsx,
+ boq_rows_from_sheet,
+ fill_from_costn,
+ find_sheet,
+ load_book,
+ read_initial,
+)
+from conv_xls_res import comp_from_san, comp_from_sheets, costn_from_lists, memos_from_sheet
+from dbf_memo import BLANK_PTR, SixMemo, memo_text_bytes
+from dbf_rw import DbfTable
+from smt_parser import parse_dbf_bytes
+from stc_project import new_project
+from stc_save import SaveLog, pick_boq_table, prj_row_for, recompute_boq
+from stc_save_extra import save_bdqty
+
+
+def rows_to_table(template: bytes, rows: list[dict]) -> DbfTable:
+ """틀 DBF 필드로 rows 를 담은 새 표(레코드 전부 새로 씀)."""
+ t = DbfTable(template)
+ zero = bytearray(b" " * t.rlen)
+ for f in t.fields.values():
+ if f.type in ("N", "F"):
+ zero[f.off : f.off + f.len] = t.encode_value(f.name, 0)
+ t.recs, t.dirty = [], True
+ for i, r in enumerate(rows):
+ t.recs.append(bytearray(zero))
+ for k, v in r.items():
+ if k.startswith("_") or k not in t.fields or t.fields[k].type == "M":
+ continue
+ if v is None or (isinstance(v, str) and not v.strip() and t.fields[k].type != "C"):
+ continue
+ t.set_raw(i, k, t.encode_value(k, v))
+ return t
+
+
+def _direct_to_prj(files: dict, out: dict, bt: DbfTable, name: str, log: SaveLog) -> dict:
+ """소계 행 다시 셈 + PRJLST 그 공사 행 ?COSTn = 차수별 직접비."""
+ slots = {s for s in "12345" if f"T_AMT{s}" in bt.fields}
+ direct = recompute_boq(bt, name, slots, log)
+ if "PRJLST.DBF" in files:
+ pt = DbfTable(out.get("PRJLST.DBF", files["PRJLST.DBF"]))
+ pi = prj_row_for(pt, name)
+ for s, vals in direct.items():
+ for c, v in zip(("MCOST", "LCOST", "SCOST", "TCOST"), vals):
+ if f"{c}{s}" in pt.fields:
+ log.set(pt, "PRJLST.DBF", pi, f"{c}{s}", v)
+ if pt.dirty:
+ out["PRJLST.DBF"] = pt.to_bytes()
+ return {s: [float(x) for x in v] for s, v in direct.items()}
+
+
+def import_boq(files: dict, src, table: str = "", kind: str = "내역서") -> tuple[dict, dict]:
+ """엑셀 내역 → 프로젝트 BOQ 표 통째 바꿈 — (새 files, 보고)."""
+ name = pick_boq_table(files, table)
+ if not name:
+ raise ValueError("BOQ 표 없음")
+ got = boq_from_xlsx(src, kind)
+ rows = got["rows"]
+ costn = parse_dbf_bytes(files["COSTN.DBF"])[1] if "COSTN.DBF" in files else []
+ filled = fill_from_costn(rows, costn)
+ known = {str(r.get("CODE", "")).strip() for r in costn}
+ unknown = sorted(
+ {
+ r["BDCODE1"]
+ for r in rows
+ if r["BDCODE1"][:1] in tuple("MLSXBDW") and r["BDCODE1"] not in known
+ }
+ )
+ out = dict(files)
+ log = SaveLog()
+ bt = rows_to_table(files[name], rows)
+ direct = _direct_to_prj(files, out, bt, name, log)
+ out[name] = bt.to_bytes()
+ return out, {
+ "table": name,
+ "sheet": got["sheet"],
+ "row_count": len(rows),
+ "costn_filled": filled,
+ "unknown_codes": unknown,
+ "direct": direct,
+ }
+
+
+def _costn_table(template: bytes, rows: list[dict], memos: dict[str, str]) -> tuple[bytes, bytes]:
+ t = rows_to_table(template, rows)
+ memo = SixMemo(None)
+ if "IFORM" in t.fields:
+ for i in range(len(t)):
+ text = memos.get(t.get(i, "CODE"))
+ ptr = memo.write(BLANK_PTR, memo_text_bytes(text)) if text else BLANK_PTR
+ t.set_raw(i, "IFORM", ptr)
+ return t.to_bytes(), memo.to_bytes()
+
+
+def register(template: dict, src, pname: str = "") -> tuple[dict, dict]:
+ """역등록 — 엑셀 한 권으로 새 프로젝트 files 를 만듦(틀 = 다른 프로젝트의 요율·서식·설정)."""
+ wb = load_book(src)
+ info = read_initial(wb)
+ sec = info["sections"].get("공사정보", {})
+ name = pname or str((sec.get("PNAME") or ["엑셀 역등록 공사"])[0])
+ out = new_project(template, name)
+ costn = costn_from_lists(wb, info)
+ memos = memos_from_sheet(wb, info)
+ out["COSTN.DBF"], out["COSTN.SMT"] = _costn_table(template["COSTN.DBF"], costn, memos)
+ spec = find_sheet(info, "내역서")
+ if not spec or spec["sheet"] not in wb.sheetnames:
+ raise ValueError("INITIAL 목차에 내역서 시트 없음")
+ rows = boq_rows_from_sheet(wb[spec["sheet"]], spec["cols"])
+ fill_from_costn(rows, costn)
+ log = SaveLog()
+ bt = rows_to_table(template["BOQ11.DBF"], rows)
+ direct = _direct_to_prj(out, out, bt, "BOQ11.DBF", log)
+ out["BOQ11.DBF"] = bt.to_bytes()
+ comp = comp_from_sheets(wb, info) + comp_from_san(wb, info)
+ if comp and "BDQTY.DBF" in template:
+ base = template["BDQTY.DBF"] # 틀 레코드(같은 구성 코드)의 이름 암호 칸을 옮겨 씀
+ out["BDQTY.DBF"] = save_bdqty(base, comp, log, strict=False) or base
+ return out, {
+ "pname": name,
+ "costn_count": len(costn),
+ "memo_count": len(memos),
+ "boq_count": len(rows),
+ "bdqty_count": len(comp),
+ "direct": direct,
+ "sheets": {k: v["sheet"] for k, v in info["sheets"].items()},
+ }
diff --git a/tools/stmate-test/conv_xls_boq.py b/tools/stmate-test/conv_xls_boq.py
index 44d2242a9..b41edc2ff 100644
--- a/tools/stmate-test/conv_xls_boq.py
+++ b/tools/stmate-test/conv_xls_boq.py
@@ -37,6 +37,7 @@ INFO_FIELD = {
"RCU": "RCU",
"KWN": "KWNGP",
"BLA": "BL_AMT",
+ "IPR": "PRPT", # 비율 줄(PR) 밑수 표시
}
INITIAL = "INITIAL"
diff --git a/tools/stmate-test/conv_xls_out.py b/tools/stmate-test/conv_xls_out.py
new file mode 100644
index 000000000..e380ec5b2
--- /dev/null
+++ b/tools/stmate-test/conv_xls_out.py
@@ -0,0 +1,242 @@
+# -*- coding: utf-8 -*-
+"""엑셀 내보내기 짝 — 기준 BOQ(MK_BSCBOQ_EXL ↔ MK_EXL_BSCBOQ) · 자재 기준자료(wM_JajeXLS) · 코드 변경표(ST_U_ChgCode).
+
+근거: 24_모듈구조_전수.md §3(기준 BOQ 양방향 · CONV_XLS_2_ST) · XLSX_INITIAL_구조.md(재등록 메타) ·
+ 10번(ST_U_ChgCode 코드 일괄·선택 변경 · Excel 왕복) · 34번(wM_JajeXLS 자재 기준자료 XLS)
+ - 기준 BOQ: STMATE 와 같은 꼴 — 「설계내역서」 시트 + 숨은 INFO 열(COD·END·PSKP…) + veryHidden 「〓 INITIAL 〓」
+ → conv_xls_boq 로 되읽으면 같은 내역 행(닫는 행·페이지 넘김·SORTCODE 포함)
+ - 자재 기준자료: COSTN M 코드 · 단가 출처 5칸(AMT_11~51 · 이름 = Start8x.Ini JMOK1~5) · 적용 단가(AMT_61) ·
+ 되읽으면 바뀐 칸만 costn_updates
+ - 코드 변경표: 지금 코드 · 명칭 · 규격 · 단위 · 새 코드(빈칸 = 그대로) → {옛: 새}
+ 화면·방향·선택지 원본 미확인(34번) — 칸 배치는 STMATE 목록표 꼴을 따름.
+"""
+
+from __future__ import annotations
+
+import io
+import re
+
+from conv_xls_boq import COLS, INITIAL, LEVEL_NAME, _numv, load_book
+from smt_parser import parse_dbf_bytes, parse_ini_bytes
+from stc_save import STRUCT_RE, pick_boq_table
+
+BOQ_HEAD = [
+ "공 종",
+ "명 칭",
+ "규 격",
+ "수 량",
+ "단위",
+ "합 계",
+ None,
+ "노 무 비",
+ None,
+ "재 료 비",
+ None,
+ "경 비",
+ None,
+ "비 고",
+]
+BOQ_COLS = {
+ "ITNAME": 1,
+ "NAME": 2,
+ "SIZE": 3,
+ "QTY": 4,
+ "UNIT": 5,
+ "TAMT": 6,
+ "TAMOUNT": 7,
+ "LAMT": 8,
+ "LAMOUNT": 9,
+}
+BOQ_COLS.update({"MAMT": 10, "MAMOUNT": 11, "SAMT": 12, "SAMOUNT": 13, "INFO": 15})
+INFO_OUT = (("EQCODE", "EQC"), ("PRIN", "PRI"), ("PROUT", "PRO"), ("RCU", "RCU"), ("KWNGP", "KWN"))
+
+
+def _info(pairs: list[tuple[str, object]]) -> str:
+ return "_x0007_" + "".join(f"`{k}|{v}_x0005_" for k, v in pairs) + "`"
+
+
+def _wb():
+ import openpyxl
+
+ return openpyxl.Workbook()
+
+
+def _initial(wb, pname: str, sheets: list[tuple[str, str, dict]]):
+ ws = wb.create_sheet("〓 INITIAL 〓")
+ ws.sheet_state = "veryHidden"
+ ws.append([None, "※※ 현재 Sheet를 수정 또는 삭제하지 마십시요 ※※"])
+ ws.append([None, "[Sheet정보]", False, 24])
+ ws.append([None, "목차", "Sheet", "FPos", *COLS])
+ for title, sheet, cols in sheets:
+ ws.append([None, title, sheet, None, *[cols.get(c) for c in COLS]])
+ ws.append([None, "[공사정보]"])
+ ws.append([None, "PNAME", pname])
+ ws.append([None, "[.끝.]"])
+
+
+def export_boq_xlsx(files: dict, table: str = "") -> bytes:
+ """BOQ 표 → STMATE 꼴 설계내역서 xlsx(INITIAL 메타 포함)."""
+ name = pick_boq_table(files, table)
+ prj = parse_dbf_bytes(files["PRJLST.DBF"])[1] if "PRJLST.DBF" in files else [{}]
+ pname = (prj[0] if prj else {}).get("PNAME", "").strip()
+ wb = _wb()
+ ws = wb.active
+ ws.title = "설계내역서"
+ ws.append(["설 계 내 역 서"])
+ ws.append([f"공사명 : {pname}"])
+ ws.append(BOQ_HEAD)
+ ws.append([None, None, None, None, None] + ["단 가", "금 액"] * 4)
+ spos = 5
+ rows = parse_dbf_bytes(files[name])[1] # 글 칸 앞 빈칸 보존(들여쓰기)
+ sort_at = {r["SORTCODE"]: k for k, r in enumerate(rows)}
+ lines: list[list] = []
+ line_of: dict[int, int] = {} # BOQ 행 → 엑셀 줄
+ for k, r in enumerate(rows):
+ bd = r["BDCODE1"].strip()
+ line_of[k] = spos + len(lines)
+ if bd.startswith(("E", "C")) and STRUCT_RE.match(bd):
+ continue # 닫는 행은 머리 줄에 금액이 실림
+ if bd == ":":
+ if lines:
+ lines[-1][14] = lines[-1][14][:-1] + "`PSKP|1_x0005_`"
+ continue
+ amt = [_numv(r.get(f"{c}AMT1")) for c in "TLMS"]
+ tot = [_numv(r.get(f"{c}_AMT1")) for c in "TLMS"]
+ cells = [
+ r["ITNUM"],
+ r["NAME1"],
+ r["SIZE1"] or None,
+ _numv(r["QTY1"]) or None,
+ r["UNIT1"] or None,
+ ]
+ for a, b in zip(amt, tot):
+ cells += [a, b]
+ cells.append(r.get("BQCODE") or None)
+ if bd.startswith("T") and STRUCT_RE.match(bd):
+ end = sort_at.get(r["ENDCODE"])
+ close = rows[end]["BDCODE1"].strip() if end is not None else "E" + bd[1:]
+ meta = [("COD", close), ("QTY1", 1), ("EXI", 0), ("ITT", 0), ("END", f"@{end}")]
+ elif not bd:
+ meta = [("COD", ""), ("QTY1", 1), ("EXI", 1), ("ITT", 0)]
+ else:
+ meta = [
+ ("COD", bd),
+ ("QTY1", 1),
+ ("EXI", r.get("EXIST1") or 0),
+ ("IPR", r.get("PRPT") or 0),
+ ]
+ meta += [
+ (m, r.get(f)) for f, m in INFO_OUT if str(r.get(f) or "").strip() not in ("", "0")
+ ]
+ if r["UNIT1"].strip():
+ meta.append(("UNI", r["UNIT1"].strip()))
+ meta.append(("BLA", r.get("BL_AMT") or "F"))
+ lines.append(cells + [_info(meta)])
+ for ln in lines: # END = 닫는 행 다음 첫 엑셀 줄
+ m = re.search(r"`END\|@(\d+)", ln[14])
+ if m:
+ k = int(m.group(1))
+ ln[14] = ln[14].replace(m.group(0), f"`END|{line_of.get(k + 1, spos + len(lines))}")
+ ws.append(ln)
+ ws.column_dimensions["O"].hidden = True
+ cols = dict(BOQ_COLS, SPOS=spos, EPOS=spos + len(lines))
+ _initial(wb, pname, [("11. 내역서", "설계내역서", cols)])
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+# ── 자재 기준자료 ──
+JAJE_HEAD = ["코드", "명 칭", "규 격", "단위"]
+
+
+def export_jaje_xlsx(files: dict) -> bytes:
+ """COSTN M 코드 → 자재 기준자료 xlsx(출처 5칸 · 적용 단가)."""
+ ini = parse_ini_bytes(files.get("START8X.INI") or files.get("STINI.MEM") or b"")
+ src = [ini.get(f"JMOK{i}", f"출처{i}") for i in range(1, 6)]
+ wb = _wb()
+ ws = wb.active
+ ws.title = "자재기준자료"
+ ws.append(JAJE_HEAD + src + ["적용 단가", "BQCODE", "JDCODE"])
+ for r in parse_dbf_bytes(files["COSTN.DBF"])[1]:
+ if not r["CODE"].strip().startswith("M"):
+ continue
+ ws.append(
+ [r["CODE"].strip(), r["NAME"], r["SIZE"], r["UNIT"].strip()]
+ + [_numv(r.get(f"AMT_{i}1")) for i in range(1, 7)]
+ + [r.get("BQCODE", "").strip(), r.get("JDCODE", "").strip()]
+ )
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+def import_jaje_xlsx(files: dict, src) -> list[dict]:
+ """자재 기준자료 xlsx → 바뀐 칸만 담은 costn_updates(없는 코드는 새 행)."""
+ ws = load_book(src).worksheets[0]
+ cur = {r["CODE"].strip(): r for r in parse_dbf_bytes(files["COSTN.DBF"])[1]}
+ head = [str(v or "").strip() for v in next(ws.iter_rows(max_row=1, values_only=True))]
+ fields = (
+ ["CODE", "NAME", "SIZE", "UNIT"] + [f"AMT_{i}1" for i in range(1, 7)] + ["BQCODE", "JDCODE"]
+ )
+ out = []
+ for r in ws.iter_rows(min_row=2, values_only=True):
+ if not r or not r[0]:
+ continue
+ vals = dict(zip(fields, r[: len(fields)]))
+ code = str(vals["CODE"]).strip()
+ old = cur.get(code)
+ upd = {"CODE": code}
+ for k, v in vals.items():
+ if k == "CODE" or k not in fields[: len(head)]:
+ continue
+ o = old.get(k) if old else None
+ if k.startswith("AMT_"):
+ if old is None or abs(_numv(o) - _numv(v)) > 1e-9:
+ upd[k] = _numv(v)
+ elif old is None or str(o or "").strip() != str(v or "").strip():
+ upd[k] = "" if v is None else str(v)
+ if "AMT_61" in upd: # 적용 단가 = 1차 재료비 단가
+ upd["MAMT1"] = upd["TAMT1"] = upd["AMT_61"]
+ if len(upd) > 1:
+ out.append(upd)
+ return out
+
+
+# ── 코드 변경표 ──
+def export_code_xlsx(files: dict) -> bytes:
+ wb = _wb()
+ ws = wb.active
+ ws.title = "코드변경"
+ ws.append(["지금 코드", "명 칭", "규 격", "단위", "새 코드"])
+ for r in parse_dbf_bytes(files["COSTN.DBF"])[1]:
+ ws.append([r["CODE"].strip(), r["NAME"], r["SIZE"], r["UNIT"].strip(), None])
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+def import_code_xlsx(src) -> dict[str, str]:
+ """코드 변경표 → {옛 코드: 새 코드}(새 코드 칸이 찬 줄만)."""
+ ws = load_book(src).worksheets[0]
+ out = {}
+ for r in ws.iter_rows(min_row=2, values_only=True):
+ if r and r[0] and len(r) > 4 and r[4] and str(r[4]).strip() != str(r[0]).strip():
+ out[str(r[0]).strip()] = str(r[4]).strip().upper()
+ return out
+
+
+def level_label(code: str) -> str:
+ """닫는 행 단계 이름(소계·계·합계…) — 화면 표시용."""
+ return LEVEL_NAME.get(int(code[1:]) if code[1:].isdigit() else 0, "")
+
+
+__all__ = [
+ "INITIAL",
+ "export_boq_xlsx",
+ "export_jaje_xlsx",
+ "import_jaje_xlsx",
+ "export_code_xlsx",
+ "import_code_xlsx",
+ "level_label",
+]
diff --git a/tools/stmate-test/conv_xls_res.py b/tools/stmate-test/conv_xls_res.py
new file mode 100644
index 000000000..4e71cc02e
--- /dev/null
+++ b/tools/stmate-test/conv_xls_res.py
@@ -0,0 +1,213 @@
+# -*- coding: utf-8 -*-
+"""엑셀 역등록 자원 쪽 (CONV_XLS_2_ST · XLS_2_BDREC) — 목록표 → COSTN 행 · 일위대가표/중기사용료 → BDQTY 구성 ·
+단가산출근거 → COSTN.SMT 산출식.
+
+근거: XLSX_INITIAL_구조.md · 24_모듈구조_전수.md §3(XLS_2_BDREC — 엑셀 수량산출 양식 ↔ BDQTY) · 23번(COSTN.SMT) ·
+ 실무 xlsx 숨은 열 실측
+목록표 INFO: COD · BQC(BQCODE) · EQC(EQCODE) · JDC(JDCODE) · WQC(WQCODE) · EDT(EDT_DATE) · ADJ(S_ADJ) · RXX
+구성표 머리 줄 INFO: BDCOD(호표 코드) · POSS/POSE(구성 줄 범위)
+구성 줄 INFO: COD · EXI(EXIST1) · DVD(DEVD1) · BMK(BIMOK) · IPR(PRPT) · KWN · BLA · EQC · PRI · PRO · UNI · RCU
+산출근거 숨은 열: 머리 줄 BDCOD·POSS·POSE · 그 아래 줄마다 0x07+원래 식 한 줄 → CRLF 로 이으면 COSTN.SMT 본문
+"""
+
+from __future__ import annotations
+
+import re
+
+from conv_xls_boq import _numv, _txt, find_sheet, parse_info
+
+# 목록표 이름 → (머리글자, 단가 열 배치)
+LISTS = {
+ "재료비목록표": "M",
+ "노무비목록표": "L",
+ "경비목록표": "S",
+ "일식목록표": "W",
+ "중기목록표": "X",
+ "일위대가목록표": "B",
+ "산출근거목록표": "D",
+}
+LIST_INFO = {
+ "BQC": "BQCODE",
+ "EQC": "EQCODE",
+ "JDC": "JDCODE",
+ "WQC": "WQCODE",
+ "EDT": "EDT_DATE",
+ "ADJ": "S_ADJ",
+ "RXX": "RXX",
+}
+COMP_INFO = {
+ "EXI": "EXIST1",
+ "DVD": "DEVD1",
+ "BMK": "BIMOK",
+ "IPR": "PRPT",
+ "KWN": "KWNGP",
+ "BLA": "BL_AMT",
+ "EQC": "EQCODE",
+ "PRI": "PRIN",
+ "PRO": "PROUT",
+ "RCU": "RCU",
+}
+
+
+def _sheet(wb, info: dict, kind: str):
+ spec = find_sheet(info, kind)
+ if not spec or spec["sheet"] not in wb.sheetnames:
+ return None, None
+ return wb[spec["sheet"]], spec["cols"]
+
+
+def _info_col(ws, top: int = 3) -> int | None:
+ """숨은 INFO 열 — 머리 아래 첫 몇 줄에서 `COD| 또는 `BDCOD| 가 든 열."""
+ for r in ws.iter_rows(min_row=top, max_row=min(ws.max_row, top + 30), values_only=True):
+ for j, v in enumerate(r, start=1):
+ if isinstance(v, str) and ("`COD|" in v or "`BDCOD|" in v):
+ return j
+ return None
+
+
+def stored_unit(code: str, unit: str, size: str) -> str:
+ """보이는 단위 → 저장 단위(report_core.unitText 의 역) — 실무 7건 COSTN 전수 꼴:
+ X 코드 '시간' = 'X' · '%' = 규격 글로 비목(노무·인건 → L% · 재료·연료 → M% · 경비 → S% · 그 밖 T%)."""
+ u = str(unit or "").strip()
+ if code[:1] == "X" and u in ("시간", "X"):
+ return "X"
+ if u != "%":
+ return u
+ s = str(size or "")
+ if re.search(r"노무|인건|,노", s):
+ return "L%"
+ if re.search(r"재료|연료", s):
+ return "M%"
+ return "S%" if "경비" in s else "T%"
+
+
+def costn_from_lists(wb, info: dict) -> list[dict]:
+ """목록표 시트들 → COSTN 행(CODE NAME SIZE UNIT ?AMT1 + INFO 칸) · 목록 차례대로."""
+ out, seen = [], set()
+ for kind, pre in LISTS.items():
+ ws, cols = _sheet(wb, info, kind)
+ if ws is None:
+ continue
+ ic = _info_col(ws) or cols.get("INFO")
+ for r in ws.iter_rows(
+ min_row=cols.get("SPOS", 4), max_row=cols.get("EPOS", ws.max_row), values_only=True
+ ):
+ meta = parse_info(r[ic - 1] if ic and ic - 1 < len(r) else None)
+ code = meta.get("COD", "").strip()
+ if not code or code in seen:
+ continue
+ row = {
+ "CODE": code,
+ "NAME": _txt(r[cols["NAME"] - 1]) if cols.get("NAME") else "",
+ "SIZE": _txt(r[cols["SIZE"] - 1]) if cols.get("SIZE") else "",
+ "UNIT": _txt(r[cols["UNIT"] - 1]).strip() if cols.get("UNIT") else "",
+ }
+ row["UNIT"] = stored_unit(code, row["UNIT"], row["SIZE"])
+ t = _numv(r[cols["TAMT"] - 1]) if cols.get("TAMT") else 0.0
+ part = {
+ c: (_numv(r[cols[f"{c}AMT"] - 1]) if cols.get(f"{c}AMT") else None) for c in "MLS"
+ }
+ if all(v is None for v in part.values()): # 단가 한 칸 목록(재·노·경)
+ part = {c: (t if c == pre else 0.0) for c in "MLS"}
+ row.update({f"{c}AMT1": part[c] or 0.0 for c in "MLS"}, TAMT1=t)
+ for k, f in LIST_INFO.items():
+ if k in meta:
+ row[f] = meta[k]
+ out.append(row)
+ seen.add(code)
+ return out
+
+
+def _blocks(ws, ic: int):
+ """구성표·산출근거의 호표 묶음 — (코드, 시작 줄, 끝 줄) · POSS/POSE 는 엑셀 줄 번호."""
+ for line, r in enumerate(ws.iter_rows(values_only=True), start=1):
+ v = r[ic - 1] if ic - 1 < len(r) else None
+ if isinstance(v, str) and "`BDCOD|" in v:
+ m = parse_info(v)
+ yield (
+ m["BDCOD"].strip(),
+ int(_numv(m.get("POSS")) or line + 1),
+ int(_numv(m.get("POSE")) or line + 1),
+ )
+
+
+def comp_from_sheets(wb, info: dict) -> list[dict]:
+ """일위대가표 · 중기사용료 → BDQTY 구성행(decode_bdqty 칸 이름) · 호표 차례대로."""
+ out = []
+ for kind in ("일위대가표", "중기사용료"):
+ ws, cols = _sheet(wb, info, kind)
+ if ws is None:
+ continue
+ ic = _info_col(ws)
+ rows = list(ws.iter_rows(values_only=True))
+ for bd, a, b in _blocks(ws, ic):
+ for line in range(a, b + 1):
+ r = rows[line - 1]
+ meta = parse_info(r[ic - 1] if ic - 1 < len(r) else None)
+ if "COD" not in meta:
+ continue
+ q = r[cols["QTY"] - 1] if cols.get("QTY") else None
+ row = {"BDCODE": bd, "CODE": meta["COD"].strip(), "QTY1": _numv(q)}
+ for k, f in COMP_INFO.items():
+ if k in meta:
+ row[f] = meta[k]
+ out.append(row)
+ return out
+
+
+REF_RE = re.compile(
+ r"(PR|RR)[A-Z]?_\(.*\)_1|([A-Z][0-9A-Z]{0,5})_1"
+) # 'RRC_(25)_1' · 'PRS_(2)_1' = 비율 줄
+
+
+def comp_from_san(wb, info: dict) -> list[dict]:
+ """단가산출근거 → D 코드 BDQTY 구성행 — 코드 열 「코드_1」 줄(수량 = 숨은 QTY 열) · 소계/계 줄 = T1·T2…
+ (묶음 뿌리 T7 = 총계 줄은 저장 안 함 — 봉화 D00014 등 복호 대조)."""
+ ws, _cols = _sheet(wb, info, "단가산출근거")
+ if ws is None:
+ return []
+ ic = _info_col(ws)
+ rows = list(ws.iter_rows(values_only=True))
+ qc = next(
+ (j for r in rows[:6] for j, v in enumerate(r, start=1) if str(v or "").strip() == "QTY"),
+ None,
+ )
+ out = []
+ for bd, a, b in _blocks(ws, ic):
+ for line in range(a, b + 1):
+ r = rows[line - 1]
+ ref = next(
+ (
+ m.group(1) or m.group(2)
+ for v in r
+ if isinstance(v, str)
+ for m in [REF_RE.fullmatch(v)]
+ if m
+ ),
+ None,
+ )
+ if not ref or ref == "T7" or ref == bd:
+ continue
+ q = _numv(r[qc - 1]) if qc and not ref.startswith("T") else 0.0
+ out.append({"BDCODE": bd, "CODE": ref, "QTY1": round(q, 11)})
+ return out
+
+
+def memos_from_sheet(wb, info: dict) -> dict[str, str]:
+ """단가산출근거 → {D코드: COSTN.SMT 본문} — 숨은 열 줄을 CRLF 로 이음."""
+ ws, _cols = _sheet(wb, info, "단가산출근거")
+ if ws is None:
+ return {}
+ ic = _info_col(ws)
+ rows = list(ws.iter_rows(values_only=True))
+ out = {}
+ for bd, a, b in _blocks(ws, ic):
+ lines = []
+ for line in range(a, b + 1):
+ v = rows[line - 1][ic - 1] if ic - 1 < len(rows[line - 1]) else None
+ if isinstance(v, str):
+ v = re.sub(r"_x0007_", "\x07", v)
+ if v.startswith("\x07"):
+ lines.append(v[1:])
+ out[bd] = "\r\n".join(lines)
+ return out
diff --git a/tools/stmate-test/conv_xml.py b/tools/stmate-test/conv_xml.py
new file mode 100644
index 000000000..fdd488626
--- /dev/null
+++ b/tools/stmate-test/conv_xml.py
@@ -0,0 +1,238 @@
+# -*- coding: utf-8 -*-
+"""조달청 공사원가호환규정 XML → 프로젝트 가져오기 (Conv_XML_ST8 · Conv_Jodal_ST8 대응).
+
+근거: 30_조달청_호환규정_XSD.md(문서 뼈대 · 호환규정 ↔ STmate 대응표) · 28번 §3(조달청 ↔ ST8 양방향) ·
+ old/30_원자료/조달청_공사원가호환규정.xsd(V1.0.5) · report_pps.js(내보내기 짝)
+대응:
+ 내역서(내역서번호 두 자리씩 = 깊이) → BOQ Tk 여는 행 · 세부공종 → 말단 행 · Ek/Ck 닫는 행
+ 공종구분 제요율적용제외공종·관급자재공종 = C 구역(일반 공종 아래 첫 제외 묶음만 Ck 로 닫음)
+ 단계 번호 k = 가장 깊은 묶음 1 … 바깥으로 갈수록 +1 (XML 에 단계 숫자가 없음 — 깊이로 정함)
+ 자재 M · 노무 L · 경비항목 S · 일위대가 B · 중기단가산출 D(중기단가산출식 = COSTN.SMT) · 기계경비 X → COSTN
+ 일위대가상세 · 기계경비상세 → BDQTY 구성(자원코드 · 수량)
+ 제경비항목 700(총공사비) → PRJLST COSTW1 · 공사명 → PNAME
+"""
+
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+
+from conv_xls_boq import LEVEL_NAME
+from conv_xls_res import stored_unit
+
+RES_TAGS = { # 태그 → (머리글자, 코드 태그)
+ "자재": ("M", "자재코드"),
+ "노무": ("L", "노무코드"),
+ "경비항목": ("S", "경비항목코드"),
+ "일위대가": ("B", "일위대가코드"),
+ "중기단가산출": ("D", "중기단가산출코드"),
+ "기계경비": ("X", "기계경비코드"),
+}
+EXCL = ("제요율적용제외공종", "관급자재공종")
+
+
+def _t(el, tag: str, default: str = "") -> str:
+ v = el.findtext(tag) if el is not None else None
+ return default if v is None else v.strip()
+
+
+def _f(el, tag: str) -> float:
+ try:
+ return float(_t(el, tag) or 0)
+ except ValueError:
+ return 0.0
+
+
+def parse_xml(src) -> ET.Element:
+ if isinstance(src, (bytes, bytearray)):
+ return ET.fromstring(bytes(src))
+ if isinstance(src, str) and src.lstrip().startswith("<"):
+ return ET.fromstring(src.encode("utf-8"))
+ return ET.parse(src).getroot()
+
+
+def _leaf(it) -> dict:
+ u = {c: _f(it, f"{n}단가") for c, n in (("M", "재료비"), ("L", "노무비"), ("S", "경비"))}
+ a = {c: _f(it, n) for c, n in (("M", "재료비"), ("L", "노무비"), ("S", "경비"))}
+ row = {
+ "BDCODE1": _t(it, "세부공종코드"),
+ "ITNUM": _t(it, "세부공종번호"),
+ "NAME1": _t(it, "세부공종명"),
+ "SIZE1": _t(it, "규격"),
+ "UNIT1": _t(it, "단위"),
+ "QTY1": _f(it, "수량"),
+ "BQCODE": _t(it, "비고"),
+ "EXIST1": 0,
+ }
+ row.update({f"{c}AMT1": u[c] for c in "MLS"}, TAMT1=sum(u.values()))
+ row.update({f"{c}_AMT1": a[c] for c in "MLS"}, T_AMT1=sum(a.values()))
+ return row
+
+
+def boq_rows(root: ET.Element) -> list[dict]:
+ """내역서 묶음 → BOQ 행(여는 Tk · 말단 · 닫는 Ek/Ck) · SORTCODE↔ENDCODE 짝."""
+ groups = list(root.iter("내역서"))
+ if not groups:
+ return []
+ depth = {id(g): max(1, len(_t(g, "내역서번호")) // 2) for g in groups}
+ deepest = max(depth.values())
+ out: list[dict] = []
+ stack: list[tuple[int, dict, int, bool]] = [] # (깊이, 여는 행, 자리, 제외 구역 시작인가)
+
+ def close_to(d: int):
+ while stack and stack[-1][0] >= d:
+ dd, head, at, cz = stack.pop()
+ lev = deepest - dd + 1
+ name = LEVEL_NAME.get(lev, "")
+ row = {k: v for k, v in head.items() if "_AMT" in k}
+ if cz:
+ row.update(BDCODE1=f"C{lev}", ITNUM="", NAME1=f"{name}(제외금액)", EXIST1=1)
+ else:
+ row.update(BDCODE1=f"E{lev}", ITNUM=name, NAME1="", EXIST1=0)
+ row["_pair"], head["_pair"] = at, len(out)
+ out.append(row)
+
+ for g in groups:
+ d = depth[id(g)]
+ close_to(d)
+ kind = _t(g, "공종구분")
+ in_excl = any(s[3] or s[1].get("_excl") for s in stack)
+ a = {c: _f(g, n) for c, n in (("M", "재료비"), ("L", "노무비"), ("S", "경비"))}
+ head = {
+ "BDCODE1": f"T{deepest - d + 1}",
+ "ITNUM": _t(g, "공종번호"),
+ "NAME1": _t(g, "공종명"),
+ "EXIST1": 1,
+ "_excl": kind in EXCL,
+ }
+ head.update({f"{c}_AMT1": a[c] for c in "MLS"}, T_AMT1=sum(a.values()))
+ stack.append((d, head, len(out), kind in EXCL and not in_excl))
+ out.append(head)
+ for it in g.findall("세부공종"):
+ out.append(_leaf(it))
+ close_to(0)
+ for i, r in enumerate(out):
+ r["SORTCODE"] = 256.0 * (i + 1)
+ r.pop("_excl", None)
+ for r in out:
+ r["ENDCODE"] = out[r.pop("_pair")]["SORTCODE"] if "_pair" in r else 0.0
+ return out
+
+
+def costn_rows(root: ET.Element) -> tuple[list[dict], dict[str, str]]:
+ """자원 목록 → COSTN 행 · 중기단가산출식 → {D코드: 식}."""
+ rows, memos, seen = [], {}, set()
+ for tag, (pre, ctag) in RES_TAGS.items():
+ for el in root.iter(tag):
+ code = _t(el, ctag)
+ if not code or code in seen or el.find(ctag) is None:
+ continue
+ size = _t(el, "규격")
+ row = {
+ "CODE": code,
+ "NAME": _t(el, "품명"),
+ "SIZE": size,
+ "UNIT": stored_unit(code, _t(el, "단위"), size),
+ "BQCODE": _t(el, "비고"),
+ }
+ if pre in "ML":
+ p = _f(el, "재료비단가" if pre == "M" else "노무비단가")
+ part = {c: (p if c == pre else 0.0) for c in "MLS"}
+ elif pre == "S":
+ part = {
+ c: _f(el, f"{n}단가")
+ for c, n in (("M", "재료비"), ("L", "노무비"), ("S", "경비"))
+ }
+ else:
+ part = {c: _f(el, n) for c, n in (("M", "재료비"), ("L", "노무비"), ("S", "경비"))}
+ row.update({f"{c}AMT1": part[c] for c in "MLS"}, TAMT1=sum(part.values()))
+ rows.append(row)
+ seen.add(code)
+ if pre == "D":
+ text = el.findtext("중기단가산출식") or ""
+ if text.strip():
+ memos[code] = text.replace("\r\n", "\n").replace("\n", "\r\n")
+ return rows, memos
+
+
+def comp_rows(root: ET.Element) -> list[dict]:
+ """일위대가상세 · 기계경비상세 → BDQTY 구성행(자원코드 · 수량)."""
+ out = []
+ for tag, ctag, dtag in (
+ ("일위대가", "일위대가코드", "일위대가상세"),
+ ("기계경비", "기계경비코드", "기계경비상세"),
+ ):
+ for el in root.iter(tag):
+ bd = _t(el, ctag)
+ if not bd:
+ continue
+ for d in el.findall(dtag):
+ out.append({"BDCODE": bd, "CODE": _t(d, "자원코드"), "QTY1": _f(d, "수량")})
+ return out
+
+
+def project_info(root: ET.Element) -> dict:
+ """공사명 · 공사관리번호 · 주공사 · 총공사비(제경비 700) · 직접비(100)."""
+ info = root.find("공사정보")
+ costs = {_t(x, "세부공종코드"): _f(x, "산출금액") for x in root.iter("제경비항목")}
+ return {
+ "pname": _t(info, "공사명"),
+ "pnum": _t(info, "공사관리번호"),
+ "main": _t(info, "주공사"),
+ "grand_total": costs.get("700", 0.0),
+ "direct_total": costs.get("100", 0.0),
+ "schema": _t(root, "스키마버전"),
+ }
+
+
+def read_xml(src) -> dict:
+ root = parse_xml(src)
+ if root.tag != "공사원가호환규정":
+ raise ValueError(f"공사원가호환규정 XML 이 아님(뿌리 {root.tag})")
+ costn, memos = costn_rows(root)
+ return {
+ "info": project_info(root),
+ "boq": boq_rows(root),
+ "costn": costn,
+ "memos": memos,
+ "comp": comp_rows(root),
+ }
+
+
+def xml_to_project(template: dict, src, pname: str = "") -> tuple[dict, dict]:
+ """XML 한 개 → 새 프로젝트 files(틀 = 다른 프로젝트의 요율·서식·설정) · 보고."""
+ from conv_xls_apply import _costn_table, _direct_to_prj, rows_to_table
+ from conv_xls_boq import fill_from_costn
+ from dbf_rw import DbfTable
+ from stc_project import new_project
+ from stc_save import SaveLog
+ from stc_save_extra import save_bdqty
+
+ data = read_xml(src)
+ info = data["info"]
+ out = new_project(template, pname or info["pname"] or "XML 가져온 공사", info["pnum"][:20])
+ out["COSTN.DBF"], out["COSTN.SMT"] = _costn_table(
+ template["COSTN.DBF"], data["costn"], data["memos"]
+ )
+ rows = data["boq"]
+ fill_from_costn(rows, data["costn"])
+ log = SaveLog()
+ bt = rows_to_table(template["BOQ11.DBF"], rows)
+ direct = _direct_to_prj(out, out, bt, "BOQ11.DBF", log)
+ out["BOQ11.DBF"] = bt.to_bytes()
+ if data["comp"] and "BDQTY.DBF" in template:
+ out["BDQTY.DBF"] = (
+ save_bdqty(template["BDQTY.DBF"], data["comp"], log, strict=False)
+ or template["BDQTY.DBF"]
+ )
+ pt = DbfTable(out["PRJLST.DBF"])
+ if info["grand_total"] and "COSTW1" in pt.fields:
+ pt.set(0, "COSTW1", info["grand_total"])
+ out["PRJLST.DBF"] = pt.to_bytes()
+ return out, {
+ "info": info,
+ "boq_count": len(rows),
+ "costn_count": len(data["costn"]),
+ "memo_count": len(data["memos"]),
+ "bdqty_count": len(data["comp"]),
+ "direct": direct,
+ }
diff --git a/tools/stmate-test/conv_xml_cfg.py b/tools/stmate-test/conv_xml_cfg.py
new file mode 100644
index 000000000..587f4d2ea
--- /dev/null
+++ b/tools/stmate-test/conv_xml_cfg.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+"""XML 변환 설정 (uM_XML_Info · uM_ConstType) — 공사유형 · 건축공사 사업내용(공사현장 지역 포함) → 조달청 XML 에 반영.
+
+근거: 30_조달청_호환규정_XSD.md §5·§6(주공사 9종 · 지역코드 · 건축 사업내용) · 34번 §2.7(uM_XML_Info 공사유형 8) ·
+ old/30_원자료/ui_form_catalog.txt(uM_XML_Info ed_PrjTyp · uM_ConstType cn_Cn_Code1 시도 · cn_* 선택지)
+선택지·칸 차례·열거값은 XSD 에서 읽음(코드에 박지 않음) · 화면 공사유형 8 = uM_XML_Info ed_PrjTyp(문화재수리 제외).
+반영: 공사정보/주공사 · 단일공사정보/공사구분 = 공사유형 · 건축이면 작성정보/건축공사사업내용작성여부 = true 와
+ 공사정보/건축공사사업내용(XSD 차례 · 빈 칸 = 숫자 0 · 열거 첫 값 · 글 빈칸) · 끝에 XSD 검증 결과.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import re
+import xml.etree.ElementTree as ET
+
+HERE = pathlib.Path(__file__).resolve().parent
+KB = HERE.parent.parent / "resources/knowledge/original/경쟁사 프로그램/STmate/old/30_원자료"
+XSD = KB / "조달청_공사원가호환규정.xsd"
+CATALOG = KB / "ui_form_catalog.txt"
+XS = "{http://www.w3.org/2001/XMLSchema}"
+
+
+def _xsd():
+ return ET.parse(XSD).getroot()
+
+
+def _enum(el) -> list[str]:
+ return [e.get("value") for e in el.iter(f"{XS}enumeration")]
+
+
+def _ui_list(form: str, ctl: str) -> list[str]:
+ """ui_form_catalog 의 폼·컨트롤 선택지(쉼표 구분)."""
+ if not CATALOG.exists():
+ return []
+ cur = ""
+ for ln in CATALOG.read_text(encoding="utf-8").splitlines():
+ m = re.match(r"^■\s+(\S+)", ln)
+ if m:
+ cur = m.group(1)
+ elif cur == form and ln.strip().startswith(ctl):
+ return [x.strip() for x in ln.strip()[len(ctl) :].split(",") if x.strip()]
+ return []
+
+
+def building_fields() -> list[dict]:
+ """건축공사사업내용형식 칸 — {name, type(double|enum|text), enum, max}."""
+ ct = next(c for c in _xsd().iter(f"{XS}complexType") if c.get("name") == "건축공사사업내용형식")
+ out = []
+ for el in ct.find(f"{XS}sequence").findall(f"{XS}element"):
+ en = _enum(el)
+ mx = el.find(f".//{XS}maxLength")
+ kind = "double" if el.get("type") == "xsd:double" else ("enum" if en else "text")
+ out.append(
+ {
+ "name": el.get("name"),
+ "type": kind,
+ "enum": en,
+ "max": int(mx.get("value")) if mx is not None else 0,
+ }
+ )
+ return out
+
+
+def options() -> dict:
+ """설정 화면 선택지 — 공사유형(화면 8 · XSD 9) · 시도(지역코드 앞 두 자리 · 이름은 화면 목록) · 건축 칸."""
+ root = _xsd()
+ main = next(e for e in root.iter(f"{XS}element") if e.get("name") == "주공사")
+ names = {}
+ for item in _ui_list("uM_ConstType", "cn_Cn_Code1"):
+ m = re.match(r"^(.+);\s*(\d+)$", item)
+ if m:
+ names[m.group(2)] = m.group(1)
+ fields = building_fields()
+ codes = next((f["enum"] for f in fields if f["name"] == "공사현장지역코드"), [])
+ sido = sorted({c[:2] for c in codes})
+ return {
+ "prj_types": _ui_list("uM_XML_Info", "ed_PrjTyp") or _enum(main),
+ "prj_types_xsd": _enum(main),
+ "sido": [
+ {"code": s, "name": names.get(s, ""), "regions": [c for c in codes if c[:2] == s]}
+ for s in sido
+ ],
+ "building": fields,
+ }
+
+
+def _set(parent, tag: str, text: str, after: tuple[str, ...] = ()):
+ """parent 아래 tag 를 text 로(없으면 after 칸들 뒤에 끼움)."""
+ el = parent.find(tag)
+ if el is None:
+ el = ET.Element(tag)
+ kids = list(parent)
+ at = max((i + 1 for i, k in enumerate(kids) if k.tag in after), default=len(kids))
+ parent.insert(at, el)
+ el.text = text
+ return el
+
+
+def _fmt(v) -> str:
+ try:
+ f = float(v)
+ return str(int(f)) if f.is_integer() else repr(f)
+ except (TypeError, ValueError):
+ return str(v or "")
+
+
+def apply_settings(xml: str, cfg: dict) -> str:
+ """내보낸 XML 글 + 설정 {prj_type, building: {칸: 값}} → 새 XML 글."""
+ root = ET.fromstring(xml.encode("utf-8"))
+ ptype = (cfg or {}).get("prj_type") or ""
+ allowed = options()["prj_types_xsd"]
+ if ptype and ptype not in allowed:
+ raise ValueError(f"공사유형 '{ptype}' 은 호환규정 주공사에 없음")
+ if ptype:
+ info = root.find("공사정보")
+ if info is not None:
+ _set(info, "주공사", ptype)
+ for el in root.iter("단일공사정보"):
+ _set(el, "공사구분", ptype)
+ bld = (cfg or {}).get("building")
+ if ptype == "건축공사" and bld is not None:
+ writer = root.find("작성정보")
+ _set(writer, "건축공사사업내용작성여부", "true", ("파일검증여부", "파일제작일"))
+ info = root.find("공사정보")
+ old = info.find("건축공사사업내용")
+ if old is not None:
+ info.remove(old)
+ box = ET.Element("건축공사사업내용")
+ for f in building_fields():
+ v = bld.get(f["name"])
+ if f["type"] == "double":
+ text = _fmt(v if v not in (None, "") else 0)
+ elif f["type"] == "enum":
+ if v not in (None, "") and str(v) not in f["enum"]:
+ raise ValueError(f"{f['name']} 값 '{v}' 은 호환규정 열거에 없음")
+ text = str(v) if v not in (None, "") else f["enum"][0]
+ else:
+ text = re.sub(r"[\r\n\t]+", " ", str(v or "")).strip()
+ text = text[: f["max"]] if f["max"] else text
+ ET.SubElement(box, f["name"]).text = text
+ kids = list(info)
+ at = max(
+ (i + 1 for i, k in enumerate(kids) if k.tag in ("비고", "기계경비산출")),
+ default=len(kids),
+ )
+ info.insert(at, box)
+ body = ET.tostring(root, encoding="unicode")
+ return '\n' + body
+
+
+def validate(xml: str) -> dict:
+ """XSD 검증 — {ok, errors[]} (lxml 없으면 ok=None)."""
+ try:
+ from lxml import etree
+ except ImportError:
+ return {"ok": None, "errors": ["lxml 없음 — 검증 생략"]}
+ schema = etree.XMLSchema(etree.parse(str(XSD)))
+ doc = etree.fromstring(xml.encode("utf-8"))
+ ok = schema.validate(doc)
+ return {"ok": bool(ok), "errors": [str(e) for e in list(schema.error_log)[:10]]}
diff --git a/tools/stmate-test/scr_conv.js b/tools/stmate-test/scr_conv.js
new file mode 100644
index 000000000..4a9137acf
--- /dev/null
+++ b/tools/stmate-test/scr_conv.js
@@ -0,0 +1,249 @@
+/**
+ * STMATE w 8.0 — 변환·가져오기 끼워 넣기 (scr_conv.js) · 1번(파일) 메뉴
+ * 조달청 XML 가져오기(Conv_XML_ST8) · XML 변환 설정(uM_XML_Info · uM_ConstType) · 엑셀 → 내역(wM_From_XLS) ·
+ * 엑셀 역등록(CONV_XLS_2_ST) · 기준 BOQ·자재 기준자료·코드 변경표 엑셀 왕복 · 분리형 변경 STC · JB_* 제비율표
+ * 서버: api_conv.py — 쓰기는 work_dbf/ 아래만 · 파일은 화면에서 골라 base64 로 보냄
+ */
+(function (global) {
+ "use strict";
+
+ const PLUG = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
+ screens: {},
+ actions: {},
+ menus: [],
+ });
+ const app = () => global.STMATE_APP;
+
+ async function req(url, body, raw) {
+ const res = await fetch(url, {
+ method: body === undefined ? "GET" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return raw ? res.blob() : res.json();
+ }
+
+ const curPath = (ctx) => {
+ if (!ctx.currentSourcePath) throw new Error("먼저 프로젝트를 불러오세요.");
+ return ctx.currentSourcePath;
+ };
+ const pname = (ctx) => (ctx.state.projectMeta.pname || "공사").slice(0, 28);
+
+ // 파일 고르기 → {b64, name}
+ function pickFile(accept) {
+ return new Promise((resolve, reject) => {
+ const inp = document.createElement("input");
+ inp.type = "file";
+ inp.accept = accept;
+ inp.onchange = () => {
+ const f = inp.files && inp.files[0];
+ if (!f) return reject(new Error("파일을 고르지 않음"));
+ const rd = new FileReader();
+ rd.onload = () => resolve({ b64: String(rd.result), name: f.name });
+ rd.onerror = () => reject(rd.error);
+ rd.readAsDataURL(f);
+ };
+ inp.click();
+ });
+ }
+
+ function download(blob, name) {
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(blob);
+ a.download = name;
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => (URL.revokeObjectURL(a.href), a.remove()), 1000);
+ }
+
+ async function openSaved(path) {
+ const a = app();
+ const src = await req("/api/sources");
+ if (src && src.projects) a.ctx.sourcesData = src;
+ const sel = document.getElementById("top-project-select");
+ if (sel && src && src.projects)
+ sel.innerHTML = src.projects
+ .map((p) => ``)
+ .join("");
+ await a.loadProjectFromServer(path, "wPrjList");
+ }
+
+ const fmtRep = (r) =>
+ Object.entries(r || {})
+ .filter(([, v]) => typeof v !== "object")
+ .map(([k, v]) => `${k}: ${v}`)
+ .join("\n");
+
+ // 화면 XML — report_pps 내보내기(지금 계산 결과)
+ function currentXml(ctx) {
+ const P = global.STMATE_REPORT_PPS;
+ if (!P) throw new Error("조달청 XML 모듈(report_pps.js) 없음");
+ return P.buildPpsXml(ctx.dbfData, ctx.state, ctx.computed, 1);
+ }
+
+ const ACTS = {
+ async xmlImport(ctx) {
+ const f = await pickFile(".xml");
+ const r = await req("/api/conv/xml-import", {
+ template_path: curPath(ctx),
+ xml: { b64: f.b64 },
+ });
+ alert(
+ `[조달청 XML 가져오기] ${r.saved_dir}\n${fmtRep(r.report.info)}\n내역 ${r.report.boq_count}행 · 단가 ${r.report.costn_count}건`,
+ );
+ await openSaved(r.saved_dir);
+ },
+ async xmlSettings(ctx) {
+ const opt = await req("/api/conv/xml-options");
+ const types = opt.prj_types || [];
+ const pick = prompt(
+ `[XML 변환 설정] 공사유형 번호:\n${types.map((t, i) => `${i + 1}. ${t}`).join("\n")}`,
+ "1",
+ );
+ const prjType = types[parseInt(pick, 10) - 1];
+ if (!prjType) return;
+ const settings = { prj_type: prjType };
+ if (prjType === "건축공사") {
+ const help = (opt.building || []).map((b) => b.name).join(", ");
+ const txt = prompt(
+ `건축공사 사업내용 — 칸=값 을 ; 로 이어 적음(빈 칸 = 0·첫 값)\n${help}`,
+ "연면적=0",
+ );
+ settings.building = Object.fromEntries(
+ String(txt || "")
+ .split(";")
+ .map((s) => s.split("=").map((x) => x.trim()))
+ .filter((kv) => kv[0]),
+ );
+ }
+ const r = await req("/api/conv/xml-settings", { xml_text: currentXml(ctx), settings });
+ download(new Blob([r.xml], { type: "application/xml" }), `${pname(ctx)}_조달청.xml`);
+ alert(
+ r.validation.ok ? "XSD 검증 통과" : `XSD 위반: ${(r.validation.errors || [])[0] || ""}`,
+ );
+ },
+ async xlsBoq(ctx) {
+ const f = await pickFile(".xlsx");
+ const r = await req("/api/conv/xls-boq", {
+ source_path: curPath(ctx),
+ xlsx: { b64: f.b64 },
+ table: (ctx.dbfData && ctx.dbfData.boq_table_name) || "",
+ });
+ const unk = r.report.unknown_codes || [];
+ alert(
+ `[엑셀 → 내역] ${r.report.sheet} ${r.report.row_count}행 → ${r.report.table}${unk.length ? `\n단가에 없는 코드 ${unk.length}: ${unk.slice(0, 8).join(", ")}` : ""}`,
+ );
+ await openSaved(r.saved_dir);
+ },
+ async xlsRegister(ctx) {
+ const f = await pickFile(".xlsx");
+ const r = await req("/api/conv/xls-register", {
+ template_path: curPath(ctx),
+ xlsx: { b64: f.b64 },
+ });
+ alert(`[엑셀 역등록] ${r.report.pname}\n${fmtRep(r.report)}`);
+ await openSaved(r.saved_dir);
+ },
+ async boqXlsx(ctx) {
+ const tb = (ctx.dbfData && ctx.dbfData.boq_table_name) || "";
+ download(
+ await req("/api/conv/boq-xlsx", { source_path: curPath(ctx), table: tb }, true),
+ `${pname(ctx)}_기준BOQ.xlsx`,
+ );
+ },
+ async jajeXlsx(ctx) {
+ download(
+ await req("/api/conv/jaje-xlsx", { source_path: curPath(ctx) }, true),
+ `${pname(ctx)}_자재기준자료.xlsx`,
+ );
+ },
+ async jajeImport(ctx) {
+ const f = await pickFile(".xlsx");
+ const r = await req("/api/conv/jaje-import", {
+ source_path: curPath(ctx),
+ xlsx: { b64: f.b64 },
+ });
+ const rows = ctx.dbfData.costn_rows || (ctx.dbfData.costn_rows = []);
+ for (const u of r.costn_updates) {
+ const row = rows.find((x) => String(x.CODE).trim() === u.CODE);
+ if (row) Object.assign(row, u);
+ else rows.push(Object.assign({}, u));
+ }
+ app().cascadeAndRender && app().cascadeAndRender();
+ alert(`[자재 기준자료 불러오기] 바뀐 자재 ${r.count}건 — [저장]으로 반영`);
+ },
+ async codeXlsx(ctx) {
+ download(
+ await req("/api/conv/code-xlsx", { source_path: curPath(ctx) }, true),
+ `${pname(ctx)}_코드변경표.xlsx`,
+ );
+ },
+ async codeChange(ctx) {
+ const how = prompt("코드 변경 — 1: 코드 변경표 엑셀 고르기 · 2: 직접 입력(옛=새;옛=새)", "1");
+ const body = { source_path: curPath(ctx) };
+ if (how === "1") body.xlsx = { b64: (await pickFile(".xlsx")).b64 };
+ else if (how === "2") {
+ const t = prompt("옛코드=새코드 ; 로 이음", "");
+ if (!t) return;
+ body.mapping = Object.fromEntries(
+ t.split(";").map((s) => s.split("=").map((x) => x.trim())),
+ );
+ } else return;
+ const r = await req("/api/conv/code-change", body);
+ alert(`[코드 변경] ${Object.keys(r.mapping).length}개 코드 · ${r.change_count}곳 바꿈`);
+ await openSaved(r.saved_dir);
+ },
+ async splitChange(ctx) {
+ if (!confirm("현재안(통합형이면 BOQ12)을 차수1로 하는 다음 변경 차수 STC 를 만들까요?"))
+ return;
+ const r = await req("/api/conv/split-change", { source_path: curPath(ctx) });
+ alert(
+ `[분리형 변경 STC] 공사 ${r.report.from_pcode} 차수 ${r.report.from_slot} → 새 파일 차수1 · ${r.saved_stc}`,
+ );
+ await openSaved(r.saved_dir);
+ },
+ async jb(ctx) {
+ const L = await req("/api/conv/jb-list");
+ const m = ctx.currentSourcePath
+ ? await req("/api/conv/jb-match", { source_path: ctx.currentSourcePath })
+ : {};
+ const by = {};
+ for (const f of L.files)
+ by[`${f.kind} ${f.edition}`] = (by[`${f.kind} ${f.edition}`] || 0) + 1;
+ alert(
+ `[기준 제비율표 JB_*] ${L.files.length}개 (${L.dir})\n${Object.entries(by)
+ .map(([k, v]) => `${k}: ${v}`)
+ .join("\n")}\n\n[이 공사 RATE 표 ↔ 기준]\n` +
+ Object.entries(m)
+ .map(([t, v]) => `${t}: ${v.jb} ${v.exact ? "(같음)" : `(다른 칸 ${v.diff_cells})`}`)
+ .join("\n"),
+ );
+ },
+ };
+
+ async function run(act) {
+ try {
+ await ACTS[act](app().ctx);
+ } catch (err) {
+ alert("변환 실패: " + err.message);
+ }
+ }
+
+ [
+ ["Act_ConvXmlImport", "조달청 XML 가져오기 (Conv_XML_ST8)", "xmlImport"],
+ ["Act_ConvXmlSettings", "조달청 XML 변환 설정·내보내기 (uM_XML_Info)", "xmlSettings"],
+ ["Act_ConvXlsBoq", "엑셀 → 내역 불러오기 (wM_From_XLS)", "xlsBoq"],
+ ["Act_ConvXlsRegister", "엑셀 역등록 — 새 공사 (CONV_XLS_2_ST)", "xlsRegister"],
+ ["Act_ConvBoqXlsx", "기준 BOQ 엑셀로 내보내기 (MK_BSCBOQ_EXL)", "boqXlsx"],
+ ["Act_ConvJajeXlsx", "자재 기준자료 엑셀로 내보내기 (wM_JajeXLS)", "jajeXlsx"],
+ ["Act_ConvJajeImport", "자재 기준자료 엑셀 불러오기 (wM_JajeXLS)", "jajeImport"],
+ ["Act_ConvCodeXlsx", "코드 변경표 엑셀로 내보내기 (ST_U_ChgCode)", "codeXlsx"],
+ ["Act_ConvCodeChange", "코드 일괄 변경 (ST_U_ChgCode)", "codeChange"],
+ ["Act_ConvSplitChange", "분리형 변경 STC 만들기 — 차수 인계", "splitChange"],
+ ["Act_ConvJb", "기준 제비율표 JB_* 보기", "jb"],
+ ].forEach(([act, label, key]) => {
+ PLUG.actions[act] = () => run(key);
+ PLUG.menus.push({ menu: "m1", label, act });
+ });
+})(window);
diff --git a/tools/stmate-test/scr_file.js b/tools/stmate-test/scr_file.js
index cd67dcb1e..2264f1afd 100644
--- a/tools/stmate-test/scr_file.js
+++ b/tools/stmate-test/scr_file.js
@@ -53,7 +53,8 @@
.map(([n, h]) => `${n} v${h.ver.toString(16)} ${h.nfield}칸 ${h.nrec}행`)
.join("\n");
alert(
- `[STC 검사] ${r.ok ? "이상 없음" : "문제 있음"} · ${r.edition}\n${(r.problems || []).join("\n")}\n\n${t}`,
+ `[STC 검사] ${r.ok ? "이상 없음" : "문제 있음"} · ${r.edition}\n${(r.problems || []).join("\n")}` +
+ `${(r.warnings || []).length ? `\n[주의]\n${r.warnings.join("\n")}` : ""}\n\n${t}`,
);
},
async pack(ctx) {
diff --git a/tools/stmate-test/server.py b/tools/stmate-test/server.py
index 9cf406ee4..1aa643bb4 100644
--- a/tools/stmate-test/server.py
+++ b/tools/stmate-test/server.py
@@ -304,7 +304,8 @@ def convert_dbf_to_sim_state(
row_type = "normal"
if i in excl_span:
row_type = "gov_fee" if ("수수료" in name1 or fnum(r.get("S_AMT1")) > 0) else "gov_mat"
- is_pct_fee = unit1.endswith("%") and fnum(r.get("T_AMT1")) > 0
+ # 비율 줄(PR)은 수량 칸 = 율 그대로(calc.js prRow · PRPT/PRIN/PROUT) — 코드 붙은 % 줄만 옛 꼴(1 × 저장 금액)
+ is_pct_fee = bd != "PR" and unit1.endswith("%") and fnum(r.get("T_AMT1")) > 0
if is_pct_fee:
fee_amt = fnum(r.get("T_AMT1"))
for res in resources:
@@ -333,6 +334,10 @@ def convert_dbf_to_sim_state(
)
},
"_recno": r.get("_recno", i),
+ "PRPT": r.get("PRPT", ""),
+ "PRIN": r.get("PRIN", ""),
+ "PROUT": r.get("PROUT", ""),
+ "prQtys": [fnum(r.get(f"QTY{n}")) for n in range(1, 5)],
}
)
diff --git a/tools/stmate-test/stc_archive.py b/tools/stmate-test/stc_archive.py
index 0003fd5fc..e9b387eb7 100644
--- a/tools/stmate-test/stc_archive.py
+++ b/tools/stmate-test/stc_archive.py
@@ -211,6 +211,35 @@ SCHEMA_VARIANTS = { # STC_컨테이너_구조.md §4 — 표본에 있는 필
}
+VER_BYTE = { # STC_컨테이너_구조.md §3 — 헤더 첫 바이트(0x03 dBase III · 0xE5 메모 보유 · 0x06 난독화)
+ **{n: 0xE5 for n in ("COSTN.DBF", "PRJLST.DBF", "QCODE.DBF")},
+ **{n: 0x06 for n in ("BDQTY.DBF", "SYSINFO.DBF", "COSTA.DBF")},
+}
+
+
+def _counters(files: dict[str, bytes]) -> list[str]:
+ """Start8x.Ini 코드 발번 카운터(?CDNM = 다음 발번 기준) ≥ COSTN 코드 번호 최대 · STini.MEM = Start8x.Ini 미러."""
+ from smt_parser import parse_dbf_bytes, parse_ini_bytes
+
+ warn = []
+ ini_b = files.get("START8X.INI")
+ if ini_b and files.get("STINI.MEM") is not None and files["STINI.MEM"] != ini_b:
+ warn.append("STini.MEM 이 Start8x.Ini 와 다름(미러 깨짐)")
+ if not ini_b or "COSTN.DBF" not in files:
+ return warn
+ ini = parse_ini_bytes(ini_b)
+ top: dict[str, int] = {}
+ for r in parse_dbf_bytes(files["COSTN.DBF"])[1]:
+ c = str(r.get("CODE", "")).strip()
+ if len(c) == 6 and c[1:].isdigit():
+ top[c[0]] = max(top.get(c[0], 0), int(c[1:]))
+ for k, n in sorted(top.items()):
+ cnt = ini.get(f"{k}CDNM", "")
+ if cnt.strip().isdigit() and int(cnt) < n:
+ warn.append(f"{k}CDNM={cnt} < COSTN {k} 코드 최대 {n} — 새 코드 발번이 겹칠 수 있음")
+ return warn
+
+
def _dbf_head(b: bytes) -> dict:
n, hlen, rlen = struct.unpack_from(" dict:
res["problems"].append(f"{n} 머리 길이 불일치")
if n in SCHEMA_VARIANTS and h["nfield"] not in SCHEMA_VARIANTS[n]:
res["problems"].append(f"{n} 필드 수 {h['nfield']} — 표본에 없던 판")
+ want = VER_BYTE.get(n, 0x03)
+ if h["ver"] != want:
+ res["problems"].append(f"{n} 머리 첫 바이트 0x{h['ver']:02X} (기대 0x{want:02X})")
elif n.endswith(".SMT") and data[8:15] != b"SIxMemo":
res["problems"].append(f"{n} SIxMemo 서명 없음")
for dbf, smt, fld in (
@@ -276,6 +308,7 @@ def check_stc(b: bytes) -> dict:
):
if dbf in files and smt in files:
res["problems"] += _memo_problems(files[dbf], files[smt], fld, dbf)
+ res["warnings"] = _counters(files)
prj = _dbf_head(files["PRJLST.DBF"])["fields"] if "PRJLST.DBF" in files else []
res["edition"] = "8.0 2020 확장(5차 칸)" if "MCOST5" in prj else "8.0 구형(4차 칸)"
res["ok"] = not res["problems"]
diff --git a/tools/stmate-test/stc_change.py b/tools/stmate-test/stc_change.py
new file mode 100644
index 000000000..721554e17
--- /dev/null
+++ b/tools/stmate-test/stc_change.py
@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+"""분리형 변경 STC 만들기 · 차수 인계 — 앞 파일의 현재안이 새 파일 BOQ11 차수1, 차수2 = 이번 변경 출발점.
+
+근거: 16_골든셋_회귀_결과.md §4.5(한 파일에 BOQ11·BOQ12 → BOQ12 가 현재안) · §4.6(분리형: 새 파일 BOQ11 차수1 =
+ 앞 파일 BOQ12 · 차수2 = 이번 변경분) · 08번 §2(통합형 / 분리형) · stc_project(해당분만 압축 · 차수 칸 복사)
+규칙:
+ - 현재안 = 내역 표가 있는 PRJLST 행 중 PCODE 가 가장 큰 행(지정 가능) · 그 행 K_ORD k → 현재 차수 s = k+1
+ - 새 파일 = 그 공사만 담은 STC(BOQ11 · PRJLST 한 행 · 그 메모) · 내역 s 차 칸 → 1차 칸(s ≠ 1 일 때) → 2차 칸 복사
+ - PRJLST ?COSTs·COSTWs → 1차 칸 · 1차 → 2차 칸 · K_ORD = 1 · 메모 [s_KANJUB] → [1_KANJUB] · [1_KANJUB] → [2_KANJUB]
+"""
+
+from __future__ import annotations
+
+import re
+
+from dbf_memo import BLANK_PTR, SixMemo, ini_items, memo_text_bytes
+from dbf_rw import DbfTable
+from stc_project import SLOT_COL, _copy_slot, _rename_section, pack_selected
+
+PRJ_SLOT = ("MCOST", "LCOST", "SCOST", "TCOST", "ACOST", "COSTW")
+
+
+def current_pcode(files: dict) -> str:
+ """현재안 공사 — BOQ 표가 있는 PRJLST 행 중 PCODE 최대(통합형 BOQ12 > BOQ11)."""
+ prj = DbfTable(files["PRJLST.DBF"])
+ codes = [
+ prj.get(i, "PCODE") for i in range(len(prj)) if f"BOQ{prj.get(i, 'PCODE')}.DBF" in files
+ ]
+ if not codes:
+ raise ValueError("내역 표가 있는 공사 없음")
+ return max(codes, key=lambda c: int(c) if c.isdigit() else 0)
+
+
+def _move_section(text: str, src: int, dst: int) -> str:
+ """[src_KANJUB] 섹션을 [dst_KANJUB] 로 이름·키 접두를 바꿔 옮김(dst 가 이미 있으면 그대로)."""
+ if src == dst:
+ return text
+ secs = {s for s, _k, _v in ini_items(text)}
+ if f"{src}_KANJUB" not in secs or f"{dst}_KANJUB" in secs:
+ return text
+ out, cur = [], ""
+ for ln in text.split("\r\n"):
+ s = ln.strip()
+ if s.startswith("[") and s.endswith("]"):
+ cur = s[1:-1]
+ if cur == f"{src}_KANJUB":
+ ln = f"[{dst}_KANJUB]"
+ elif cur == f"{src}_KANJUB":
+ ln = re.sub(rf"^{src}_", f"{dst}_", ln)
+ out.append(ln)
+ return "\r\n".join(out)
+
+
+def split_change(files: dict, pcode: str = "") -> tuple[dict, dict]:
+ """앞 파일 files → 다음 변경 차수 파일 files · 보고."""
+ pc = pcode or current_pcode(files)
+ prj0 = DbfTable(files["PRJLST.DBF"])
+ si = next(i for i in range(len(prj0)) if prj0.get(i, "PCODE") == pc)
+ k = int(float(prj0.get(si, "K_ORD") or 0)) if "K_ORD" in prj0.fields else 0
+ s = k + 1
+ out = pack_selected(files, pc)
+ bt = DbfTable(out["BOQ11.DBF"])
+ if s != 1:
+ _copy_slot(bt, str(s), "1")
+ _copy_slot(bt, "1", "2")
+ bt.dirty = True
+ out["BOQ11.DBF"] = bt.to_bytes()
+ prj = DbfTable(out["PRJLST.DBF"])
+ for c in PRJ_SLOT:
+ a, one, two = f"{c}{s}", f"{c}1", f"{c}2"
+ if a in prj.fields and one in prj.fields:
+ prj.set_raw(0, one, prj0.raw(si, a))
+ if one in prj.fields and two in prj.fields:
+ prj.set_raw(0, two, prj.raw(0, one))
+ if "K_ORD" in prj.fields:
+ prj.set(0, "K_ORD", 1)
+ memo = SixMemo(out.get("PRJLST.SMT"))
+ text = memo.read(prj.raw(0, "PRJ_MEMO")).decode("cp949", "replace")
+ text = _rename_section(_move_section(text, s, 1), 1, 2)
+ fresh = SixMemo(None, memo.bs)
+ prj.set_raw(0, "PRJ_MEMO", fresh.write(BLANK_PTR, memo_text_bytes(text)) if text else BLANK_PTR)
+ out["PRJLST.DBF"] = prj.to_bytes()
+ out["PRJLST.SMT"] = fresh.to_bytes()
+ slot_cols = sorted({SLOT_COL.match(c).group(1) for c in bt.order if SLOT_COL.match(c)})
+ return out, {
+ "from_pcode": pc,
+ "from_slot": s,
+ "k_ord": 1,
+ "rows": len(bt),
+ "slot_cols": slot_cols,
+ }
diff --git a/tools/stmate-test/stc_codechg.py b/tools/stmate-test/stc_codechg.py
new file mode 100644
index 000000000..825ec7da7
--- /dev/null
+++ b/tools/stmate-test/stc_codechg.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+"""코드 일괄 변경(ST_U_ChgCode Chg_Code_Sel · Exec_Change_Code) — 옛 코드 → 새 코드를 프로젝트 전체에 바꿈.
+
+근거: 10_System_186개_파일분석장부.md(ST_U_ChgCode — 코드 일괄·선택 변경 · Excel 왕복 · CH_CODE 임시표) ·
+ 23번(COSTN.SMT 식 참조 &코드[LMS]&) · STC_난독화_상태.md(BDQTY 자리별 변환) · 02번(BOQ BDCODE1~4)
+바꾸는 자리: COSTN.CODE · BOQ1n.BDCODE1~5 · BDQTY.BDCODE·CODE(암호 그대로 다시 숨김) · COSTN.SMT 식의 &코드…& ·
+ QCODE.BQCODE · BOQ1n.CODEA
+규칙: 새 코드는 6자 영숫자 · 이미 있는 코드로는 못 바꿈 · 같은 길이라 메모는 제자리 덮어씀
+"""
+
+from __future__ import annotations
+
+import re
+
+from dbf_crypt import BdqtyCodec
+from dbf_memo import SixMemo
+from dbf_rw import DbfTable
+from stc_save import SaveLog
+
+CODE_RE = re.compile(r"^[A-Z][0-9A-Z]{5}$")
+
+
+def _check(files: dict, mapping: dict[str, str]) -> dict[str, str]:
+ ct = DbfTable(files["COSTN.DBF"])
+ have = {ct.get(i, "CODE") for i in range(len(ct))}
+ m = {
+ str(a).strip().upper(): str(b).strip().upper() for a, b in mapping.items() if str(b).strip()
+ }
+ m = {a: b for a, b in m.items() if a != b}
+ for a, b in m.items():
+ if a not in have:
+ raise ValueError(f"COSTN 에 없는 코드: {a}")
+ if not CODE_RE.match(b):
+ raise ValueError(f"새 코드 형식 오류(영문 1자 + 5자): {b}")
+ if b in have and b not in m:
+ raise ValueError(f"이미 있는 코드로 바꿀 수 없음: {a} → {b}")
+ if len(set(m.values())) != len(m):
+ raise ValueError("새 코드가 겹침")
+ return m
+
+
+def _table(
+ files: dict, out: dict, name: str, cols: tuple[str, ...], m: dict, log: SaveLog, codec=None
+):
+ if name not in files:
+ return
+ t = DbfTable(files[name], codec=codec)
+ for i in range(len(t)):
+ for c in cols:
+ if c in t.fields:
+ v = t.get(i, c)
+ if v in m and t.set_raw(i, c, t.encode_value(c, m[v])):
+ log.add(name, i, c, v, m[v])
+ if t.dirty:
+ out[name] = t.to_bytes()
+
+
+def change_codes(files: dict, mapping: dict[str, str]) -> tuple[dict, list]:
+ """{옛: 새} → 새 files · 바뀐 자리 목록."""
+ m = _check(files, mapping)
+ out, log = dict(files), SaveLog()
+ if not m:
+ return out, []
+ _table(files, out, "COSTN.DBF", ("CODE",), m, log)
+ for name in sorted(k for k in files if re.match(r"^BOQ\d*\.DBF$", k)):
+ _table(files, out, name, tuple(f"BDCODE{n}" for n in range(1, 6)) + ("CODEA",), m, log)
+ _table(files, out, "BDQTY.DBF", ("BDCODE", "CODE"), m, log, codec=BdqtyCodec())
+ _table(files, out, "QCODE.DBF", ("BQCODE",), m, log)
+ if "COSTN.SMT" in files and "IFORM" in DbfTable(out["COSTN.DBF"]).fields:
+ ct = DbfTable(out["COSTN.DBF"])
+ memo = SixMemo(files["COSTN.SMT"])
+ pat = re.compile(r"&(" + "|".join(map(re.escape, m)) + r")([LMS]?)&")
+ for i in range(len(ct)):
+ ptr = ct.raw(i, "IFORM")
+ body = memo.read(ptr)
+ if not body:
+ continue
+ text = body.decode("cp949", "replace")
+ new = pat.sub(lambda x: f"&{m[x.group(1)]}{x.group(2)}&", text)
+ if new != text:
+ new_ptr = memo.write(ptr, new.encode("cp949"))
+ if ct.set_raw(i, "IFORM", new_ptr):
+ out["COSTN.DBF"] = ct.to_bytes()
+ log.add("COSTN.SMT", i, ct.get(i, "CODE"), "", f"{len(pat.findall(text))}곳")
+ if memo.dirty:
+ out["COSTN.SMT"] = memo.to_bytes()
+ return out, log.items
diff --git a/tools/stmate-test/stc_save.py b/tools/stmate-test/stc_save.py
index 1fc5f6854..9a9cfc79d 100644
--- a/tools/stmate-test/stc_save.py
+++ b/tools/stmate-test/stc_save.py
@@ -6,6 +6,7 @@
요청 키(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 — 바뀐 것만)
+ · prjlst_rows[공사목록 전체 행 · _recno] — 다른 공사 행도 바뀐 칸만(지금 공사 행은 prjlst_updates·직접비가 앞섬)
파생 규칙(실무 STC 7건 전 행에서 다시 셈 = 저장값 확인):
- 단가 전파: COSTN n차 단가가 바뀐 코드 → 그 코드 내역 행 중 옛 단가가 COSTN 옛 단가와 같던 행만 새 단가
- 행 금액: 그 행 n차 수량·단가가 바뀌었을 때만 ROUNDDOWN(QTYn × ?AMTn) · T_AMTn = 재+노+경
@@ -319,6 +320,13 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str
for c, v in (("MCOST", m), ("LCOST", lab), ("SCOST", sv), ("TCOST", t)):
if f"{c}{s}" in pt.fields and f"{c}{s}" not in upd:
upd[f"{c}{s}"] = v
+ for r in req.get("prjlst_rows") or []: # 공사목록 전체 행(scr_ui_prj) — 바뀐 칸만
+ k = int(r["_recno"]) if str(r.get("_recno", "")).isdigit() else -1
+ vals = {f: v for f, v in r.items() if not f.endswith("_HEX") and f != "PRJ_MEMO"}
+ if k == pi:
+ upd = {**{f: v for f, v in vals.items() if f not in upd}, **upd}
+ elif 0 <= k < len(pt):
+ _apply_fields(pt, "PRJLST.DBF", k, vals, log)
_apply_fields(pt, "PRJLST.DBF", pi, upd, log)
smt_upd = req.get("smt_updates") or {}
stages = req.get("kanjub_stages") or {}
diff --git a/tools/stmate-test/stc_save_extra.py b/tools/stmate-test/stc_save_extra.py
index 3a909a87a..70b70acfa 100644
--- a/tools/stmate-test/stc_save_extra.py
+++ b/tools/stmate-test/stc_save_extra.py
@@ -84,12 +84,26 @@ def _groups(rows: list[dict]) -> dict[str, list[int]]:
return out
-def save_bdqty(b: bytes, new_rows: list[dict], log) -> bytes | None:
- """화면 구성행(decode_bdqty 꼴) → 새 BDQTY 바이트 · 바뀐 것이 없으면 None."""
+def _lsame(t: DbfTable, k: str, a, b) -> bool:
+ """칸 꼴을 아는 비교 — 논리 칸은 빈칸 = F."""
+ f = t.fields.get(k)
+ if f is not None and f.type == "L":
+ n = lambda x: str(x or "").strip().upper()[:1] or "F" # noqa: E731
+ return n(a) == n(b)
+ return same_val(a, b)
+
+
+def save_bdqty(b: bytes, new_rows: list[dict], log, strict: bool = True) -> bytes | None:
+ """화면 구성행(decode_bdqty 꼴) → 새 BDQTY 바이트 · 바뀐 것이 없으면 None.
+ strict=False(엑셀 역등록) — 암호 표 밖 칸은 오류 대신 건너뜀."""
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())
+
+ def same(o: dict, n: dict) -> bool:
+ return all(_lsame(t, k, v, n[k]) for k, v in o.items() if k in n and not k.startswith("_"))
+
+ if len(old) == len(new_rows) and all(same(o, n) for o, n in zip(old, new_rows)):
+ return None
g_old, g_new = _groups(old), _groups(new_rows)
by_comp: dict[str, int] = {}
for i, r in enumerate(old):
@@ -98,7 +112,7 @@ def save_bdqty(b: bytes, new_rows: list[dict], log) -> bytes | None:
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)):
+ if len(olds) == len(news) and all(same(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):
@@ -117,15 +131,17 @@ def save_bdqty(b: bytes, new_rows: list[dict], log) -> bytes | None:
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):
+ if k.startswith("_") or k not in t.fields or _lsame(t, k, 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 f.type in ("N", "F") and _num(v) is None and str(v).strip():
+ continue # 숫자로 읽히지 않는 복호값(자리 표가 덜 맞는 칸)도 같음
if not covers(f.off, f.len):
- raise ValueError(f"BDQTY {k} 칸은 암호 표 밖이라 쓸 수 없음")
+ if strict:
+ raise ValueError(f"BDQTY {k} 칸은 암호 표 밖이라 쓸 수 없음")
+ continue
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()
@@ -137,7 +153,9 @@ def save_qcode(files: dict[str, bytes], rows: list[dict], log) -> dict[str, byte
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())]
+ 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]