Files
Aislo/B08_Quantity/B08_Quantity_Router_StmateLibrary.py
eomsangdonandClaude Opus 5 0bb14ce2b3 feat(b08): 라이브러리 공유 — [동료에게 보내기] 같은 회사 동료의 「받음」 단으로 복사(개인 단을 안 덮음 · 보낸 사람 기록 · 출처 공사명 그대로) · 받음 단 항목도 가져오기·복제 가능 · 회사 밖·나에게·내 것 아닌 항목은 막음(브레인 판정 ①)
화면: 복제로 만든 개인 항목 선택 → 동료 목록 받음 → 확인창 「받는 쪽 목록에 받음으로 뜸 · 그 사람 것은 안 덮음」 · 공용 storage 라 취소(동료 폴더 안 생김 확인) · 개인 항목 지워 되돌림 · 쓰기는 임시 storage 시험

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

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