"""B08 라이브러리 — STmate 출력 엑셀에서 **고정형 항목 뽑아 넣기** (PLAN 4장 · 2026-09-14 브레인 판정). 두 걸음: ① [읽기] 파일을 올리면 읽은 호표 수·구성 줄 수·못 읽은 사유를 돌려줌 — **아무것도 안 씀**. 사용자가 수를 보고 넣을지 정함(판정 「읽은 뒤 수를 보이고 묻는 걸음」). ② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀. ⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은 덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ). """ import asyncio import io from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import JSONResponse from common_util.common_util_auth import verify_session router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) BASE = "/{project_id}/quantity/structure-sheets/library/stmate" #: 출력 엑셀은 수백 KB — 넉넉히 두되 끝없이 받지 않음. MAX_BYTES = 20 * 1024 * 1024 def _error(status: int, message: str, **extra: Any) -> JSONResponse: return JSONResponse( status_code=status, content={"status": "error", "message": message, **extra} ) async def _read(file: UploadFile) -> dict[str, Any] | JSONResponse: from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes data = await file.read(MAX_BYTES + 1) if len(data) > MAX_BYTES: return _error(413, "파일이 너무 큼(20MB 넘음)") return await asyncio.to_thread(read_recipes, io.BytesIO(data)) @router.post(BASE + "/read") async def read_stmate_recipes( project_id: UUID, file: UploadFile = File(...), session: dict[str, Any] = Depends(verify_session), ) -> JSONResponse: """① 읽기만 — 호표 목록(차례·명칭·규격·단위·구성 줄 수)과 못 읽은 사유.""" read = await _read(file) if isinstance(read, JSONResponse): return read hopyo = [ { "no": h["no"], "name": h["name"], "spec": h["spec"], "unit": h["unit"], "rows": len(h["rows"]), "percent_rows": sum(1 for row in h["rows"] if row["unit"] == "%"), "contract_rows": h["contract_rows"], } for h in read["hopyo"] ] return JSONResponse( content={ "status": "success", "file_name": file.filename or "", "project": read["project"], "hopyo": hopyo, "counts": {"hopyo": len(hopyo), "rows": sum(h["rows"] for h in hopyo)}, "problems": read["problems"], } ) @router.post(BASE + "/save") async def save_stmate_recipe( project_id: UUID, file: UploadFile = File(...), hopyo_no: int = Form(...), type_id: str = Form(..., min_length=1, max_length=100), session: dict[str, Any] = Depends(verify_session), ) -> JSONResponse: """② 넣기 — 서버가 다시 읽은 호표 하나를 고정형 항목으로 **로그인한 사람 개인 단**에 씀.""" from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item from B08_Quantity.B08_Quantity_Engine_StructureLibrary import save_item_personal, tier_dirs folder = tier_dirs(session.get("company_id"), session.get("user_id")).get("personal") if folder is None: return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.") if type_id not in structure_type_map(): return _error(400, f"모르는 구조물 종류: {type_id}") read = await _read(file) if isinstance(read, JSONResponse): return read hopyo = next((h for h in read["hopyo"] if h["no"] == hopyo_no), None) if hopyo is None: return _error(404, f"제{hopyo_no}호표를 읽지 못함", problems=read["problems"]) item = recipe_item( hopyo, type_id=type_id, file_name=file.filename or "", project=read["project"] ) code = await asyncio.to_thread(save_item_personal, folder, item) return JSONResponse( content={ "status": "success", "code": code, "name": item["name"], "rows": len(item["rows"]), "note": item["note"], } )