feat(stmate-test): 2차 — 엑셀·조달청 XML 가져오기 · 분리형 변경 STC · JB 요율표 · 저장 확장(새 코드·구성표·QCODE·제잡비 설정) · 장부

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 19:55:07 +09:00
co-authored by Claude Opus 5.5
parent 47379758ee
commit d07d65683c
22 changed files with 2514 additions and 32 deletions
+77
View File
@@ -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_* 만
+173
View File
@@ -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개 셀만 다름」
+216
View File
@@ -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"}
+143
View File
@@ -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
View File
@@ -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)
+251
View File
@@ -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)
+111
View File
@@ -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
+142
View File
@@ -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()},
}
+1
View File
@@ -37,6 +37,7 @@ INFO_FIELD = {
"RCU": "RCU",
"KWN": "KWNGP",
"BLA": "BL_AMT",
"IPR": "PRPT", # 비율 줄(PR) 밑수 표시
}
INITIAL = "INITIAL"
+242
View File
@@ -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",
]
+213
View File
@@ -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
+238
View File
@@ -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,
}
+161
View File
@@ -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]]}
+249
View File
@@ -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);
+2 -1
View File
@@ -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) {
+6 -1
View File
@@ -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)],
}
)
+33
View File
@@ -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"]
+91
View File
@@ -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,
}
+87
View File
@@ -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
+8
View File
@@ -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 {}
+28 -10
View File
@@ -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]