화면(시스템 관리자 계정): 두 단추 보임 · 확인창 「모든 회사가 쓰는 프로그램 기본에 발행 — 같은 종류가 있으면 덮어씀」 · 공용 storage 라 취소로 멈춤(쓰기 없음 확인) · 쓰기는 시험(임시 storage)으로 잼 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
226 lines
9.9 KiB
Python
226 lines
9.9 KiB
Python
"""구조물도 양식 **라이브러리** — 3단(개인·회사·프로그램) 목록과 프로젝트로 **가져오기** (PLAN 4장).
|
|
|
|
⛔ 표를 그릴 때 라이브러리를 읽지 않음 — 가져온 양식은 **프로젝트 작업본에 박히고**
|
|
(`{프로젝트}/B08_Quantity/library/<code>.json`) 표는 그것만 읽음. 3단은 가져오기 고르개에서만 돎.
|
|
여는 사람마다 값이 갈리지 않게 하려는 것(브레인 판정 Ⓑ — 개인 = 로그인한 사람).
|
|
⚠ 아직 아무것도 안 가져온 종류는 프로그램 기본을 읽음 — 보는 사람과 무관한 한 벌.
|
|
⚠ 저장은 **목록**(항목마다 파일 한 개 · 코드 `AX-ST-<8자리 16진>`, 명세 2장 ②) —
|
|
지금은 종류당 하나만 씀(판정 Ⓐ). 고르기 칸은 뒤에 붙임.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import secrets
|
|
from pathlib import Path
|
|
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")
|
|
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
|
|
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
|
|
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]:
|
|
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
|
dirs: dict[str, Path] = {}
|
|
if company_id is not None:
|
|
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
|
|
if user_id is not None:
|
|
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
|
dirs["company"] = company / LIBRARY_SUBDIR
|
|
dirs["program"] = program_library_dir()
|
|
return dirs
|
|
|
|
|
|
def _items(folder: Path) -> list[dict[str, Any]]:
|
|
if not folder.is_dir():
|
|
return []
|
|
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:
|
|
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
|
|
저장된 `item_kind` 표시는 안 믿음 — 줄과 어긋나면 배지가 거짓이 됨."""
|
|
return (
|
|
"form"
|
|
if any(str(row.get("formula") or "").strip() for row in item.get("rows") or [])
|
|
else "fixed"
|
|
)
|
|
|
|
|
|
def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
|
|
"""가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름·종류)."""
|
|
return [
|
|
{
|
|
"tier": tier,
|
|
"code": item.get("code"),
|
|
"name": item.get("name"),
|
|
"type_id": type_id,
|
|
"kind": item_kind(item),
|
|
}
|
|
for tier in TIERS
|
|
if tier in dirs
|
|
for item in _tier_items(dirs, tier)
|
|
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:
|
|
if tier not in dirs:
|
|
return 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:
|
|
return Path(project_root) / "B08_Quantity" / LIBRARY_SUBDIR
|
|
|
|
|
|
def project_templates(project_root: str | Path | None) -> dict[str, dict[str, Any]]:
|
|
"""프로젝트에 박힌 양식 — 종류(type_id)별 하나. **표를 그릴 때 읽는 유일한 양식 자리.**"""
|
|
if not project_root:
|
|
return {}
|
|
return {
|
|
str(item["type_id"]): item
|
|
for item in _items(project_library_dir(project_root))
|
|
if item.get("type_id")
|
|
}
|
|
|
|
|
|
def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]:
|
|
"""하위 일위대가(`B-AX-ST-*`)를 찾을 양식들 — 프로그램 기본 뒤에 **프로젝트에 박힌 것**(이김).
|
|
|
|
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
|
|
"""
|
|
return [*program_items(), *project_templates(project_root).values()]
|
|
|
|
|
|
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
|
|
"""고른 항목을 프로젝트 작업본에 박음 — 같은 종류의 옛 것은 지움(종류당 하나).
|
|
|
|
⚠ 새 것을 먼저 쓰고 옛 것을 지움 — 쓰다 실패하면 옛 양식이 남아 조용히 기본으로 안 떨어짐.
|
|
"""
|
|
code = str(item.get("code") or "")
|
|
if not CODE_PATTERN.match(code):
|
|
raise ValueError(f"항목 코드 모양이 아님: {code!r}")
|
|
folder = project_library_dir(project_root)
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
body = {**item, "imported_from": tier}
|
|
target = folder / f"{code}.json"
|
|
target.write_text(json.dumps(body, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
for path in folder.glob("*.json"):
|
|
if path == target:
|
|
continue
|
|
if json.loads(path.read_text(encoding="utf-8")).get("type_id") == item.get("type_id"):
|
|
path.unlink()
|
|
|
|
|
|
def _write(folder: Path, item: dict[str, Any]) -> None:
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
text = json.dumps(item, ensure_ascii=False, indent=2)
|
|
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
|
|
|
|
|
def _personal_code(folder: Path, type_id: Any, existing: list[dict[str, Any]] | None = None) -> str:
|
|
"""그 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로.
|
|
|
|
프로그램 기본은 씨앗까지 봄(`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)}"
|
|
|
|
|
|
def save_item_personal(folder: Path, item: dict[str, Any]) -> str:
|
|
"""이미 만든 항목(STmate 에서 뽑은 고정형 등)을 개인 단에 씀. 코드."""
|
|
code = _personal_code(folder, item.get("type_id"))
|
|
_write(folder, {**item, "code": code, "library_tier": "personal"})
|
|
return code
|
|
|
|
|
|
def save_personal(
|
|
folder: Path,
|
|
template: dict[str, Any],
|
|
overrides: dict[str, Any] | None,
|
|
unit_price_rows: list[dict[str, Any]] | None = None,
|
|
tier: str = "personal",
|
|
) -> str:
|
|
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
|
|
|
|
⚠ 반대 방향(작업본 → 라이브러리)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
|
⚠ 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
|
⚠ `tier` — 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
|
|
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
|
|
"""
|
|
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
|
|
|
existing = program_items() if tier == "program" else None
|
|
code = _personal_code(folder, template.get("type_id"), existing)
|
|
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
|
dropped = {"default_formula", "default_rounding"}
|
|
rows = [
|
|
{**{k: v for k, v in row.items() if k not in dropped}, "source": "library"}
|
|
for row in overridden_rows(template, overrides)
|
|
]
|
|
item = {k: v for k, v in template.items() if k != "imported_from"}
|
|
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})
|
|
return code
|
|
|
|
|
|
def delete_personal(folder: Path, type_id: str) -> int:
|
|
"""[내 것 지우기] — 개인 단의 그 종류만 지움. 프로젝트 작업본은 안 바꿈. 지운 수."""
|
|
count = 0
|
|
for path in folder.glob("*.json") if folder.is_dir() else []:
|
|
if json.loads(path.read_text(encoding="utf-8")).get("type_id") == type_id:
|
|
path.unlink()
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def pin_program_templates(project_root: str | Path) -> int:
|
|
"""[확정] 때 — 아직 안 박힌 종류는 **그 시점 프로그램 기본**을 박음. 박은 수.
|
|
|
|
확정은 정본을 굳히는 자리라, 뒤에 프로그램 기본을 고쳐도 확정한 값이 안 흔들려야 함
|
|
(브레인 판정). 확정 전(GET 만 한 프로젝트)은 기본을 그대로 읽음 — 정본이 아님.
|
|
"""
|
|
pinned = project_templates(project_root)
|
|
count = 0
|
|
for item in program_items():
|
|
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
|
|
import_item(project_root, item, "program")
|
|
count += 1
|
|
return count
|