"""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["received", "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")}) async def _company_members(company_id: int) -> list[dict[str, Any]]: """같은 회사 구성원 — B01 구성원 저장소를 읽기만(시험이 갈아 끼움).""" from B01_Dashboard.B01_Dashboard_Repository_Members import list_company_members return await list_company_members(company_id) @router.get("/{project_id}/quantity/structure-sheets/library/colleagues") async def list_library_colleagues( project_id: UUID, session: dict[str, Any] = Depends(verify_session) ) -> JSONResponse: """④ 보낼 동료 — 같은 회사에서 나를 뺀 사람(이름만).""" company_id = session.get("company_id") if company_id is None: return _error(403, "회사에 속한 사용자만 보냅니다.") members = await _company_members(int(company_id)) colleagues = [ {"id": m["id"], "name": m.get("name") or m.get("email") or ""} for m in members if m["id"] != session.get("user_id") ] return JSONResponse(content={"status": "success", "colleagues": colleagues}) class LibraryShareRequest(BaseModel): """내 개인 단 항목 하나를 같은 회사 동료에게.""" model_config = ConfigDict(extra="forbid") type_id: str = Field(min_length=1, max_length=100) code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$") to_user_id: int @router.put("/{project_id}/quantity/structure-sheets/library/share") async def share_library_item( project_id: UUID, payload: LibraryShareRequest, session: dict[str, Any] = Depends(verify_session), ) -> JSONResponse: """④ [동료에게 보내기] — 받는 쪽 「받음」 단에 복사(PLAN 4장 공유 · 브레인 판정 ①).""" from B08_Quantity.B08_Quantity_Engine_StructureLibrary import ( RECEIVED_SUBDIR, find_item, share_item, tier_dirs, ) company_id = session.get("company_id") dirs = tier_dirs(company_id, session.get("user_id")) if "personal" not in dirs: return _error(403, "회사에 속한 사용자만 보냅니다.") if payload.to_user_id == session.get("user_id"): return _error(400, "나에게는 보내지 않습니다.") members = {m["id"]: m for m in await _company_members(int(company_id))} target = members.get(payload.to_user_id) if target is None: return _error(404, "같은 회사에서 받는 사람을 찾지 못했습니다.") item = await asyncio.to_thread(find_item, dirs, "personal", payload.code) if item is None or item.get("type_id") != payload.type_id: return _error(404, "보낼 내 항목을 찾지 못했습니다.") me = members.get(session.get("user_id")) or {} sender = {"user_id": session.get("user_id"), "name": me.get("name") or me.get("email") or ""} to_folder = dirs["personal"].parents[1] / str(payload.to_user_id) / RECEIVED_SUBDIR code = await asyncio.to_thread(share_item, item, to_folder, sender) return JSONResponse(content={"status": "success", "code": code, "to": target.get("name") or ""})