Files
Aislo/B08_Quantity/B08_Quantity_Router_StmateLibrary.py
T
eomsangdonandClaude Opus 5 64e43463f4 feat(b08): 라이브러리 [복제해서 내 것으로] — 기본·회사 항목을 개인 단에 베낌(출처 cloned_from 기록 · 새 코드 · 프로젝트 작업본 안 바꿈 · 개인 단 항목에선 안 눌림 · 같은 종류 내 것은 덮어씀을 먼저 물음)
화면(936be972 돌쌓기(찰) 장): 기본 항목 복제 → 목록 「개인 · [양식형] 돌쌓기(찰)」 + 「내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]」 · 프로젝트 라이브러리 폴더 안 생김 · [내 것 지우기]로 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:45:46 +09:00

152 lines
6.4 KiB
Python

"""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")})