- DBF 고친 칸만 다시 씀(한글 깨짐 · 2칸을 1칸으로 · 3칸 87.745% 덮어쓰기 없앰) · 단가 → 내역 행 → 소계 → 직공비 다시 셈 · 행 끼우기·지우기 - SIxMemo(COSTN.SMT · PRJLST.SMT) 블록 구조 유지 · 넘치면 뒤에 붙이고 가리키는 값 갱신 - STC 다시 묶기(같은 파일은 원본 바이트) · 검사 · 자동 백업·복원 · 새 공사 · 복사등록(차수 인계) · 해당분 압축 - BDQTY 역변환(쓰기 준비) · 모듈 dbf_rw · dbf_memo · dbf_crypt · stc_archive · stc_save · stc_read · stc_project · api_file · scr_file - 지킴 시험 test_stmate_file_roundtrip · file_project · file_payload — 실무 STC 7건 왕복 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
11 KiB
Python
262 lines
11 KiB
Python
"""STMATE 재현 앱 파일 저장 무손실 — 실무 STC 7건 열기 → 저장 왕복과 고친 칸만 바뀌는지.
|
|
|
|
정답은 원본 STC 자체(코드에 값을 박지 않음). 쓰기는 임시 폴더(server.WORK_DBF_ROOT 바꿈)에서만.
|
|
근거: STC_왕복검증.md R2·R3 · SIxMemo_구조.md · 16_골든셋_회귀_결과.md §4
|
|
"""
|
|
|
|
import struct
|
|
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 dbf_crypt import BdqtyCodec, covers # noqa: E402
|
|
from dbf_memo import SixMemo, ptr_unpack, ptr_valid # 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, files_of, read_entries # noqa: E402
|
|
from stc_save import _leaf, apply_save, boq_spans # noqa: E402
|
|
|
|
DOCS = ROOT / "resources/knowledge/original/실무문서"
|
|
STCS = sorted(DOCS.rglob("*.STC"))
|
|
IDS = [p.stem[:16] for p in STCS]
|
|
|
|
|
|
def _files(p):
|
|
return files_of(read_entries(p.read_bytes()))
|
|
|
|
|
|
def _full_payload(files):
|
|
"""화면이 보내는 꼴 — 불러온 값 전부(글자 칸은 strip 된 값)."""
|
|
_, crows, _ = parse_dbf_bytes(files["COSTN.DBF"])
|
|
_, brows, _ = parse_dbf_bytes(files["BOQ11.DBF"])
|
|
_, prows, _ = parse_dbf_bytes(files["PRJLST.DBF"])
|
|
clean = lambda r: {k: v for k, v in r.items() if not k.endswith("_HEX") and k not in ("IFORM", "PRJ_MEMO", "XCODE")} # noqa: E731,E501
|
|
memos = extract_costn_memos(files["COSTN.DBF"], files["COSTN.SMT"])
|
|
return {
|
|
"costn_updates": [clean(r) for r in crows],
|
|
"boq_updates": [clean(r) for r in brows],
|
|
"prjlst_updates": {k: v for k, v in clean(prows[0]).items() if not k.startswith("_")},
|
|
"smt_updates": parse_prjlst_smt(files["PRJLST.SMT"], prows[0]["PRJ_MEMO_HEX"]),
|
|
"costn_memo_updates": {c: m["text"] for c, m in memos.items()},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def work(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
|
|
return tmp_path
|
|
|
|
|
|
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
|
def test_stc_rebuild_is_byte_identical(stc):
|
|
b = stc.read_bytes()
|
|
ents = read_entries(b)
|
|
assert build_stc(ents, files_of(ents)) == b
|
|
for name, data in files_of(ents).items():
|
|
if name.endswith(".DBF"):
|
|
assert DbfTable(data).to_bytes() == data, name
|
|
|
|
|
|
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
|
def test_api_save_without_edits_reproduces_stc(stc, work):
|
|
req = server.SaveDbfReq(source_path=str(stc), target_folder_name="rt", **_full_payload(_files(stc)))
|
|
res = server.api_save(req)
|
|
assert res["change_count"] == 0 and res["changed_files"] == []
|
|
assert Path(res["saved_stc"]).read_bytes() == stc.read_bytes()
|
|
for e in read_entries(stc.read_bytes()):
|
|
assert (work / "rt" / e["name"]).read_bytes() == e["data"], e["name"]
|
|
server.api_save(req) # 두 번째 저장 — 앞 작업본 백업
|
|
assert list((work / "_backup").glob("rt__*.STC"))
|
|
|
|
|
|
def _changed_cells(a: bytes, b: bytes):
|
|
ta, tb = DbfTable(a), DbfTable(b)
|
|
assert len(ta) == len(tb)
|
|
out = set()
|
|
for i in range(len(ta)):
|
|
for f in ta.order:
|
|
if ta.raw(i, f) != tb.raw(i, f):
|
|
out.add((i, f))
|
|
return out
|
|
|
|
|
|
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
|
def test_boq_qty_edit_changes_only_derived_cells(stc):
|
|
f = _files(stc)
|
|
bt = DbfTable(f["BOQ11.DBF"])
|
|
i = next(
|
|
k
|
|
for k in range(len(bt))
|
|
if _leaf(bt.get(k, "BDCODE1")) and float(bt.get(k, "TAMT1") or 0) > 0
|
|
and not bt.get(k, "UNIT1").endswith("%")
|
|
)
|
|
q = float(bt.get(i, "QTY1") or 0) + 1
|
|
out, log = apply_save(f, {"boq_updates": [{"_recno": i, "QTY1": q, "NAME1": bt.get(i, "NAME1")}]})
|
|
spans, _excl = boq_spans(bt)
|
|
span_rows = {r for r, a, b, _x in spans if a <= i <= b}
|
|
allowed = {(i, c) for c in ("QTY1", "M_AMT1", "L_AMT1", "S_AMT1", "T_AMT1")}
|
|
allowed |= {(r, c) for r in span_rows for c in ("M_AMT1", "L_AMT1", "S_AMT1", "T_AMT1")}
|
|
cells = _changed_cells(f["BOQ11.DBF"], out["BOQ11.DBF"])
|
|
assert (i, "QTY1") in cells and cells <= allowed
|
|
nt = DbfTable(out["BOQ11.DBF"])
|
|
assert float(nt.get(i, "QTY1")) == q
|
|
assert nt.get(i, "NAME1") == bt.get(i, "NAME1") # 한글 그대로
|
|
# 소계 행 = 구간 말단 합 · PRJLST TCOST1 = 직접비
|
|
for r, a, b, isx in spans:
|
|
tot = sum(
|
|
int(float(nt.get(k, "T_AMT1") or 0))
|
|
for k in range(a, b + 1)
|
|
if _leaf(nt.get(k, "BDCODE1")) and (isx or k not in _excl)
|
|
)
|
|
assert int(float(nt.get(r, "T_AMT1") or 0)) == tot
|
|
pt, pt0 = DbfTable(out["PRJLST.DBF"]), DbfTable(f["PRJLST.DBF"])
|
|
direct = sum(
|
|
int(float(nt.get(k, "T_AMT1") or 0))
|
|
for k in range(len(nt))
|
|
if _leaf(nt.get(k, "BDCODE1")) and k not in _excl
|
|
)
|
|
assert int(float(pt.get(0, "TCOST1"))) == direct
|
|
assert {c for _r, c in _changed_cells(f["PRJLST.DBF"], out["PRJLST.DBF"])} <= {
|
|
"MCOST1", "LCOST1", "SCOST1", "TCOST1"
|
|
}
|
|
assert pt.get(0, "PNAME") == pt0.get(0, "PNAME")
|
|
for k in f: # 다른 파일은 그대로
|
|
if k not in ("BOQ11.DBF", "PRJLST.DBF"):
|
|
assert out[k] == f[k], k
|
|
assert {x["table"] for x in log} <= {"BOQ11.DBF", "PRJLST.DBF"}
|
|
|
|
|
|
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
|
def test_costn_price_edit_propagates_to_linked_boq_rows(stc):
|
|
f = _files(stc)
|
|
ct, bt = DbfTable(f["COSTN.DBF"]), DbfTable(f["BOQ11.DBF"])
|
|
crow = {ct.get(i, "CODE"): i for i in range(len(ct))}
|
|
linked = [
|
|
k
|
|
for k in range(len(bt))
|
|
if bt.get(k, "BDCODE1") in crow
|
|
and all(
|
|
float(bt.get(k, f"{c}AMT1") or 0) == float(ct.get(crow[bt.get(k, "BDCODE1")], f"{c}AMT1") or 0)
|
|
for c in "MLS"
|
|
)
|
|
and float(bt.get(k, "MAMT1") or 0) > 0
|
|
]
|
|
if not linked:
|
|
pytest.skip("단가가 같은 연결 행 없음")
|
|
code = bt.get(linked[0], "BDCODE1")
|
|
m1 = float(ct.get(crow[code], "MAMT1")) + 10
|
|
out, _log = apply_save(f, {"costn_updates": [{"CODE": code, "MAMT1": m1}]})
|
|
cc = _changed_cells(f["COSTN.DBF"], out["COSTN.DBF"])
|
|
assert cc == {(crow[code], "MAMT1")}
|
|
nt = DbfTable(out["BOQ11.DBF"])
|
|
for k in range(len(bt)):
|
|
if bt.get(k, "BDCODE1") == code:
|
|
want = m1 if k in linked else float(bt.get(k, "MAMT1") or 0)
|
|
assert float(nt.get(k, "MAMT1")) == want
|
|
k = linked[0]
|
|
assert int(float(nt.get(k, "M_AMT1"))) == int(float(nt.get(k, "QTY1")) * m1 + 1e-9)
|
|
|
|
|
|
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
|
def test_costn_memo_edit_keeps_sixmemo_layout(stc):
|
|
f = _files(stc)
|
|
memos = extract_costn_memos(f["COSTN.DBF"], f["COSTN.SMT"])
|
|
codes = sorted(memos, key=lambda c: memos[c]["blockNo"])
|
|
grow, shrink = codes[0], codes[-1]
|
|
upd = {grow: memos[grow]["text"] + "\r\n'추가 줄' 1'개' =" * 40, shrink: memos[shrink]["text"][:20]}
|
|
out, _ = apply_save(f, {"costn_memo_updates": upd})
|
|
smt, smt0 = out["COSTN.SMT"], f["COSTN.SMT"]
|
|
assert smt[4:16] == smt0[4:16]
|
|
m = SixMemo(smt)
|
|
assert m.next_free == -(-len(smt) // 64) # 다음 빈 블록 = 파일 끝 블록
|
|
new = extract_costn_memos(out["COSTN.DBF"], smt)
|
|
for c, v in memos.items():
|
|
nl = lambda t: t.replace("\r\n", "\n").replace("\r", "\n") # noqa: E731
|
|
assert nl(new[c]["text"]) == nl(upd.get(c, v["text"])), c
|
|
# 늘어난 메모는 뒤에 붙고 줄어든 메모는 제자리 · 다른 메모 블록 바이트 그대로
|
|
assert new[grow]["blockNo"] >= len(smt0) // 64
|
|
assert new[shrink]["blockNo"] == memos[shrink]["blockNo"]
|
|
for c, v in memos.items():
|
|
if c not in upd:
|
|
st = v["blockNo"] * 64
|
|
assert smt[st : st + v["byteLen"]] == smt0[st : st + v["byteLen"]]
|
|
cc = _changed_cells(f["COSTN.DBF"], out["COSTN.DBF"])
|
|
assert {c for _r, c in cc} == {"IFORM"}
|
|
|
|
|
|
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
|
def test_prjlst_smt_update_writes_valid_memo(stc):
|
|
f = _files(stc)
|
|
_, prows, _ = parse_dbf_bytes(f["PRJLST.DBF"])
|
|
kv = parse_prjlst_smt(f["PRJLST.SMT"], prows[0]["PRJ_MEMO_HEX"])
|
|
key = next(k for k in kv if k.startswith("1_R_"))
|
|
upd = {key: float(kv[key]) + 0.5, "1_Z_FILE_TEST": 7}
|
|
out, _ = apply_save(f, {"smt_updates": upd})
|
|
pt = DbfTable(out["PRJLST.DBF"])
|
|
ptr = pt.raw(0, "PRJ_MEMO")
|
|
assert ptr_valid(ptr) and ptr_unpack(ptr)[0] == ptr_unpack(bytes.fromhex(prows[0]["PRJ_MEMO_HEX"]))[0]
|
|
kv2 = parse_prjlst_smt(out["PRJLST.SMT"], ptr.hex())
|
|
assert float(kv2[key]) == float(kv[key]) + 0.5 and kv2["1_Z_FILE_TEST"] == "7"
|
|
assert {k: v for k, v in kv2.items() if k not in upd} == {k: v for k, v in kv.items() if k not in upd}
|
|
assert struct.unpack("<I", out["PRJLST.SMT"][:4])[0] == -(-len(out["PRJLST.SMT"]) // 64)
|
|
if len(pt) > 1: # 둘째 공사 메모는 그대로
|
|
m0, m1 = SixMemo(f["PRJLST.SMT"]), SixMemo(out["PRJLST.SMT"])
|
|
assert m0.read(DbfTable(f["PRJLST.DBF"]).raw(1, "PRJ_MEMO")) == m1.read(pt.raw(1, "PRJ_MEMO"))
|
|
|
|
|
|
def test_korean_edit_and_unencodable_text(work):
|
|
stc = STCS[0]
|
|
f = _files(stc)
|
|
out, _ = apply_save(f, {"prjlst_updates": {"PNAME": "임도 시설 공사(변경)"}})
|
|
assert DbfTable(out["PRJLST.DBF"]).get(0, "PNAME") == "임도 시설 공사(변경)"
|
|
with pytest.raises(Exception):
|
|
server.api_save(
|
|
server.SaveDbfReq(source_path=str(stc), target_folder_name="bad", prjlst_updates={"PNAME": "工事🙂"})
|
|
)
|
|
|
|
|
|
def test_boq_insert_and_delete_rows():
|
|
stc = STCS[0]
|
|
f = _files(stc)
|
|
_, brows, _ = parse_dbf_bytes(f["BOQ11.DBF"])
|
|
leaf = next(r for r in brows if _leaf(r["BDCODE1"]) and float(r["TAMT1"] or 0) > 0)
|
|
k = leaf["_recno"]
|
|
ups = []
|
|
for r in brows:
|
|
ups.append({"_recno": r["_recno"]})
|
|
if r["_recno"] == k:
|
|
ups.append({"_new": True, "BDCODE1": leaf["BDCODE1"], "NAME1": "끼운 행", "QTY1": 2})
|
|
out, log = apply_save(f, {"boq_updates": ups, "boq_deleted": []})
|
|
nt = DbfTable(out["BOQ11.DBF"])
|
|
assert len(nt) == len(brows) + 1 and nt.get(k + 1, "NAME1") == "끼운 행"
|
|
s0, s1, s2 = (float(nt.get(j, "SORTCODE")) for j in (k, k + 1, k + 2))
|
|
assert s0 < s1 < s2
|
|
assert int(float(nt.get(k + 1, "M_AMT1"))) == int(2 * float(leaf["MAMT1"]))
|
|
out2, _ = apply_save(out, {"boq_deleted": [k + 1]})
|
|
assert DbfTable(out2["BOQ11.DBF"]).recs == DbfTable(f["BOQ11.DBF"]).recs
|
|
assert any(x["field"] == "_insert" for x in log)
|
|
|
|
|
|
@pytest.mark.parametrize("stc", STCS, ids=IDS)
|
|
def test_bdqty_codec_is_inverse(stc):
|
|
b = _files(stc)["BDQTY.DBF"]
|
|
t, codec = DbfTable(b), BdqtyCodec()
|
|
n = 0
|
|
for i in range(min(len(t), 60)):
|
|
for name in t.order:
|
|
fd = t.fields[name]
|
|
if covers(fd.off, fd.len):
|
|
c = bytes(t.recs[i][fd.off : fd.off + fd.len])
|
|
assert codec.encode(codec.decode(c, fd.off), fd.off) == c
|
|
n += 1
|
|
assert n > 0
|
|
ct = DbfTable(b, codec=codec)
|
|
name = next(x for x in ct.order if ct.fields[x].type == "N" and covers(ct.fields[x].off, ct.fields[x].len))
|
|
v = float(ct.get(0, name) or 0)
|
|
assert ct.set(0, name, v + 1) and float(ct.get(0, name)) == v + 1
|
|
assert ct.set(0, name, v) and ct.to_bytes()[32:] == b[32:]
|