"""B08 라이브러리 — STmate 출력 엑셀에서 **고정형 항목 뽑아 넣기** (PLAN 4장 · 2026-09-14 브레인 판정). 두 걸음: ① [읽기] 파일을 올리면 읽은 호표 수·구성 줄 수·못 읽은 사유를 돌려줌 — **아무것도 안 씀**. 사용자가 수를 보고 넣을지 정함(판정 「읽은 뒤 수를 보이고 묻는 걸음」). ② [넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 → 서버가 **파일을 다시 읽어** 개인 단에 씀. ⚠ 브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5장) · 개인 단만(판정 Ⓗ) · 종류당 하나라 같은 종류 내 것은 덮어씀(판정 Ⓐ) · 종류는 사용자가 고름 — 이름으로 자동으로 안 붙임(판정 Ⓒ). ③ [복제해서 내 것으로](PLAN 4장) — 기본·회사 단 항목을 개인 단에 베낌. 프로젝트 작업본은 안 바꿈. (구조물도 라우터가 700줄에 닿아 개인 단으로 넣는 창구를 이 파일에 모음.) """ import asyncio import io from typing import Any, Literal from uuid import UUID from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field 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"], } ) class LibraryCloneRequest(BaseModel): """베낄 항목 — 단과 코드(이름은 겹칠 수 있음). 개인 단 것은 이미 내 것이라 안 받음.""" model_config = ConfigDict(extra="forbid") type_id: str = Field(min_length=1, max_length=100) tier: Literal["company", "program"] code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$") @router.put("/{project_id}/quantity/structure-sheets/library/clone") async def clone_library_item( project_id: UUID, payload: LibraryCloneRequest, session: dict[str, Any] = Depends(verify_session), ) -> JSONResponse: """③ 기본·회사 항목을 **로그인한 사람 개인 단**에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]할 길을 한 번에.""" from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( find_item, save_item_personal, tier_dirs, ) dirs = tier_dirs(session.get("company_id"), session.get("user_id")) folder = dirs.get("personal") if folder is None: return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.") item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code) if item is None or item.get("type_id") != payload.type_id: return _error(404, "베낄 항목을 찾지 못했습니다.") body = {k: v for k, v in item.items() if k not in ("code", "imported_from", "library_tier")} body["cloned_from"] = {"tier": payload.tier, "code": payload.code, "name": item.get("name")} code = await asyncio.to_thread(save_item_personal, folder, body) return JSONResponse(content={"status": "success", "code": code, "name": item.get("name")})