# -*- 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