feat(stmate-test): 3차 화면·데이터 마무리 — 보호표시 · 공종번호 · 직종·노임 · 제비율표 편집 · 차수 칸 저장 · 계약 변환등록 = 울진 계약 STC · 장부 87.6%

- 시험 615 통과 · 브라우저 화면 13개·메뉴 90개 오류 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 21:00:14 +09:00
co-authored by Claude Opus 5.5
parent e8b76dd65b
commit ffc2401870
36 changed files with 1907 additions and 78 deletions
+158
View File
@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""공사(내역서) 삭제·단계 등록(차수) 끼워 넣기 API — 공사목록표(wPrjList) 「내역서 삭제」(Act_Del_PrjExecute · ST_U_DelPrj Delete_Prj).
근거: REA 09 resource-TWPRJLIST 메뉴 「내역서 삭제」 · old/30_원자료 module_exports(ST_U_DelPrj: Delete_Prj)
· 02번(PRJLST 한 행 = 공사 · PCODE '1n' ↔ BOQ1n.DBF · PRJ_MEMO = 그 공사 원가 설정)
규칙: 고른 PCODE 의 PRJLST 행과 BOQ1n.DBF 를 뺌 · 다른 공사 행·표·메모는 바이트 그대로 · 마지막 한 공사는 지우지 않음
(원본 확인 문구·선택지는 근거 없음 — 화면은 확인 창 한 번)
단계 등록(NewProject → MkBoq · REA 31 Main_K_ORD_registration_transition_matrix · 최종보고서 §4.1):
원본 PCLASS × 등록(6 기성 · 7 설계변경) × 플래그(0 차수별 · 1 당초/변경) → 새 K_ORD(1 또는 K+1) · 상한 없음
새 PCLASS = 등록 번호(4 계약 · 6 기성 · 7 설계변경 갈래와 같은 수 — 추정)
K+1 = 다음 차수 칸 열기(stc_project change) · 1 = 원본 마지막 차수 칸을 1·2 칸(당초·변경)으로 옮겨 새로 시작
"""
from __future__ import annotations
import pathlib
from dbf_rw import DbfTable
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from stc_project import SLOT_COL, copy_register, safe_name, write_output
from stc_read import Source
from stc_save import SaveLog, recompute_boq
router = APIRouter()
def _root() -> pathlib.Path:
import server # 실행 중 바뀔 수 있는 작업 폴더(시험은 임시 폴더로 바꿈)
return server.WORK_DBF_ROOT
def delete_project(files: dict, pcode: str) -> tuple[dict, list]:
"""(새 files, 바뀐 목록) — PRJLST 행 · BOQ{pcode}.DBF 뺌."""
pcode = str(pcode).strip()
if "PRJLST.DBF" not in files:
raise ValueError("PRJLST.DBF 없음")
prj = DbfTable(files["PRJLST.DBF"])
idx = [i for i in range(len(prj)) if prj.get(i, "PCODE") == pcode]
if not idx:
raise ValueError(f"PRJLST 에 PCODE {pcode} 공사 없음")
if len(prj) - len(idx) < 1:
raise ValueError("마지막 공사는 지울 수 없음")
log = [
{"table": "PRJLST.DBF", "recno": i, "field": "_delete", "old": pcode, "new": ""}
for i in idx
]
prj.recs = [r for i, r in enumerate(prj.recs) if i not in set(idx)]
prj.dirty = True
out = dict(files)
out["PRJLST.DBF"] = prj.to_bytes()
tbl = f"BOQ{pcode}.DBF"
if out.pop(tbl, None) is not None:
log.append({"table": tbl, "recno": -1, "field": "_drop", "old": tbl, "new": ""})
return out, log
class DelPrjReq(BaseModel):
source_path: str
pcode: str
target_folder_name: str = ""
@router.post("/api/ui/project-delete")
def api_project_delete(req: DelPrjReq):
try:
s = Source(req.source_path)
files, log = delete_project(s.files, req.pcode)
except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, str(e))
target = req.target_folder_name or s.path.stem
names = {k: v for k, v in s.names.items() if k in files}
res = write_output(_root(), safe_name(target), files, names, s.entries)
tbl = f"BOQ{str(req.pcode).strip()}.DBF"
left = pathlib.Path(res["saved_dir"]) / s.names.get(tbl, tbl) # 같은 작업본 폴더에 남은 옛 표
if tbl not in files and left.exists():
left.unlink()
return {**res, "deleted": req.pcode, "changes": log}
# 원본 PCLASS × 등록 × 플래그 → 새 K_ORD("1" | "K+1") — 표에 없는 원본(설계 0 등)은 다음 차수(K+1)
K_ORD_RULE = {
(4, 6, None): "1",
(6, 6, None): "K+1",
(7, 6, None): "1",
(4, 7, None): "1",
(6, 7, None): "1",
(7, 7, 0): "K+1",
(7, 7, 1): "1",
}
def new_k_ord(pclass: int, op: int, flag: int, k: int) -> int:
rule = K_ORD_RULE.get((pclass, op, flag)) or K_ORD_RULE.get((pclass, op, None)) or "K+1"
return k + 1 if rule == "K+1" else 1
def _move_slot(tbl: DbfTable, src: str, dst: tuple[str, ...]) -> None:
pairs = []
for c in tbl.order:
m = SLOT_COL.match(c)
if m and m.group(2) == src:
pairs += [(c, f"{m.group(1)}{d}") for d in dst if f"{m.group(1)}{d}" in tbl.fields]
for i in range(len(tbl)):
for a, b in pairs:
tbl.set_raw(i, b, tbl.raw(i, a))
def stage_register(files: dict, src_pcode: str, op: int, flag: int, pname: str = ""):
"""(새 files, 새 PCODE, 새 K_ORD) — 단계 등록."""
prj = DbfTable(files["PRJLST.DBF"])
si = next(i for i in range(len(prj)) if prj.get(i, "PCODE") == str(src_pcode))
num = lambda f: int(float(prj.get(si, f) or 0)) if f in prj.fields else 0 # noqa: E731
k, pclass = num("K_ORD"), num("PCLASS")
nk = new_k_ord(pclass, int(op), int(flag), k)
out, pcode, _log = copy_register(
files, str(src_pcode), "change" if nk == k + 1 else "copy", pname
)
prj2 = DbfTable(out["PRJLST.DBF"])
ni = next(i for i in range(len(prj2)) if prj2.get(i, "PCODE") == pcode)
prj2.set(ni, "K_ORD", nk)
if "PCLASS" in prj2.fields:
prj2.set(ni, "PCLASS", int(op))
if nk == 1 and k + 1 != 1: # 원본 마지막 차수(k+1) 칸 → 새 공사 당초·변경(1·2) 칸
name = f"BOQ{pcode}.DBF"
bt = DbfTable(out[name])
_move_slot(bt, str(k + 1), ("1", "2"))
direct = recompute_boq(bt, name, {"1", "2"}, SaveLog())
for s, vals in direct.items():
for c, v in zip(("MCOST", "LCOST", "SCOST", "TCOST"), vals):
if f"{c}{s}" in prj2.fields:
prj2.set(ni, f"{c}{s}", v)
out[name] = bt.to_bytes()
out["PRJLST.DBF"] = prj2.to_bytes()
return out, pcode, nk
class StageRegReq(BaseModel):
source_path: str
src_pcode: str = "11"
op: int = 7 # 6 기성 · 7 설계변경
flag: int = 0 # 0 차수별 · 1 당초/변경
pname: str = ""
target_folder_name: str = ""
@router.post("/api/ui/stage-register")
def api_stage_register(req: StageRegReq):
try:
s = Source(req.source_path)
files, pcode, nk = stage_register(s.files, req.src_pcode, req.op, req.flag, req.pname)
except (FileNotFoundError, ValueError, StopIteration) as e:
raise HTTPException(400, str(e) or "공사 없음")
target = req.target_folder_name or f"{s.path.stem}_등록{pcode}"
names = dict(s.names, **{f"BOQ{pcode}.DBF": f"BOQ{pcode}.DBF"})
res = write_output(_root(), safe_name(target), files, names, s.entries)
return {**res, "new_pcode": pcode, "k_ord": nk}