Files
Aislo/resources/tester/test_stmate_conv_xls.py
T

217 lines
8.7 KiB
Python

"""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"}