feat(b08): 라이브러리 관리자 발행 — 프로그램 기본도 storage 작업본(git resources 는 씨앗 · 없는 코드만 · 덮어쓰기 금지) · [회사 라이브러리에 발행](마스터·시스템 관리자) · [프로그램 기본으로 발행](시스템 관리자) — [내 라이브러리에 저장]과 같은 모양 · 권한은 서버가 can_publish 로 내리고 화면은 그대로만 보임
화면(시스템 관리자 계정): 두 단추 보임 · 확인창 「모든 회사가 쓰는 프로그램 기본에 발행 — 같은 종류가 있으면 덮어씀」 · 공용 storage 라 취소로 멈춤(쓰기 없음 확인) · 쓰기는 시험(임시 storage)으로 잼 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -26,6 +26,25 @@ CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
|
|||||||
LIBRARY_SUBDIR = "library"
|
LIBRARY_SUBDIR = "library"
|
||||||
|
|
||||||
|
|
||||||
|
#: 프로그램 기본 작업본 자리 — 회사 번호 폴더와 안 겹치게 밑줄 이름.
|
||||||
|
PROGRAM_SUBDIR = "_program"
|
||||||
|
|
||||||
|
|
||||||
|
def program_library_dir() -> Path:
|
||||||
|
"""프로그램 기본 **작업본**(시스템 관리자가 화면에서 고친 것) — storage(회사·개인 단과 같은 결).
|
||||||
|
|
||||||
|
git `resources/library_structure`(`TEMPLATE_DIR`)는 **씨앗** — 쓰기 금지(데이터 3층의 초기값 자리).
|
||||||
|
"""
|
||||||
|
return Path(STORAGE_BASE_DIR).resolve() / PROGRAM_SUBDIR / LIBRARY_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
def program_items() -> list[dict[str, Any]]:
|
||||||
|
"""프로그램 기본 = 작업본 + 씨앗 중 **작업본에 없는 코드만**(관리자가 고친 것이 배포에 안 덮임)."""
|
||||||
|
stored = _items(program_library_dir())
|
||||||
|
codes = {item.get("code") for item in stored}
|
||||||
|
return [*stored, *(item for item in _items(TEMPLATE_DIR) if item.get("code") not in codes)]
|
||||||
|
|
||||||
|
|
||||||
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
||||||
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
||||||
dirs: dict[str, Path] = {}
|
dirs: dict[str, Path] = {}
|
||||||
@@ -34,7 +53,7 @@ def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
|||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
||||||
dirs["company"] = company / LIBRARY_SUBDIR
|
dirs["company"] = company / LIBRARY_SUBDIR
|
||||||
dirs["program"] = TEMPLATE_DIR
|
dirs["program"] = program_library_dir()
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +63,13 @@ def _items(folder: Path) -> list[dict[str, Any]]:
|
|||||||
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
|
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_items(dirs: dict[str, Path], tier: str) -> list[dict[str, Any]]:
|
||||||
|
"""단의 항목 — 프로그램 기본은 작업본 + 씨앗, 나머지는 그 폴더."""
|
||||||
|
if tier == "program":
|
||||||
|
return program_items()
|
||||||
|
return _items(dirs[tier]) if tier in dirs else []
|
||||||
|
|
||||||
|
|
||||||
def item_kind(item: dict[str, Any]) -> str:
|
def item_kind(item: dict[str, Any]) -> str:
|
||||||
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
|
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
|
||||||
저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨."""
|
저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨."""
|
||||||
@@ -66,16 +92,15 @@ def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
|
|||||||
}
|
}
|
||||||
for tier in TIERS
|
for tier in TIERS
|
||||||
if tier in dirs
|
if tier in dirs
|
||||||
for item in _items(dirs[tier])
|
for item in _tier_items(dirs, tier)
|
||||||
if item.get("type_id") == type_id and item.get("code")
|
if item.get("type_id") == type_id and item.get("code")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
|
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
|
||||||
folder = dirs.get(tier)
|
if tier not in dirs:
|
||||||
if folder is None:
|
|
||||||
return None
|
return None
|
||||||
return next((item for item in _items(folder) if item.get("code") == code), None)
|
return next((item for item in _tier_items(dirs, tier) if item.get("code") == code), None)
|
||||||
|
|
||||||
|
|
||||||
def project_library_dir(project_root: str | Path) -> Path:
|
def project_library_dir(project_root: str | Path) -> Path:
|
||||||
@@ -98,7 +123,7 @@ def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]
|
|||||||
|
|
||||||
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
|
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
|
||||||
"""
|
"""
|
||||||
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()]
|
return [*program_items(), *project_templates(project_root).values()]
|
||||||
|
|
||||||
|
|
||||||
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
|
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
|
||||||
@@ -127,9 +152,13 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
|
|||||||
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _personal_code(folder: Path, type_id: Any) -> str:
|
def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
|
||||||
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로."""
|
"""그 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
|
||||||
same = [item for item in _items(folder) if item.get("type_id") == type_id]
|
|
||||||
|
프로그램 기본은 씨앗까지 봄(`existing`) — 씨앗 코드로 덮어써야 작업본이 씨앗을 이김.
|
||||||
|
"""
|
||||||
|
items = _items(folder) if existing is None else existing
|
||||||
|
same = [item for item in items if item.get("type_id") == type_id]
|
||||||
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
|
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
|
||||||
|
|
||||||
|
|
||||||
@@ -145,16 +174,19 @@ def save_personal(
|
|||||||
template: dict[str, Any],
|
template: dict[str, Any],
|
||||||
overrides: dict[str, Any] | None,
|
overrides: dict[str, Any] | None,
|
||||||
unit_price_rows: list[dict[str, Any]] | None = None,
|
unit_price_rows: list[dict[str, Any]] | None = None,
|
||||||
|
tier: str = "personal",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드.
|
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
|
||||||
|
|
||||||
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
⚠ 반대 방향(작업본 → 라이브러리)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||||
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
⚠ 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||||
|
⚠ `tier` — 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
|
||||||
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
||||||
"""
|
"""
|
||||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
||||||
|
|
||||||
code = _personal_code(folder, template.get("type_id"))
|
existing = program_items() if tier == "program" else None
|
||||||
|
code = _personal_code(folder, template.get("type_id"), existing)
|
||||||
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
||||||
dropped = {"default_formula", "default_rounding"}
|
dropped = {"default_formula", "default_rounding"}
|
||||||
rows = [
|
rows = [
|
||||||
@@ -164,7 +196,7 @@ def save_personal(
|
|||||||
item = {k: v for k, v in template.items() if k != "imported_from"}
|
item = {k: v for k, v in template.items() if k != "imported_from"}
|
||||||
if unit_price_rows is not None:
|
if unit_price_rows is not None:
|
||||||
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
|
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
|
||||||
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
|
_write(folder, {**item, "code": code, "library_tier": tier, "rows": rows})
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
@@ -186,7 +218,7 @@ def pin_program_templates(project_root: str | Path) -> int:
|
|||||||
"""
|
"""
|
||||||
pinned = project_templates(project_root)
|
pinned = project_templates(project_root)
|
||||||
count = 0
|
count = 0
|
||||||
for item in _items(TEMPLATE_DIR):
|
for item in program_items():
|
||||||
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
|
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
|
||||||
import_item(project_root, item, "program")
|
import_item(project_root, item, "program")
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -29,11 +28,13 @@ OVERRIDES_KEY = "structure_formula_overrides"
|
|||||||
|
|
||||||
|
|
||||||
def load_template(type_id: str) -> dict[str, Any] | None:
|
def load_template(type_id: str) -> dict[str, Any] | None:
|
||||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
|
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬.
|
||||||
path = TEMPLATE_DIR / f"{type_id}.json"
|
|
||||||
if not path.is_file():
|
storage 작업본(시스템 관리자가 고친 것)이 먼저, 없으면 git 씨앗(`TEMPLATE_DIR`) — 2026-09-14 관리자 UI.
|
||||||
return None
|
"""
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_items
|
||||||
|
|
||||||
|
return next((item for item in program_items() if item.get("type_id") == type_id), None)
|
||||||
|
|
||||||
|
|
||||||
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
|
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ async def get_structure_library(
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"items": await asyncio.to_thread(list_items, dirs, type_id),
|
"items": await asyncio.to_thread(list_items, dirs, type_id),
|
||||||
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
|
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
|
||||||
|
"can_publish": {tier: _publish_dir(session, tier) is not None for tier in PUBLISH},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -394,15 +395,32 @@ def _no_personal() -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: 발행 단 — 회사는 마스터·시스템 관리자, 프로그램 기본은 시스템 관리자(2026-09-14 브레인 승인).
|
||||||
|
PUBLISH = ("company", "program")
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_dir(session: dict[str, Any], tier: str) -> Path | None:
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import program_library_dir, tier_dirs
|
||||||
|
|
||||||
|
admin = session.get("role") == "SYSTEM_ADMIN"
|
||||||
|
if tier == "program":
|
||||||
|
return program_library_dir() if admin else None
|
||||||
|
if tier == "company" and (admin or session.get("is_master")):
|
||||||
|
return tier_dirs(session.get("company_id"), None).get("company")
|
||||||
|
return _personal_dir(session) if tier == "personal" else None
|
||||||
|
|
||||||
|
|
||||||
class LibrarySaveRequest(BaseModel):
|
class LibrarySaveRequest(BaseModel):
|
||||||
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지."""
|
"""[내 라이브러리에 저장]·[발행] — 어느 장의 양식을 어느 단에 쓸지(같은 모양)."""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
sheet_key: str
|
sheet_key: str
|
||||||
|
tier: Literal["personal", "company", "program"] = "personal"
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
|
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
|
||||||
|
@router.put("/{project_id}/quantity/structure-sheets/library/publish")
|
||||||
async def put_structure_library_personal(
|
async def put_structure_library_personal(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: LibrarySaveRequest,
|
payload: LibrarySaveRequest,
|
||||||
@@ -420,9 +438,13 @@ async def put_structure_library_personal(
|
|||||||
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
|
||||||
from common_util.common_util_project_settings import quantity_settings
|
from common_util.common_util_project_settings import quantity_settings
|
||||||
|
|
||||||
folder = _personal_dir(session)
|
folder = _publish_dir(session, payload.tier)
|
||||||
if folder is None:
|
if folder is None:
|
||||||
return _no_personal()
|
if payload.tier == "personal":
|
||||||
|
return _no_personal()
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403, content={"status": "error", "message": "발행 권한이 없습니다."}
|
||||||
|
)
|
||||||
project_root = await _project_root(project_id)
|
project_root = await _project_root(project_id)
|
||||||
if project_root is None:
|
if project_root is None:
|
||||||
return _not_found()
|
return _not_found()
|
||||||
@@ -439,7 +461,7 @@ async def put_structure_library_personal(
|
|||||||
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
|
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
|
||||||
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
|
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
|
||||||
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
|
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
|
||||||
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows)
|
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, payload.tier)
|
||||||
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
|
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
load.disabled = true;
|
load.disabled = true;
|
||||||
status.textContent = "목록 받는 중…";
|
status.textContent = "목록 받는 중…";
|
||||||
try {
|
try {
|
||||||
const { items } = await readJson<{ items: LibraryItem[] }>(
|
const { items, can_publish: canPublish } = await readJson<{
|
||||||
|
items: LibraryItem[];
|
||||||
|
can_publish?: { company: boolean; program: boolean };
|
||||||
|
}>(
|
||||||
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
|
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
}),
|
}),
|
||||||
@@ -146,6 +149,9 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
);
|
);
|
||||||
list.hidden = take.hidden = clone.hidden = items.length === 0;
|
list.hidden = take.hidden = clone.hidden = items.length === 0;
|
||||||
syncClone();
|
syncClone();
|
||||||
|
// 발행 단추 — 서버가 준 권한대로만 보임(회사 = 마스터 · 기본 = 시스템 관리자).
|
||||||
|
toCompany.hidden = !canPublish?.company;
|
||||||
|
toProgram.hidden = !canPublish?.program;
|
||||||
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
status.textContent = items.length ? afterLoad : "가져올 항목이 없음";
|
||||||
afterLoad = "";
|
afterLoad = "";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -232,9 +238,32 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
|
|||||||
);
|
);
|
||||||
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
|
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
|
||||||
});
|
});
|
||||||
|
// 발행 — [내 라이브러리에 저장]과 같은 모양으로 회사·프로그램 기본 단에(2026-09-14 브레인 승인).
|
||||||
|
const publish = (label: string, tier: "company" | "program"): HTMLButtonElement => {
|
||||||
|
const button = personal(label, async () => {
|
||||||
|
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
|
||||||
|
const whom = tier === "program" ? "모든 회사가 쓰는 프로그램 기본" : "우리 회사 라이브러리";
|
||||||
|
if (!window.confirm(`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀`)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
await readJson(
|
||||||
|
await fetch(libraryUrl(projectId, "/publish"), {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sheet_key: sheetKey, tier }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return `${whom}에 발행함`;
|
||||||
|
});
|
||||||
|
button.hidden = true;
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
const toCompany = publish("회사 라이브러리에 발행", "company");
|
||||||
|
const toProgram = publish("프로그램 기본으로 발행", "program");
|
||||||
const mine = document.createElement("div");
|
const mine = document.createElement("div");
|
||||||
mine.className = "b08-sheet__actions";
|
mine.className = "b08-sheet__actions";
|
||||||
mine.append(save, remove);
|
mine.append(save, remove, toCompany, toProgram);
|
||||||
|
|
||||||
panel.append(title, scope, load, list, take, clone, mine, status);
|
panel.append(title, scope, load, list, take, clone, mine, status);
|
||||||
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
|
||||||
|
|||||||
@@ -200,6 +200,67 @@ def test_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_
|
|||||||
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount)
|
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount)
|
||||||
|
|
||||||
|
|
||||||
|
def test_프로그램_기본은_storage_작업본이_씨앗을_이긴다(storage: Path) -> None:
|
||||||
|
"""관리자 UI(2026-09-14 브레인) — 프로그램 기본도 storage 작업본 · git 파일은 씨앗(없는 코드만)."""
|
||||||
|
seed = load_template("masonry_wet")
|
||||||
|
assert library_module.program_items()[0]["code"] == seed["code"] # 작업본 없으면 씨앗
|
||||||
|
edited = {**seed, "name": "돌쌓기(찰) 관리자 고침"}
|
||||||
|
folder = library_module.program_library_dir()
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / f"{seed['code']}.json").write_text(json.dumps(edited), encoding="utf-8")
|
||||||
|
items = library_module.program_items()
|
||||||
|
assert [i["name"] for i in items if i["type_id"] == "masonry_wet"] == ["돌쌓기(찰) 관리자 고침"]
|
||||||
|
assert load_template("masonry_wet")["name"] == "돌쌓기(찰) 관리자 고침"
|
||||||
|
assert str(folder).startswith(str(storage)) # 씨앗(git) 파일은 안 건드림
|
||||||
|
|
||||||
|
|
||||||
|
def test_발행은_권한대로_회사_기본_단에_같은_모양으로(client: TestClient, storage: Path) -> None:
|
||||||
|
sheet = _sheet(client)
|
||||||
|
denied = client.put(
|
||||||
|
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
|
||||||
|
)
|
||||||
|
assert denied.status_code == 403 # 시스템 관리자만
|
||||||
|
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()[
|
||||||
|
"can_publish"
|
||||||
|
] == {"company": False, "program": False}
|
||||||
|
|
||||||
|
client.app.dependency_overrides[verify_session] = lambda: {
|
||||||
|
"company_id": 7,
|
||||||
|
"user_id": 42,
|
||||||
|
"is_master": True,
|
||||||
|
"role": "ADMIN",
|
||||||
|
}
|
||||||
|
company = client.put(
|
||||||
|
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "company"}
|
||||||
|
)
|
||||||
|
assert company.status_code == 200, company.text
|
||||||
|
saved = json.loads(
|
||||||
|
(storage / "7" / "library" / f"{company.json()['code']}.json").read_text("utf-8")
|
||||||
|
)
|
||||||
|
assert saved["library_tier"] == "company"
|
||||||
|
assert (
|
||||||
|
client.put(
|
||||||
|
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
client.app.dependency_overrides[verify_session] = lambda: {
|
||||||
|
"company_id": None,
|
||||||
|
"user_id": 1,
|
||||||
|
"role": "SYSTEM_ADMIN",
|
||||||
|
}
|
||||||
|
program = client.put(
|
||||||
|
f"{SHEETS}/library/publish", json={"sheet_key": sheet["key"], "tier": "program"}
|
||||||
|
)
|
||||||
|
assert program.status_code == 200, program.text
|
||||||
|
assert program.json()["code"] == load_template("masonry_wet")["code"] # 씨앗 코드로 덮어씀
|
||||||
|
assert (library_module.program_library_dir() / f"{program.json()['code']}.json").is_file()
|
||||||
|
assert client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["can_publish"][
|
||||||
|
"program"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
|
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
|
||||||
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
|
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
|
||||||
sheet = _sheet(client)
|
sheet = _sheet(client)
|
||||||
@@ -277,6 +338,9 @@ def test_복제_단추는_개인_단_항목에선_안_눌린다() -> None:
|
|||||||
)
|
)
|
||||||
assert "/clone" in ui and "복제해서 내 것으로" in ui
|
assert "/clone" in ui and "복제해서 내 것으로" in ui
|
||||||
assert 'clone.disabled = tier === "personal"' in ui
|
assert 'clone.disabled = tier === "personal"' in ui
|
||||||
|
# 발행 단추 — 서버 권한(can_publish)대로만 보임
|
||||||
|
assert "/publish" in ui and "toCompany.hidden = !canPublish?.company" in ui
|
||||||
|
assert "toProgram.hidden = !canPublish?.program" in ui
|
||||||
|
|
||||||
|
|
||||||
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
|
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user