3 Commits
Author SHA1 Message Date
eomsangdon 3ca1a45b2b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 18:37:03 +09:00
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
eomsangdonandClaude Opus 5 ecd25ae9ee fix(b08): 프로그램 기본 발행본엔 원문 공사명·파일명을 안 실음(브레인 ②) — 출처에 「원문에서 뽑음 — 공사명은 발행 시 가림」 · 원본(개인 단)·회사 단 발행은 그대로(③) · 발행 확인창에 「이 항목은 「○○」에서 뽑은 것 — 공사명은 빼고 발행됩니다」
화면(936be972 · 봉화 호표 가져온 장): 확인창 문구 확인 · 공용 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:21:24 +09:00
8 changed files with 311 additions and 8 deletions
@@ -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"
@@ -28,6 +29,9 @@ LIBRARY_SUBDIR = "library"
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
PROGRAM_SUBDIR = "_program"
#: 프로그램 기본 발행본에서 뺄 출처 칸 — 어느 공사인지(2026-09-14 브레인 판정 ②).
ORIGIN_MASKED_KEYS = ("project", "file")
ORIGIN_MASKED_NOTE = "원문에서 뽑음 — 공사명은 발행 시 가림"
def program_library_dir() -> Path:
@@ -52,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
@@ -194,6 +199,10 @@ def save_personal(
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
if tier == "program" and isinstance(item.get("origin"), dict):
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
item["origin"] = {**kept, "masked": ORIGIN_MASKED_NOTE}
if unit_price_rows is not None:
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
@@ -223,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"])
@@ -441,6 +441,8 @@ def apply_templates(
"name": template.get("name"),
"code": template.get("code"),
"imported_from": template.get("imported_from"),
# 발행 확인창이 「○○공사에서 뽑은 것 — 공사명은 빼고 발행」을 보이게(브레인 ②).
"origin_project": (template.get("origin") or {}).get("project"),
}
sheet["formula_sheet"] = body
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
@@ -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 ""})
@@ -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}$")
@@ -86,6 +86,8 @@ export interface StructureSheet extends StandardSheetSpec {
name: string;
code?: string | null;
imported_from?: string | null;
/** 뽑아 온 원문 공사명(STmate 고정형) — 프로그램 기본 발행 때 가림. */
origin_project?: string | null;
};
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
@@ -422,6 +424,7 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
sheetKey: sheet.key,
typeId: sheet.library_item.type_id,
currentCode: sheet.library_item.code ?? null,
originProject: sheet.library_item.origin_project,
isDirty: () => dirty,
confirmTake: () => {
const edited = editedRows(sheet);
@@ -10,7 +10,12 @@
import { API_BASE_URL } from "@config/config_frontend";
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
const TIER_LABELS: Record<string, string> = {
personal: "개인",
received: "받음",
company: "회사",
program: "기본",
};
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
@@ -52,6 +57,8 @@ export interface LibraryPanelOptions {
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
isDirty: () => boolean;
onImported: (notes: string[]) => Promise<void>;
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
originProject?: string | null;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -88,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("|");
@@ -147,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;
@@ -243,7 +303,15 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
const button = personal(label, async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
if (!window.confirm(`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀`)) {
const masked =
tier === "program" && options.originProject
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
: "";
if (
!window.confirm(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}`,
)
) {
return "";
}
await readJson(
@@ -265,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({
+108
View File
@@ -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
@@ -261,6 +261,41 @@ def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client:
]
def test_프로그램_기본_발행본엔_원문_공사명_파일명을_안_싣는다(tmp_path: Path) -> None:
"""2026-09-14 브레인 ② — 모든 회사로 가는 발행본에서만 가림 · 원본·회사 단은 그대로(③)."""
origin = {
"kind": "stmate_xlsx",
"file": "봉화.xlsx",
"project": "2024년 봉화 임도",
"hopyo_no": 6,
}
template = {**load_template("masonry_wet"), "origin": origin}
program = library_module.save_personal(tmp_path / "program", template, None, None, "program")
company = library_module.save_personal(tmp_path / "company", template, None, None, "company")
published = json.loads((tmp_path / "program" / f"{program}.json").read_text(encoding="utf-8"))
kept = json.loads((tmp_path / "company" / f"{company}.json").read_text(encoding="utf-8"))
assert "project" not in published["origin"] and "file" not in published["origin"]
assert published["origin"]["masked"] == "원문에서 뽑음 — 공사명은 발행 시 가림"
assert published["origin"]["kind"] == "stmate_xlsx"
assert kept["origin"] == origin
assert template["origin"] == origin # 원본은 안 건드림
def test_발행_확인창이_가릴_공사명을_보인다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text(
encoding="utf-8"
)
assert "originProject" in ui and "공사명은 빼고 발행됩니다" in ui
sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(
encoding="utf-8"
)
assert "originProject: sheet.library_item.origin_project" in sheet_ui
template_py = (ROOT / "B08_Quantity" / "B08_Quantity_Engine_StructureTemplate.py").read_text(
encoding="utf-8"
)
assert '"origin_project"' in template_py
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
sheet = _sheet(client)