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:
+33
-137
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user