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]