feat(stmate-test): 2차 — 엑셀·조달청 XML 가져오기 · 분리형 변경 STC · JB 요율표 · 저장 확장(새 코드·구성표·QCODE·제잡비 설정) · 장부
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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_* 만
|
||||
@@ -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개 셀만 다름」
|
||||
@@ -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"}
|
||||
@@ -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": "임도공사"})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+14
-14
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()},
|
||||
}
|
||||
@@ -37,6 +37,7 @@ INFO_FIELD = {
|
||||
"RCU": "RCU",
|
||||
"KWN": "KWNGP",
|
||||
"BLA": "BL_AMT",
|
||||
"IPR": "PRPT", # 비율 줄(PR) 밑수 표시
|
||||
}
|
||||
INITIAL = "INITIAL"
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 '<?xml version="1.0" encoding="UTF-8"?>\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]]}
|
||||
@@ -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) => `<option value="${p.path}">${p.name}</option>`)
|
||||
.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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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("<IHH", b, 4)
|
||||
nf, o, names, tot = 0, 32, [], 1
|
||||
@@ -268,6 +297,9 @@ def check_stc(b: bytes) -> 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"]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user