화면(936be972 돌쌓기(찰) 장): 기본 항목 복제 → 목록 「개인 · [양식형] 돌쌓기(찰)」 + 「내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]」 · 프로젝트 라이브러리 폴더 안 생김 · [내 것 지우기]로 되돌림 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
152 lines
6.4 KiB
Python
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")})
|