Files
Aislo/tools/stmate-test/api_conv.py
T
eomsangdonandClaude Opus 5.5 a4c0bc14a6 refactor(stmate-test): 원본에 없는 기능 걷음 — 제비율표 JB 대조 보기 · 보고서 CSV · 계약 저장 공사 대조 · 옛 화면 9개(표준카탈로그·레시피·REA 아키텍처 창·가짜 JDL/XML)
- 원본 대조 = 설치본 실행파일 52개 화면 이름·버튼 글·한글 문자열
- 지움: conv_jb.py · excel_builder.py · view_dan_ilwi.js · view_kanjub_prt.js · /api/conv/jb-* · /api/report/csv · /api/stage/projects · /api/catalog · /api/export-xlsx
- 낡은 시험 삭제(지운 기능 전용): conv_change 제비율표 3 · stage_api 공사별 설정 2 · report_sheets CSV 2줄
- 장부 F4-15 · F1-33 · F6-24 설명 고침 · stmate 시험 786 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:24:22 +09:00

278 lines
8.8 KiB
Python

# -*- coding: utf-8 -*-
"""변환·가져오기 끼워 넣기 API — 조달청 XML 가져오기·변환 설정 · 엑셀 내역/역등록 · 기준 BOQ·자재·코드 엑셀 왕복 ·
분리형 변경 STC.
근거: 28번 §3(ST_U_Convert) · 30번(호환규정 XSD) · 24번 §3(ST_P_XLS CONV_XLS_2_ST · XLS_2_BDREC · MK_BSCBOQ_EXL) ·
16번 §4.6(차수 인계) · 10번(ST_U_ChgCode)
쓰기는 tools/stmate-test/work_dbf/ 아래만(server.WORK_DBF_ROOT) · 엑셀·XML 원본은 읽기만.
파일 받기: 경로(서버 PC) 또는 b64(화면에서 고른 파일).
"""
from __future__ import annotations
import base64
import pathlib
from conv_jaje import build_jaje_xlsx, caps_from_ini
from conv_win8 import convert as convert_win8
from conv_win8_ini import detect as detect_win8
from conv_xls_apply import import_boq, register
from conv_xls_out import (
export_boq_xlsx,
export_code_xlsx,
import_code_xlsx,
)
from conv_xml import xml_to_project
from conv_xml_cfg import apply_settings, options, validate
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from stc_change import split_change
from stc_codechg import change_codes
from stc_project import safe_name, write_output
from stc_read import Source
router = APIRouter()
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def _root() -> pathlib.Path:
import server
return server.WORK_DBF_ROOT
def _src(path: str) -> Source:
try:
return Source(path)
except FileNotFoundError:
raise HTTPException(404, f"경로 없음: {path}")
except ValueError as e:
raise HTTPException(400, str(e))
def _blob(path: str = "", b64: str = "", text: str = ""):
"""경로 · base64 · 글 중 하나 → bytes/str."""
if text:
return text
if b64:
return base64.b64decode(b64.split(",", 1)[-1])
p = pathlib.Path(path or "")
if not path or not p.is_file():
raise HTTPException(404, f"파일 없음: {path}")
return p.read_bytes()
def _guard(fn, *a, **k):
try:
return fn(*a, **k)
except (ValueError, KeyError) as e:
raise HTTPException(400, str(e))
def _xlsx(data: bytes, name: str) -> Response:
from urllib.parse import quote
disp = f"attachment; filename*=UTF-8''{quote(name)}"
return Response(content=data, media_type=XLSX, headers={"Content-Disposition": disp})
class FileIn(BaseModel):
path: str = ""
b64: str = ""
text: str = ""
class XmlImportReq(BaseModel):
template_path: str
xml: FileIn
pname: str = ""
target_folder_name: str = ""
class XmlCfgReq(BaseModel):
xml_text: str
settings: dict = {}
class XlsBoqReq(BaseModel):
source_path: str
xlsx: FileIn
table: str = ""
kind: str = "내역서"
target_folder_name: str = ""
class XlsRegReq(BaseModel):
template_path: str
xlsx: FileIn
pname: str = ""
target_folder_name: str = ""
class SrcReq(BaseModel):
source_path: str
table: str = ""
class CodeChgReq(BaseModel):
source_path: str
mapping: dict[str, str] = {}
xlsx: FileIn | None = None
target_folder_name: str = ""
class SplitReq(BaseModel):
source_path: str
pcode: str = ""
target_folder_name: str = ""
class JajeSumReq(BaseModel):
source_path: str = ""
head: dict = {}
rows: list[dict] = []
class Win8Req(BaseModel):
old_path: str = "" # 서버 PC 의 구버전 폴더
files: dict[str, str] = {} # 또는 화면에서 고른 파일들(이름 → base64)
template_path: str = ""
pname: str = ""
target_folder_name: str = ""
def _old_files(req: Win8Req) -> dict:
if req.files:
return {
k.rsplit("/", 1)[-1].upper(): base64.b64decode(v.split(",", 1)[-1])
for k, v in req.files.items()
}
p = pathlib.Path(req.old_path or "")
if not req.old_path or not p.is_dir():
raise HTTPException(404, f"폴더 없음: {req.old_path}")
return {f.name.upper(): f.read_bytes() for f in p.iterdir() if f.is_file()}
def _save(target: str, files: dict, s: Source, extra: dict) -> dict:
res = write_output(_root(), safe_name(target), files, s.names, s.entries)
return {**res, **extra}
# ── 재료비 수량집계표 → 엑셀(wM_JajeXLS · 원본은 내보내기만) ──
@router.post("/api/conv/jaje-sum-xlsx")
def api_jaje_sum_xlsx(req: JajeSumReq):
"""화면이 집계한 M 코드 줄(수량·차수 단가) → 「주요건설자재현황」 시트 · 머리글 = Start8x.Ini [PRINT] CAP_*."""
caps = None
if req.source_path:
from stc_read import extra_tables
caps = caps_from_ini(extra_tables(_src(req.source_path).files)["start_ini_sections"])
name = f"{safe_name(str(req.head.get('PNAME') or '공사'))[:40]}_주요건설자재현황.xlsx"
return _xlsx(_guard(build_jaje_xlsx, req.head, req.rows, caps), name)
# ── 구버전 → Win8 (ST_ConvToWin8) ──
@router.post("/api/conv/win8-check")
def api_win8_check(req: Win8Req):
"""판 가리기(Chk_STDir) — 111 DOS · 120~129 7.x · 130~149 Win8 · 200 조달청 TEXT."""
v = detect_win8(_old_files(req))
kind = (
"DOS"
if 110 <= v <= 119
else "7.x"
if 120 <= v <= 129
else "Win8"
if 130 <= v <= 149
else ""
)
return {"version": v, "kind": kind or ("조달청 TEXT" if v == 200 else "구버전 자료 아님")}
@router.post("/api/conv/win8")
def api_win8(req: Win8Req):
"""구버전 폴더 → Win8 새 공사(틀 = template_path) — work_dbf/<이름>/ 와 STC."""
t = _src(req.template_path)
files, rep = _guard(convert_win8, _old_files(req), t.files, req.pname)
return _save(req.target_folder_name or f"{rep['pname']}_Win8변환", files, t, {"report": rep})
# ── 조달청 XML ──
@router.post("/api/conv/xml-import")
def api_xml_import(req: XmlImportReq):
"""XML → 새 공사(틀 = template_path 의 요율·서식·설정) — work_dbf/<이름>/ 와 STC."""
t = _src(req.template_path)
files, rep = _guard(xml_to_project, t.files, _blob(**req.xml.model_dump()), req.pname)
return _save(
req.target_folder_name or rep["info"]["pname"] or "XML가져옴", files, t, {"report": rep}
)
@router.get("/api/conv/xml-options")
def api_xml_options():
return options()
@router.post("/api/conv/xml-settings")
def api_xml_settings(req: XmlCfgReq):
"""내보낸 XML 에 공사유형·건축 사업내용 반영 + XSD 검증."""
xml = _guard(apply_settings, req.xml_text, req.settings)
return {"xml": xml, "validation": validate(xml)}
# ── 엑셀 → 내역 · 역등록 ──
@router.post("/api/conv/xls-boq")
def api_xls_boq(req: XlsBoqReq):
"""STMATE 엑셀 내역서 → 지금 공사의 BOQ 표를 바꿔 작업본으로 씀(wM_From_XLS)."""
s = _src(req.source_path)
files, rep = _guard(import_boq, s.files, _blob(**req.xlsx.model_dump()), req.table, req.kind)
return _save(req.target_folder_name or f"{s.path.stem}_엑셀내역", files, s, {"report": rep})
@router.post("/api/conv/xls-register")
def api_xls_register(req: XlsRegReq):
"""STMATE 엑셀 한 권 → 새 공사(CONV_XLS_2_ST · XLS_2_BDREC)."""
t = _src(req.template_path)
files, rep = _guard(register, t.files, _blob(**req.xlsx.model_dump()), req.pname)
return _save(req.target_folder_name or rep["pname"], files, t, {"report": rep})
# ── 엑셀 내보내기 짝 ──
@router.post("/api/conv/boq-xlsx")
def api_boq_xlsx(req: SrcReq):
s = _src(req.source_path)
return _xlsx(_guard(export_boq_xlsx, s.files, req.table), f"{s.path.stem}_기준BOQ.xlsx")
@router.post("/api/conv/code-xlsx")
def api_code_xlsx(req: SrcReq):
s = _src(req.source_path)
return _xlsx(_guard(export_code_xlsx, s.files), f"{s.path.stem}_코드변경표.xlsx")
@router.post("/api/conv/code-change")
def api_code_change(req: CodeChgReq):
"""코드 일괄 변경 — mapping 또는 코드 변경표 엑셀(새 코드 칸)."""
s = _src(req.source_path)
mapping = dict(req.mapping)
if req.xlsx and (req.xlsx.path or req.xlsx.b64):
mapping.update(_guard(import_code_xlsx, _blob(**req.xlsx.model_dump())))
files, log = _guard(change_codes, s.files, mapping)
return _save(
req.target_folder_name or f"{s.path.stem}_코드변경",
files,
s,
{"mapping": mapping, "change_count": len(log)},
)
# ── 분리형 변경 STC ──
@router.post("/api/conv/split-change")
def api_split_change(req: SplitReq):
"""앞 파일의 현재안 → 다음 변경 차수 STC(BOQ11 차수1 = 현재안 · 차수2 = 출발점)."""
s = _src(req.source_path)
files, rep = _guard(split_change, s.files, req.pcode)
return _save(
req.target_folder_name or f"{s.path.stem}_변경{rep['k_ord']}차", files, s, {"report": rep}
)