diff --git a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py index 2e730dae..ff5f1979 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureLibrary.py @@ -19,8 +19,9 @@ from typing import Any from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR from config.config_system import STORAGE_BASE_DIR -#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). -TIERS = ("personal", "company", "program") +#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터). `received` = 동료가 보낸 것(공유 · 개인 단을 안 덮음). +TIERS = ("personal", "received", "company", "program") +RECEIVED_SUBDIR = "library_received" #: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이). CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$") LIBRARY_SUBDIR = "library" @@ -55,6 +56,7 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]: company = Path(STORAGE_BASE_DIR).resolve() / str(company_id) if user_id is not None: dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR + dirs["received"] = company / str(user_id) / RECEIVED_SUBDIR dirs["company"] = company / LIBRARY_SUBDIR dirs["program"] = program_library_dir() return dirs @@ -230,3 +232,13 @@ def pin_program_templates(project_root: str | Path) -> int: import_item(project_root, item, "program") count += 1 return count + + +def share_item(item: dict[str, Any], to_folder: Path, sender: dict[str, Any]) -> str: + """[동료에게 보내기] — 내 개인 단 항목을 동료의 **받음 단**에 복사(개인 단을 안 덮음 · 브레인 판정 ①). + + 코드는 그대로(다시 보내면 같은 자리를 새로 씀) · 출처 공사명도 그대로(같은 회사 · 판정 ③). + """ + body = {k: v for k, v in item.items() if k != "imported_from"} + _write(to_folder, {**body, "library_tier": "received", "received_from": sender}) + return str(body["code"]) diff --git a/B08_Quantity/B08_Quantity_Router_StmateLibrary.py b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py index 604fba01..6162be00 100644 --- a/B08_Quantity/B08_Quantity_Router_StmateLibrary.py +++ b/B08_Quantity/B08_Quantity_Router_StmateLibrary.py @@ -121,7 +121,7 @@ class LibraryCloneRequest(BaseModel): model_config = ConfigDict(extra="forbid") type_id: str = Field(min_length=1, max_length=100) - tier: Literal["company", "program"] + tier: Literal["received", "company", "program"] code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$") @@ -149,3 +149,71 @@ async def clone_library_item( 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 ""}) diff --git a/B08_Quantity/B08_Quantity_Router_StructureSheet.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py index f3128559..0a66b219 100644 --- a/B08_Quantity/B08_Quantity_Router_StructureSheet.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -327,7 +327,7 @@ class LibraryImportRequest(BaseModel): model_config = ConfigDict(extra="forbid") type_id: str = Field(min_length=1, max_length=100) - tier: Literal["personal", "company", "program"] + tier: Literal["personal", "received", "company", "program"] code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$") diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts index 8ef3da1e..470c86d5 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -10,7 +10,12 @@ import { API_BASE_URL } from "@config/config_frontend"; import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate"; -const TIER_LABELS: Record = { personal: "개인", company: "회사", program: "기본" }; +const TIER_LABELS: Record = { + personal: "개인", + received: "받음", + company: "회사", + program: "기본", +}; /** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */ const KIND_LABELS: Record = { form: "양식형", fixed: "고정형" }; @@ -90,10 +95,63 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { clone.hidden = true; /** 목록을 다시 받은 뒤 보일 한 줄 — 받는 동안 상태 줄이 지워져 복제 결과가 사라지지 않게. */ let afterLoad = ""; + // 공유 — 내 개인 단 항목을 같은 회사 동료의 「받음」 단으로(브레인 판정 ①). 동료는 누를 때 받음. + const send = document.createElement("button"); + send.type = "button"; + send.className = "b08-quantity__tab"; + send.textContent = "동료에게 보내기"; + send.hidden = true; + const who = document.createElement("select"); + who.className = "b08-spec__input"; + who.hidden = true; const syncClone = (): void => { const [tier] = list.value.split("|"); clone.disabled = tier === "personal"; + send.disabled = tier !== "personal"; }; + send.addEventListener("click", () => { + const [tier, code] = list.value.split("|"); + if (tier !== "personal" || !code) return; + void (async () => { + send.disabled = true; + try { + if (who.hidden) { + const { colleagues } = await readJson<{ colleagues: { id: number; name: string }[] }>( + await fetch(libraryUrl(projectId, "/colleagues"), { credentials: "include" }), + ); + who.replaceChildren(...colleagues.map((c) => new Option(c.name, String(c.id)))); + who.hidden = colleagues.length === 0; + status.textContent = colleagues.length + ? "받을 동료를 고르고 한 번 더 누를 것" + : "보낼 동료가 없음"; + return; + } + const label = list.selectedOptions[0]?.textContent ?? ""; + const name = who.selectedOptions[0]?.textContent ?? ""; + if ( + !window.confirm( + `「${label}」을 ${name}에게 보냄 — 받는 쪽 목록에 「받음」으로 뜸 · 그 사람 것은 안 덮음`, + ) + ) { + return; + } + await readJson( + await fetch(libraryUrl(projectId, "/share"), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type_id: typeId, code, to_user_id: Number(who.value) }), + }), + ); + who.hidden = true; + status.textContent = `${name}에게 보냄`; + } catch (error) { + status.textContent = error instanceof Error ? error.message : "보내지 못함"; + } finally { + syncClone(); + } + })(); + }); list.addEventListener("change", syncClone); clone.addEventListener("click", () => { const [tier, code] = list.value.split("|"); @@ -149,7 +207,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { return option; }), ); - list.hidden = take.hidden = clone.hidden = items.length === 0; + list.hidden = take.hidden = clone.hidden = send.hidden = items.length === 0; syncClone(); // 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자). toCompany.hidden = !canPublish?.company; @@ -275,7 +333,7 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { mine.className = "b08-sheet__actions"; mine.append(save, remove, toCompany, toProgram); - panel.append(title, scope, load, list, take, clone, mine, status); + panel.append(title, scope, load, list, take, clone, send, who, mine, status); // 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장). panel.append( buildStmatePanel({ diff --git a/resources/tester/test_b08_library_share.py b/resources/tester/test_b08_library_share.py new file mode 100644 index 00000000..47feb08c --- /dev/null +++ b/resources/tester/test_b08_library_share.py @@ -0,0 +1,108 @@ +"""라이브러리 공유 — 같은 회사 동료에게 내 항목을 복사해 보냄 (PLAN 4장 · 2026-09-14 브레인 판정 ①). + +⚠ 받는 쪽이 모르게 들어가지 않음 — 받은 항목은 그 사람 개인 단을 덮지 않고 **「받음」 단**에 따로 섬 + (개인 단은 종류당 하나라 그대로 넣으면 동료가 고친 내 것이 조용히 덮임). 받은 뒤 가져오기·복제는 받는 사람 몫. +⚠ 같은 회사 안만 · 출처 공사명은 그대로(같은 회사라 그 공사를 앎 · 판정 ③). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402 +import B08_Quantity.B08_Quantity_Router_StmateLibrary as router_module # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402 +from common_util.common_util_auth import verify_session # noqa: E402 + +LIB = "/api/projects/33333333-3333-3333-3333-333333333333/quantity/structure-sheets/library" +MINE = "AX-ST-0000aaaa" + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + item = { + **load_template("masonry_wet"), + "code": MINE, + "library_tier": "personal", + "name": "내 돌쌓기", + "origin": {"project": "봉화 임도"}, + } + folder = tmp_path / "storage" / "7" / "42" / "library" + folder.mkdir(parents=True) + (folder / f"{MINE}.json").write_text(json.dumps(item), encoding="utf-8") + # 받는 사람(43)도 같은 종류 내 것이 있음 — 덮이면 안 됨 + theirs = tmp_path / "storage" / "7" / "43" / "library" + theirs.mkdir(parents=True) + (theirs / "AX-ST-0000bbbb.json").write_text( + json.dumps({**item, "code": "AX-ST-0000bbbb", "name": "동료 것"}), encoding="utf-8" + ) + + async def members(company_id: int) -> list[dict]: + assert company_id == 7 + return [ + {"id": 42, "name": "나", "email": "me@x"}, + {"id": 43, "name": "동료", "email": "you@x"}, + ] + + monkeypatch.setattr(router_module, "_company_members", members) + app = FastAPI() + app.include_router(router_module.router) + app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42} + return TestClient(app) + + +def test_동료_목록은_같은_회사에서_나를_뺀다(client: TestClient) -> None: + body = client.get(f"{LIB}/colleagues").json() + assert body["colleagues"] == [{"id": 43, "name": "동료"}] + + +def test_보내면_받는_쪽_받음_단에_서고_그_사람_개인_단은_안_덮인다( + client: TestClient, tmp_path: Path +) -> None: + sent = client.put( + f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 43} + ) + assert sent.status_code == 200, sent.text + received = tmp_path / "storage" / "7" / "43" / "library_received" / f"{MINE}.json" + saved = json.loads(received.read_text(encoding="utf-8")) + assert saved["library_tier"] == "received" + assert saved["received_from"]["name"] == "나" and saved["origin"]["project"] == "봉화 임도" + theirs = tmp_path / "storage" / "7" / "43" / "library" / "AX-ST-0000bbbb.json" + assert json.loads(theirs.read_text(encoding="utf-8"))["name"] == "동료 것" + dirs = library_module.tier_dirs(7, 43) + listed = library_module.list_items(dirs, "masonry_wet") + assert [i["tier"] for i in listed][:2] == ["personal", "received"] + + +def test_회사_밖이나_내_것_아닌_항목은_못_보낸다(client: TestClient) -> None: + stranger = client.put( + f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 99} + ) + assert stranger.status_code == 404 + missing = client.put( + f"{LIB}/share", json={"type_id": "masonry_wet", "code": "AX-ST-0000cccc", "to_user_id": 43} + ) + assert missing.status_code == 404 + myself = client.put( + f"{LIB}/share", json={"type_id": "masonry_wet", "code": MINE, "to_user_id": 42} + ) + assert myself.status_code == 400 + + +def test_화면에_보내기_단추와_받음_표시() -> None: + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( + encoding="utf-8" + ) + assert "/share" in ui and "/colleagues" in ui and "동료에게 보내기" in ui + assert 'received: "받음"' in ui