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>
This commit is contained in:
2026-11-13 18:42:04 +09:00
co-authored by Claude Opus 5.5
parent 123727e725
commit e987602f24
16 changed files with 2402 additions and 310 deletions
+33 -137
View File
@@ -6,27 +6,25 @@ from __future__ import annotations
import importlib
import pathlib
import re
import zipfile
from decimal import Decimal, ROUND_DOWN
from bdqty import decode_bdqty
from excel_builder import build_stmate_full_xlsx
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from bdqty import decode_bdqty
from excel_builder import build_stmate_full_xlsx
from kanjub_cfg import build_kanjub_config, stage_slots
from pydantic import BaseModel
from smt_parser import (
build_dbf_bytes,
extract_costn_memos,
load_stmate_catalog,
parse_dbf_bytes,
parse_ini_bytes,
parse_prjlst_smt,
rebuild_costn_with_smt,
update_prjlst_smt_bytes,
)
from stc_project import safe_name, write_output
from stc_read import Source, extra_tables
from stc_save import apply_save
HERE = pathlib.Path(__file__).resolve().parent
REPO_ROOT = HERE.parent.parent
@@ -93,22 +91,18 @@ def discover_sources() -> dict:
return {"projects": projects, "jbr_files": jbr_files, "work_root": str(WORK_DBF_ROOT)}
def read_project_files(source_path: str) -> dict[str, bytes]:
p = pathlib.Path(source_path)
if not p.exists():
def open_source(source_path: str) -> Source:
try:
return Source(source_path)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"경로를 찾을 수 없음: {source_path}")
files: dict[str, bytes] = {}
if p.is_file() and p.suffix.upper() == ".STC":
with zipfile.ZipFile(p, "r") as zf:
for nm in zf.namelist():
files[pathlib.Path(nm).name.upper()] = zf.read(nm)
elif p.is_dir():
for fp in p.iterdir():
if fp.is_file():
files[fp.name.upper()] = fp.read_bytes()
else:
raise HTTPException(status_code=400, detail="지원하지 않는 경로 형식")
return files
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
def read_project_files(source_path: str) -> dict[str, bytes]:
"""STC(ZIP)·폴더 → 대문자 이름 → 바이트 (stc_read.Source)."""
return open_source(source_path).files
def _make_syn_resource(res_id: str, name: str, spec: str, unit: str, kind: str, val: float) -> dict:
@@ -394,6 +388,7 @@ class SaveDbfReq(BaseModel):
prjlst_updates: dict = {}
smt_updates: dict = {}
costn_memo_updates: dict[str, str] = {}
boq_table: str = ""
@app.get("/api/sources")
@@ -457,6 +452,7 @@ def api_load(req: LoadReq):
"bdqty_rows": decode_bdqty(files.get("BDQTY.DBF", b"")),
"start_ini": parse_ini_bytes(files.get("START8X.INI") or files.get("STINI.MEM") or b""),
"sim_state": sim_state,
**extra_tables(files),
}
@@ -502,119 +498,19 @@ def api_load_jbr(req: LoadJbrReq):
@app.post("/api/save")
def api_save(req: SaveDbfReq):
files = read_project_files(req.source_path)
safe_name = re.sub(r"[^\w\-가-힣]", "_", req.target_folder_name or "stmate_edited").strip("_")
out_dir = WORK_DBF_ROOT / (safe_name or "stmate_edited")
out_dir.mkdir(parents=True, exist_ok=True)
if "COSTN.DBF" in files:
flds, rows, meta = parse_dbf_bytes(files["COSTN.DBF"])
upd_map = {str(u.get("CODE", "")).strip(): u for u in req.costn_updates if u.get("CODE")}
for r in rows:
cd = (r.get("CODE") or "").strip()
if cd in upd_map:
for k, v in upd_map[cd].items():
if k in r:
r[k] = str(v)
dbf_b = build_dbf_bytes(flds, rows, meta)
if req.costn_memo_updates:
dbf_b, smt_b = rebuild_costn_with_smt(
dbf_b, files.get("COSTN.SMT", b""), req.costn_memo_updates
)
files["COSTN.SMT"] = smt_b
files["COSTN.DBF"] = dbf_b
boq_name = "BOQ11.DBF" if "BOQ11.DBF" in files else "BOQ12.DBF"
if boq_name in files:
flds, rows, meta = parse_dbf_bytes(files[boq_name])
upd_by_rec = {int(u["_recno"]): u for u in req.boq_updates if "_recno" in u}
for r in rows:
rno = int(r.get("_recno", -1))
if rno in upd_by_rec:
u = upd_by_rec[rno]
for k in (
"ITNUM",
"NAME1",
"SIZE1",
"UNIT1",
"QTY1",
"QTY2",
"MAMT1",
"LAMT1",
"SAMT1",
"TAMT1",
"MAMT2",
"LAMT2",
"SAMT2",
"TAMT2",
"MAMT3",
"LAMT3",
"SAMT3",
"TAMT3",
):
if k in u and k in r:
r[k] = str(u[k])
bd = (r.get("BDCODE1") or "").strip()
if bd and not STRUCT_RE.match(bd):
for s_idx in ("1", "2", "3"):
q_k = (
f"QTY{s_idx}" if f"QTY{s_idx}" in r and r.get(f"QTY{s_idx}") else "QTY1"
)
q = Decimal(str(fnum(r.get(q_k))))
m_a = int(
(q * Decimal(str(fnum(r.get(f"MAMT{s_idx}"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
)
)
l_a = int(
(q * Decimal(str(fnum(r.get(f"LAMT{s_idx}"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
)
)
s_a = int(
(q * Decimal(str(fnum(r.get(f"SAMT{s_idx}"))))).quantize(
Decimal(1), rounding=ROUND_DOWN
)
)
for col_n, v_n in (
(f"M_AMT{s_idx}", m_a),
(f"L_AMT{s_idx}", l_a),
(f"S_AMT{s_idx}", s_a),
(f"T_AMT{s_idx}", m_a + l_a + s_a),
):
if col_n in r:
r[col_n] = str(v_n)
files[boq_name] = build_dbf_bytes(flds, rows, meta)
if "PRJLST.DBF" in files and req.prjlst_updates:
flds, rows, meta = parse_dbf_bytes(files["PRJLST.DBF"])
if rows:
for k, v in req.prjlst_updates.items():
if k in rows[0]:
rows[0][k] = str(v)
files["PRJLST.DBF"] = build_dbf_bytes(flds, rows, meta)
if "PRJLST.SMT" in files and req.smt_updates:
files["PRJLST.SMT"] = update_prjlst_smt_bytes(files["PRJLST.SMT"], req.smt_updates)
written_files = []
for fname, content in files.items():
(out_dir / fname).write_bytes(content)
written_files.append({"name": fname, "size": len(content)})
stc_path = WORK_DBF_ROOT / f"{out_dir.name}.STC"
with zipfile.ZipFile(stc_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for fname, content in files.items():
zf.writestr(fname, content)
return {
"ok": True,
"saved_dir": str(out_dir),
"saved_stc": str(stc_path),
"stc_size": stc_path.stat().st_size,
"written_count": len(written_files),
"written_files": written_files,
}
"""바뀐 칸만 고쳐 work_dbf/<이름>/ 와 <이름>.STC 로 씀(stc_save · stc_project) — 무변경이면 원본과 같은 바이트."""
src = open_source(req.source_path)
try:
files, changes = apply_save(src.files, req.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
res = write_output(
WORK_DBF_ROOT, safe_name(req.target_folder_name), files, src.names, src.entries
)
res["changed_files"] = sorted(k for k in files if files[k] is not src.files.get(k))
res["changes"] = changes[:500]
res["change_count"] = len(changes)
return res
class ExportXlsxReq(BaseModel):