Files
Aislo/tools/stmate-test/stc_project.py
T
eomsangdonandClaude Opus 5.5 e987602f24 feat(stmate-test): 무손실 저장 — 손대지 않은 저장 = 원본 STC 바이트 동일 · 고친 칸만 다시 씀
- 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>
2026-11-13 18:42:04 +09:00

268 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""공사 단위 파일 작업 — 작업본 쓰기·자동 백업/복원 · 복사등록(차수/단계 변환) · 해당분만 압축 · 새 공사.
근거: 08_규칙6_8_9_재계산_변경설계_일괄변경.md §2(통합형 BOQ11+BOQ12 · 분리형 새 STC) ·
16_골든셋_회귀_결과.md §4.5·§4.6(새 파일 BOQ11 차수1 = 앞 파일 BOQ12) · 27_계약_실행_기성_단계.md §2(계약 변환) ·
24_모듈구조_전수.md(ST_U_Archive ComP_Data · wM_D_Restore) · SIxMemo_구조.md
규칙:
- PRJLST 한 행 = 공사 하나 · PCODE '1n' ↔ BOQ1n.DBF · PRJ_MEMO = 그 공사의 원가 설정(PRJLST.SMT)
- 복사등록: 원본 BOQ1n 을 새 BOQ1m 으로 복사 + PRJLST 행·메모 복사(m = 빈 번호)
copy 그대로
change 변경 차수 변환 — K_ORD k → k+1 · 내역 (k+1)차 칸 → (k+2)차 칸 복사 · [k+1_KANJUB] → [k+2_KANJUB]
contract 계약 변환 — 1차 단가 × 비목별 적용율(%) 버림 · 제외 행(C* 구역 + 고른 행) 그대로 · 0% = 공내역
- 해당분만 압축: 고른 공사만 BOQ11·PRJLST 한 행·PRJLST.SMT 한 메모(8블록부터)로 새 STC
- 저장마다 같은 이름 작업본 STC 가 있으면 _backup/ 에 시각 붙여 남김(최근 10벌)
"""
from __future__ import annotations
import datetime
import pathlib
import re
import shutil
from decimal import ROUND_DOWN
from dbf_memo import BLANK_PTR, SixMemo, ini_items, memo_text_bytes
from dbf_rw import DbfTable
from stc_archive import build_stc
from stc_save import SaveLog, _dec, _leaf, _row_amounts, boq_spans, recompute_boq
BACKUP_KEEP = 10
SLOT_COL = re.compile(
r"^(QTY|TAMT|LAMT|MAMT|SAMT|T_AMT|L_AMT|M_AMT|S_AMT|EXIST|BDCODE|AAMT|A_AMT)(\d)$"
)
def safe_name(name: str) -> str:
return re.sub(r"[^\w\-가-힣]", "_", name or "stmate_edited").strip("_") or "stmate_edited"
def backup_dir(root: pathlib.Path) -> pathlib.Path:
return root / "_backup"
def make_backup(root: pathlib.Path, name: str) -> str:
stc = root / f"{name}.STC"
if not stc.exists():
return ""
bd = backup_dir(root)
bd.mkdir(parents=True, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
dst = bd / f"{name}__{stamp}.STC"
shutil.copy2(stc, dst)
olds = sorted(bd.glob(f"{name}__*.STC"))
for p in olds[:-BACKUP_KEEP]:
p.unlink()
return str(dst)
def list_backups(root: pathlib.Path, name: str = "") -> list[dict]:
bd = backup_dir(root)
if not bd.exists():
return []
pat = f"{name}__*.STC" if name else "*__*.STC"
return [
{
"name": p.stem.split("__")[0],
"path": str(p),
"size": p.stat().st_size,
"stamp": p.stem.split("__")[1],
}
for p in sorted(bd.glob(pat), reverse=True)
]
def write_output(root: pathlib.Path, target: str, files: dict, names: dict, entries: list) -> dict:
"""작업본 폴더(원래 이름 대소문자) + 같은 이름 STC — 덮기 전 백업."""
name = safe_name(target)
out_dir = root / name
out_dir.mkdir(parents=True, exist_ok=True)
backup = make_backup(root, name)
written = []
for up, data in files.items():
fname = names.get(up, up)
(out_dir / fname).write_bytes(data)
written.append({"name": fname, "size": len(data)})
stc_path = root / f"{name}.STC"
stc_path.write_bytes(build_stc(entries, files))
return {
"ok": True,
"saved_dir": str(out_dir),
"saved_stc": str(stc_path),
"stc_size": stc_path.stat().st_size,
"backup": backup,
"written_count": len(written),
"written_files": written,
}
# ── 공사(PRJLST 행) 다루기 ──
def _prj_index(prj: DbfTable, pcode: str) -> int:
for i in range(len(prj)):
if prj.get(i, "PCODE") == pcode:
return i
raise ValueError(f"PRJLST 에 PCODE {pcode} 공사 없음")
def _free_pcode(files: dict, prj: DbfTable) -> str:
used = {prj.get(i, "PCODE") for i in range(len(prj))} | {
k[3:5] for k in files if re.match(r"^BOQ1\d\.DBF$", k)
}
for n in range(11, 20):
if str(n) not in used:
return str(n)
raise ValueError("빈 공사 번호(BOQ11~19) 없음")
def _rename_section(text: str, src: int, dst: int) -> str:
"""[src_KANJUB] 섹션을 [dst_KANJUB] 로 복사해 덧붙임(이미 있으면 그대로)."""
secs = {}
for sec, k, v in ini_items(text):
secs.setdefault(sec, []).append((k, v))
if f"{dst}_KANJUB" in secs or f"{src}_KANJUB" not in secs:
return text
body = [f"[{dst}_KANJUB]"] + [
f"{re.sub(rf'^{src}_', f'{dst}_', k)}={v}" for k, v in secs[f"{src}_KANJUB"]
]
sep = "" if text.endswith("\r\n") or not text else "\r\n"
return text + sep + "\r\n".join(body)
def _copy_slot(tbl: DbfTable, a: str, b: str) -> None:
cols = []
for c in tbl.order:
m = SLOT_COL.match(c)
if m and m.group(2) == a and f"{m.group(1)}{b}" in tbl.fields:
cols.append((c, f"{m.group(1)}{b}"))
for i in range(len(tbl)):
for x, y in cols:
tbl.set_raw(i, y, tbl.raw(i, x))
def _contract(tbl: DbfTable, name: str, rates: dict, exclude: set[int], log: SaveLog) -> None:
_spans, excl = boq_spans(tbl)
for i in range(len(tbl)):
bd = tbl.get(i, "BDCODE1")
if not _leaf(bd) or i in excl or i in exclude:
continue
parts = []
for c in "MLS":
r = _dec(rates.get(c, 100)) / 100
v = (_dec(tbl.get(i, f"{c}AMT1")) * r).to_integral_value(rounding=ROUND_DOWN)
log.set(tbl, name, i, f"{c}AMT1", v)
parts.append(v)
log.set(tbl, name, i, "TAMT1", sum(parts))
_row_amounts(tbl, name, i, "1", {}, log)
def copy_register(
files: dict, src_pcode: str, mode: str = "copy", pname: str = "", rates=None, exclude=()
) -> tuple[dict, str, list]:
"""복사등록 — (새 files, 새 PCODE, 바뀐 칸 목록)."""
out = dict(files)
prj = DbfTable(files["PRJLST.DBF"])
si = _prj_index(prj, src_pcode)
src_tbl = f"BOQ{src_pcode}.DBF"
if src_tbl not in files:
raise ValueError(f"{src_tbl} 없음")
new = _free_pcode(files, prj)
new_tbl = f"BOQ{new}.DBF"
log = SaveLog()
bt = DbfTable(files[src_tbl])
bt.dirty = True
k = int(float(prj.get(si, "K_ORD") or 0)) if "K_ORD" in prj.fields else 0
memo = SixMemo(files.get("PRJLST.SMT"))
text = memo.read(prj.raw(si, "PRJ_MEMO")).decode("cp949", "replace")
ni = prj.insert(len(prj), {}, template=si)
prj.set(ni, "PCODE", new)
if pname:
prj.set(ni, "PNAME", pname)
if mode == "change":
a, b = str(k + 1), str(k + 2)
_copy_slot(bt, a, b)
for c in ("MCOST", "LCOST", "SCOST", "TCOST", "ACOST", "COSTW"):
if f"{c}{a}" in prj.fields and f"{c}{b}" in prj.fields:
prj.set_raw(ni, f"{c}{b}", prj.raw(si, f"{c}{a}"))
prj.set(ni, "K_ORD", k + 1)
text = _rename_section(text, k + 1, k + 2)
elif mode == "contract":
_contract(bt, new_tbl, rates or {}, set(exclude), log)
direct = recompute_boq(bt, new_tbl, {"1"}, log)
for c, v in zip(("MCOST1", "LCOST1", "SCOST1", "TCOST1"), direct.get("1", [])):
prj.set(ni, c, v)
elif mode != "copy":
raise ValueError(f"모르는 복사등록 방식: {mode}")
ptr = memo.write(BLANK_PTR, memo_text_bytes(text)) if text else BLANK_PTR
prj.set_raw(ni, "PRJ_MEMO", ptr)
out[new_tbl] = bt.to_bytes()
out["PRJLST.DBF"] = prj.to_bytes()
out["PRJLST.SMT"] = memo.to_bytes()
return out, new, log.items
def pack_selected(files: dict, pcode: str) -> dict:
"""해당분만 압축 — 고른 공사만 BOQ11 · PRJLST 한 행(PCODE 11) · 그 메모 하나."""
out = {k: v for k, v in files.items() if not re.match(r"^BOQ1\d\.DBF$", k)}
prj = DbfTable(files["PRJLST.DBF"])
si = _prj_index(prj, pcode)
src = files.get(f"BOQ{pcode}.DBF")
if src is None:
raise ValueError(f"BOQ{pcode}.DBF 없음")
out["BOQ11.DBF"] = src
memo = SixMemo(files.get("PRJLST.SMT"))
body = memo.read(prj.raw(si, "PRJ_MEMO"))
fresh = SixMemo(None, memo.bs)
prj.recs = [prj.recs[si]]
prj.dirty = True
prj.set(0, "PCODE", "11")
prj.set_raw(0, "PRJ_MEMO", fresh.write(BLANK_PTR, body) if body else BLANK_PTR)
out["PRJLST.DBF"] = prj.to_bytes()
out["PRJLST.SMT"] = fresh.to_bytes()
return out
def _empty(b: bytes) -> bytes:
t = DbfTable(b)
t.recs, t.dirty = [], True
return t.to_bytes()
def new_project(template: dict, pname: str, pnum: str = "") -> dict:
"""새 공사 — 틀 프로젝트의 표 구조·요율표·서식·설정은 두고 내역·단가·구성·수량은 비움."""
out = dict(template)
for k in list(out):
if re.match(r"^BOQ1[2-9]\.DBF$", k):
del out[k]
for k in ("BOQ11.DBF", "COSTN.DBF", "BDQTY.DBF", "QCODE.DBF", "COSTA.DBF"):
if k in out:
out[k] = _empty(out[k])
out["COSTN.SMT"] = SixMemo(None).to_bytes()
out["QCODE.SMT"] = SixMemo(None).to_bytes()
prj = DbfTable(template["PRJLST.DBF"])
memo = SixMemo(template.get("PRJLST.SMT"))
text = memo.read(prj.raw(0, "PRJ_MEMO")).decode("cp949", "replace")
keep, sec = [], ""
for ln in text.split("\r\n"):
s = ln.strip()
if s.startswith("["):
sec = s[1:-1]
if sec in ("KANJUB", "1_KANJUB") and ln:
m = re.match(r"^(1_C_\w+)=", s)
keep.append(f"{m.group(1)}=0" if m else ln)
prj.recs = [prj.recs[0]]
prj.dirty = True
for f in prj.order:
fd = prj.fields[f]
if fd.type in ("N", "F") and re.match(r"^[MLSTA]COST\d$|^COSTW\d$", f):
prj.set(0, f, 0)
prj.set(0, "PNAME", pname)
prj.set(0, "PNUM", pnum)
prj.set(0, "PCODE", "11")
if "K_ORD" in prj.fields:
prj.set(0, "K_ORD", 0)
fresh = SixMemo(None)
prj.set_raw(0, "PRJ_MEMO", fresh.write(BLANK_PTR, memo_text_bytes("\r\n".join(keep))))
out["PRJLST.DBF"] = prj.to_bytes()
out["PRJLST.SMT"] = fresh.to_bytes()
return out