- 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>
268 lines
10 KiB
Python
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
|