화면: 복제로 만든 개인 항목 선택 → 동료 목록 받음 → 확인창 「받는 쪽 목록에 받음으로 뜸 · 그 사람 것은 안 덮음」 · 공용 storage 라 취소(동료 폴더 안 생김 확인) · 개인 항목 지워 되돌림 · 쓰기는 임시 storage 시험 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
220 lines
9.3 KiB
Python
220 lines
9.3 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["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 ""})
|