- Labor_Api.ts · Router_Combos.py = 나무 길(sub5) · 미리 보기 길(sub2) 둘 다 지운 꼴 · 시스템 관리자 문 _door(sub4) 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""M02 일위대가 조합 층 저장소(PLAN 27-2a) — 마스터 · 회사 · 개인 세 층 한 길.
|
|
|
|
층 자리 · 키
|
|
마스터 `resources/master_data/일위대가조합.json` · 키 UA(`_키대장.json`)
|
|
쓰기는 M01 조합 저장소(`M01_MasterData_Store_Combo` 의 검사 · 대장 그대로)
|
|
회사 `storage/{회사}/templates/일위대가조합.json` · 키 `UC{회사}-######`
|
|
개인 `storage/{회사}/{사용자}/templates/일위대가조합.json` · 키 `UP{사용자}-######`
|
|
회사 · 개인 파일 = 마스터와 같은 머리 · 열 한 벌 + 머리 `다음`(키 장부 · 지운 키는 다시 안 씀).
|
|
지금 층 = `store.at(층 폴더)`(`store.root()`) — 키 머리도 그 자리에서 셈 · 권한은 라우터 몫.
|
|
목록 `폴더` = [구분] · [구분, 상세구분](읽기만 · 나무 고침 · 붙여넣기 길은 39-2 에서 지움).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from common_util.common_util_json import atomic_write_json
|
|
from M01_MasterData import M01_MasterData_Store as m01
|
|
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
|
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
|
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
|
from M02_MasterTemplete import M02_Template_Layers as layers
|
|
|
|
mcb = combo_store.mcb
|
|
FILE = combo_store.FILE
|
|
OWNERS = combo_store.OWNERS
|
|
MASTER = mcb.TID # UA
|
|
_HEAD = re.compile(r"^U([CP])(\d+)-")
|
|
DEPTH = 2 # 구분 · 상세구분
|
|
|
|
|
|
def _m01(fn, *args):
|
|
"""M01 막힘은 M02 막힘으로 — 상태 · 까닭 그대로."""
|
|
try:
|
|
return fn(*args)
|
|
except m01.StoreError as e:
|
|
raise store.StoreError(e.status, e.detail) from e
|
|
|
|
|
|
# ── 자리 ──────────────────────────────────────────────────────────────
|
|
def heads() -> tuple[str, ...]:
|
|
"""지금 층이 쓸 수 있는 키 머리 — 마지막이 제 층 것(마스터 UA · 회사 UC · 개인 UP)."""
|
|
root = store.root().resolve()
|
|
if root == Path(store.FOLDER).resolve():
|
|
return (MASTER,)
|
|
try:
|
|
parts = root.relative_to(layers.storage_root()).parts
|
|
except ValueError:
|
|
parts = ()
|
|
if len(parts) == 2:
|
|
return (MASTER, f"UC{parts[0]}-")
|
|
if len(parts) == 3:
|
|
return (MASTER, f"UC{parts[0]}-", f"UP{parts[1]}-")
|
|
raise store.StoreError(400, "일위대가 조합 층 자리를 모름")
|
|
|
|
|
|
def _file(head: str) -> Path | None:
|
|
"""키 머리의 조합 파일 — 개인은 사용자 id 로 찾음(회사 폴더를 모름)."""
|
|
if head.startswith(MASTER):
|
|
return Path(m01.FOLDER) / FILE
|
|
got = _HEAD.match(head)
|
|
if not got:
|
|
return None
|
|
if got[1] == "C":
|
|
return layers.company_dir(got[2]) / FILE
|
|
pattern = f"*/{got[2]}/{layers.TEMPLATES_DIRNAME}/{FILE}"
|
|
return next(layers.storage_root().glob(pattern), None)
|
|
|
|
|
|
def _rows_at(path: Path | None) -> list[dict]:
|
|
if path is None or not path.is_file():
|
|
return []
|
|
return json.loads(path.read_bytes()).get("줄") or []
|
|
|
|
|
|
def find(key: str) -> dict[str, Any] | None:
|
|
"""어느 층 조합이든 키로 한 줄 — 없으면 None."""
|
|
key = str(key or "")
|
|
return next((r for r in _rows_at(_file(key)) if str(r.get("키")) == key), None)
|
|
|
|
|
|
def where(key: str) -> tuple[str, str | None] | None:
|
|
"""키의 층 · 주인 — ("system", None) · ("company", "7") · ("personal", "7/42") · 모르면 None."""
|
|
key = str(key or "")
|
|
got, path = _HEAD.match(key), _file(key)
|
|
if key.startswith(MASTER) or path is None or not got:
|
|
return ("system", None) if key.startswith(MASTER) else None
|
|
if got[1] == "C":
|
|
return ("company", got[2])
|
|
return ("personal", f"{path.parent.parent.parent.name}/{got[2]}") # {회사}/{사용자}/templates
|
|
|
|
|
|
def names() -> dict[str, str]:
|
|
"""지금 층이 쓸 수 있는 조합 키 → 이름(마스터 · 회사 · 개인)."""
|
|
out: dict[str, str] = {}
|
|
for head in heads():
|
|
for r in _rows_at(_file(head)):
|
|
out[str(r.get("키"))] = str(r.get("이름") or "")
|
|
return out
|
|
|
|
|
|
def _load() -> tuple[dict[str, Any], str]:
|
|
"""(지금 층 조합 표, 판) — 회사 · 개인 파일이 없으면 빈 표 · 판 빈 글."""
|
|
if heads()[-1] == MASTER:
|
|
return _m01(combo_store._read)
|
|
path = store.root() / FILE
|
|
if not path.is_file():
|
|
head = {"그룹": mcb.GROUP, "판": str(date.today().year), "폴더": [], "다음": 1}
|
|
return {**head, "줄": []}, ""
|
|
raw = path.read_bytes()
|
|
return json.loads(raw), store.version_of(raw)
|
|
|
|
|
|
def rows() -> list[dict[str, Any]]:
|
|
return _load()[0].get("줄") or []
|
|
|
|
|
|
def _find(doc: dict, key: str) -> dict[str, Any]:
|
|
row = next((r for r in doc.get("줄") or [] if str(r.get("키")) == key), None)
|
|
if row is None:
|
|
raise store.StoreError(404, f"없는 조합 「{key}」")
|
|
return row
|
|
|
|
|
|
# ── 폴더(읽기만) ───────────────────────────────────────────────────────
|
|
def path_of(row: dict) -> list[str]:
|
|
"""조합의 폴더 — [구분] · [구분, 상세구분] · 구분이 없으면 뿌리."""
|
|
a, b = (str(row.get(k) or "").strip() for k in ("구분", "상세구분"))
|
|
return [a, b] if a and b else [a] if a else []
|
|
|
|
|
|
def folders_of(doc: dict) -> list[list[str]]:
|
|
"""폴더 전부 — 적어 둔 것 + 조합 자리 · 부모까지."""
|
|
out: list[list[str]] = []
|
|
written = [p for p in doc.get("폴더") or [] if isinstance(p, list)]
|
|
for path in [*written, *(path_of(r) for r in doc.get("줄") or [])]:
|
|
path = [str(x) for x in path][:DEPTH]
|
|
for at in range(1, len(path) + 1):
|
|
if path[:at] not in out:
|
|
out.append(path[:at])
|
|
return out
|
|
|
|
|
|
# ── 읽기 ──────────────────────────────────────────────────────────────
|
|
def _brief(whole, row: dict) -> dict[str, Any]:
|
|
problems = mcb.check_row(whole, row)
|
|
return {
|
|
**{k: row.get(k) for k in mcb.COLS if k != "담은로직"},
|
|
"count": len(mcb.items(row)),
|
|
"blocked": bool(problems),
|
|
"reasons": problems,
|
|
}
|
|
|
|
|
|
def listing() -> dict[str, Any]:
|
|
"""지금 층 — `{판, 폴더, 줄}` · 줄 = 담은 로직 수 · 막힘 까닭(담은 로직 목록은 빼고)."""
|
|
doc, version = _load()
|
|
whole = m01.loaded()[1]
|
|
줄 = [_brief(whole, r) for r in doc.get("줄") or []]
|
|
return {"판": version, "폴더": folders_of(doc), "줄": 줄}
|
|
|
|
|
|
def one(key: str) -> dict[str, Any]:
|
|
"""조합 하나 — 담은 로직에 이름 · 단위를 붙여 줌."""
|
|
doc, version = _load()
|
|
row = _find(doc, key)
|
|
whole = m01.loaded()[1]
|
|
problems = mcb.check_row(whole, row)
|
|
brief = combo_store._brief(whole, row)
|
|
return {
|
|
"판": version,
|
|
"combo": row,
|
|
"줄": brief,
|
|
"blocked": bool(problems),
|
|
"reasons": problems,
|
|
}
|
|
|
|
|
|
def pills(key: str) -> dict[str, Any]:
|
|
return combo_store.combo_pills(_find(_load()[0], key))
|
|
|
|
|
|
# ── 쓰기(회사 · 개인) ──────────────────────────────────────────────────
|
|
def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]:
|
|
"""서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든)."""
|
|
if not isinstance(row, dict):
|
|
raise store.StoreError(400, "조합 줄이 묶음 아님")
|
|
name = row.get("이름")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise store.StoreError(400, "조합 줄에 「이름」 이 없거나 글자 아님")
|
|
owner = row.get("소유") or (was or {}).get("소유") or OWNERS[0]
|
|
if owner not in OWNERS:
|
|
raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}")
|
|
origin = row.get("본뜬키", (was or {}).get("본뜬키"))
|
|
if origin is not None and (origin == key or find(origin) is None):
|
|
raise store.StoreError(400, f"본뜬키 「{origin}」 — 비었거나 있는 다른 조합 키여야 함")
|
|
one = {k: row.get(k) for k in mcb.COLS}
|
|
one.update({"키": key, "이름": name.strip(), "소유": owner, "본뜬키": origin})
|
|
one["출처"] = one["출처"] or "자체"
|
|
return one
|
|
|
|
|
|
def _name_errors(doc: dict, one: dict) -> list[str]:
|
|
name = one["이름"]
|
|
errors = [f"이름이 너무 김 — {mcb.MAX_이름}자까지"] if len(name) > mcb.MAX_이름 else []
|
|
if any(r.get("키") != one["키"] and str(r.get("이름", "")).strip() == name for r in doc["줄"]):
|
|
errors.append(f"「{name}」 이름의 조합이 이미 있음 — 다른 이름으로")
|
|
return errors
|
|
|
|
|
|
def _check(doc: dict, one: dict) -> None:
|
|
"""이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422."""
|
|
if errors := _name_errors(doc, one) + mcb.check_row(m01.loaded()[1], one):
|
|
raise store.StoreError(422, {"errors": errors})
|
|
|
|
|
|
def _save(doc: dict, version: str) -> str:
|
|
path = store.root() / FILE
|
|
with store._LOCK:
|
|
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
|
if have != (version or ""):
|
|
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
atomic_write_json(path, doc)
|
|
return store.version_of(path.read_bytes())
|
|
|
|
|
|
def _users(key: str) -> list[str]:
|
|
"""이 층 구조물 조합에서 이 조합을 쓰는 항목 이름."""
|
|
if heads()[-1] == MASTER:
|
|
return combo_store.combo_users(key)
|
|
return [
|
|
tree.name_of(tree.path_of(c)) or c["id"]
|
|
for c in store._structure_columns()
|
|
if c.get("일위대가") == key
|
|
]
|
|
|
|
|
|
def _refuse_used(keys: list[str]) -> None:
|
|
used = [u for key in keys for u in _users(key)]
|
|
if used:
|
|
msg = f"구조물 {len(used)} 곳이 쓰는 조합이라 못 지움 — {' · '.join(used)}"
|
|
raise store.StoreError(409, {"errors": [msg], "users": used})
|
|
|
|
|
|
def create(row: Any, version: str | None = None) -> dict[str, Any]:
|
|
"""새 조합 — 키 = 층 머리 + 「다음」 번호. `version` None = 지금 판(갈라 내기)."""
|
|
if heads()[-1] == MASTER:
|
|
owner = row.get("소유") if isinstance(row, dict) else None
|
|
done = _m01(combo_store.combo_new, row, owner or OWNERS[0])
|
|
return {"키": done["key"], "판": done["version"], "combo": done["combo"]}
|
|
doc, have = _load()
|
|
number = int(doc.get("다음") or 1)
|
|
fixed = _clean(row, f"{heads()[-1]}{number:06d}")
|
|
_check(doc, fixed)
|
|
doc = {**doc, "다음": number + 1, "줄": [*doc["줄"], fixed]}
|
|
return {
|
|
"키": fixed["키"],
|
|
"판": _save(doc, have if version is None else version),
|
|
"combo": fixed,
|
|
}
|
|
|
|
|
|
def edit(key: str, row: Any, version: str) -> dict[str, Any]:
|
|
if heads()[-1] == MASTER:
|
|
owner = row.get("소유") if isinstance(row, dict) else ""
|
|
done = _m01(combo_store.combo_edit, key, version, row, owner or "")
|
|
return {"키": key, "판": done["version"], "combo": done["combo"]}
|
|
doc, _ = _load()
|
|
fixed = _clean(row, key, _find(doc, key))
|
|
_check(doc, fixed)
|
|
doc = {**doc, "줄": [fixed if r.get("키") == key else r for r in doc["줄"]]}
|
|
return {"키": key, "판": _save(doc, version), "combo": fixed}
|
|
|
|
|
|
def drop_practice(version: str) -> dict[str, Any]:
|
|
"""실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 ·
|
|
구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만."""
|
|
if heads()[-1] != MASTER:
|
|
raise store.StoreError(400, "실무문서는 마스터 층에만 있음")
|
|
doc, have = _load()
|
|
if have != (version or ""):
|
|
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
|
keys = [str(r.get("키")) for r in doc.get("줄") or []]
|
|
_refuse_used(keys)
|
|
if keys:
|
|
_m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have)
|
|
return {**listing(), "지운조합": keys}
|
|
|
|
|
|
def delete(key: str, version: str) -> dict[str, Any]:
|
|
"""지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음)."""
|
|
if heads()[-1] == MASTER:
|
|
return {"판": _m01(combo_store.combo_delete, key, version)["version"]}
|
|
doc, _ = _load()
|
|
_find(doc, key)
|
|
_refuse_used([key])
|
|
return {"판": _save({**doc, "줄": [r for r in doc["줄"] if r.get("키") != key]}, version)}
|