"""M01 별칭 모아 보기 · 제안 → 승인(PLAN 815-2 · 요구 764 · 765) — 서버 단독. 별칭 = 요소 · 로직 줄 칸 `별칭` 하나(콤마 여러 개 · 풀이 정본 `master_alias`). GET /api/m01/alias → {줄: 별칭이 적힌 줄 전부(갈래 · 키 · 이름 · 규격 · 별칭 · 조각 · 들어옴 수기/승인), 대기: 승인 대기 제안} POST /api/m01/alias/save {키, 별칭} → 그 줄 `별칭` 만 고침(M01 저장 길 `make.save`) POST /api/m01/alias/approve {id} → 그 요소 `별칭` 에 콤마로 더함 · 제안 = 「승인」 기록 POST /api/m01/alias/reject {id} → 제안 지움 POST /api/m01/alias/suggest {키, 이름, 규격?, 단위?(갈래는 서버가 키로 정함)} → 제안 하나(로그인만 · 일반 계정도) 제안 파일 = `storage/_shared/별칭제안.json` `{제안: {id: {…, 상태 대기|승인}}}`. 권한 = `router` 시스템 관리자 · `suggest_router` 로그인만(`main.py`). """ from __future__ import annotations import hashlib import json import os import threading from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from common_util.common_util_auth import verify_session from common_util.common_util_time import utc_now_text from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Make as make from M02_MasterTemplete import M02_Template_Layers as layers import master_alias as ma # noqa: E402 (`store` 가 scripts 를 길에 둠) ALIAS = "별칭" FUEL = "재료_유가전력.json" ELEMENTS = ("인력.json", "기계.json", "재료_자재품목.json", FUEL) # 제안을 받는 요소 파일 _LOCK = threading.Lock() router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"]) suggest_router = APIRouter(prefix="/api/m01/alias", tags=["M01 별칭"]) def kind_of(file: str, group: str) -> str: if file == FUEL: return "유가" return "단가산출 기초" if group == "로직" else group # ── 제안 파일 ───────────────────────────────────────────────────────── def suggest_path() -> Path: return layers.storage_root() / "_shared" / "별칭제안.json" def _book() -> dict[str, dict[str, Any]]: try: got = json.loads(suggest_path().read_text(encoding="utf-8")).get("제안") except (OSError, ValueError, AttributeError): return {} return got if isinstance(got, dict) else {} def _keep(book: dict[str, dict[str, Any]]) -> None: path = suggest_path() path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps({"제안": book}, ensure_ascii=False, indent=1), encoding="utf-8") os.replace(tmp, path) # ── 줄 찾기 · 고치기 ─────────────────────────────────────────────────── def _find(key: str, files: tuple[str, ...] | None = None) -> tuple[str, dict[str, Any]]: loaded, _ = store.loaded() for name, data in loaded.items(): if files and name not in files: continue for row in data.get("줄") or []: if str(row.get("키")) == key: return name, row raise store.StoreError(404, f"없는 키 「{key}」") def set_alias(key: str, text: str) -> dict[str, Any]: """그 줄 `별칭` 만 바꿔 M01 저장 길로(빈 글 = 칸 없음 · 별칭만이라 정본 개정 확인 안 물음).""" file, _ = _find(key) data, version = store.read(file) row = dict(next(r for r in data["줄"] if str(r.get("키")) == key)) row[ALIAS] = text change = {"op": "edit", "key": key, "row": row} make.save([{"file": file, "version": version, "changes": [change]}], True) return board() def _quoted(piece: str) -> str: return f'"{piece}"' if any(s in piece for s in (",", ",")) else piece def _joined(text: Any, piece: str) -> str: """별칭 글 끝에 한 조각 — 이미 있으면(정규화 같음) 그대로.""" raw = str(text or "").strip() if ma.norm(piece) in {ma.norm(x) for x in ma.names(raw)}: return raw return f"{raw}, {_quoted(piece)}" if raw else _quoted(piece) # ── 모아 보기 ───────────────────────────────────────────────────────── def board() -> dict[str, Any]: book = _book() approved = { (str(v.get("키")), ma.norm(v.get(ALIAS))) for v in book.values() if v.get("상태") == "승인" } rows = [] loaded, _ = store.loaded() for file, data in loaded.items(): kind = kind_of(file, str(data.get("그룹") or "")) for row in data.get("줄") or []: if not str(row.get(ALIAS) or "").strip(): continue key = str(row.get("키")) pieces = [ {"글": p, "승인": (key, ma.norm(p)) in approved} for p in ma.names(row[ALIAS]) ] rows.append( { "갈래": kind, "파일": file, "키": key, "이름": str(row.get("이름") or ""), "규격": str(row.get("규격") or ""), ALIAS: str(row[ALIAS]), "조각": pieces, "들어옴": "승인" if any(p["승인"] for p in pieces) else "수기", } ) waiting = [{"id": k, **v} for k, v in book.items() if v.get("상태") == "대기"] waiting.sort(key=lambda x: str(x.get("때") or "")) return {"줄": rows, "대기": waiting} # ── 제안 · 승인 · 반려 ──────────────────────────────────────────────── def suggest(key: str, name: str, spec: str, unit: str, who: Any) -> dict[str, Any]: file, row = _find(key, ELEMENTS) piece = " ".join(x for x in (name.strip(), spec.strip()) if x) if not piece: raise store.StoreError(400, "가져온 이름이 비었음") if _joined(row.get(ALIAS), piece) == str(row.get(ALIAS) or "").strip(): return {"이미": True} # 이미 그 별칭이 있음 sid = hashlib.sha256(f"{key}|{ma.norm(piece)}".encode()).hexdigest()[:16] with _LOCK: book = _book() if book.get(sid, {}).get("상태") != "승인": book[sid] = { "갈래": kind_of(file, str(store.loaded()[0][file].get("그룹") or "")), "키": key, "이름": name.strip(), "규격": spec.strip(), "단위": unit.strip(), ALIAS: piece, "상태": "대기", "누가": who, "때": utc_now_text(), } _keep(book) return {"id": sid} def decide(sid: str, ok: bool, who: Any) -> dict[str, Any]: with _LOCK: book = _book() one = book.get(sid) if not one or one.get("상태") != "대기": raise store.StoreError(404, "대기 중인 제안이 없음(이미 승인 · 반려)") if ok: _, row = _find(str(one["키"]), ELEMENTS) set_alias(str(one["키"]), _joined(row.get(ALIAS), str(one[ALIAS]))) one.update(상태="승인", 승인={"누가": who, "때": utc_now_text()}) else: del book[sid] _keep(book) return board() # ── 길 ─────────────────────────────────────────────────────────────── def _who(session: dict[str, Any]) -> dict[str, Any]: return {"id": session.get("user_id"), "이름": session.get("name")} def _call(fn, *args): try: return fn(*args) except store.StoreError as error: raise HTTPException(error.status, error.detail) from error class AliasBody(BaseModel): 키: str = Field(min_length=1, max_length=20) 별칭: str = Field(max_length=2000) class Decide(BaseModel): id: str = Field(min_length=16, max_length=16) class Suggest(BaseModel): 갈래: str = Field(default="", max_length=20) 키: str = Field(min_length=1, max_length=20) 이름: str = Field(min_length=1, max_length=200) 규격: str = Field(default="", max_length=200) 단위: str = Field(default="", max_length=40) @router.get("") def get_board() -> dict[str, Any]: return board() @router.post("/save") def post_save(body: AliasBody) -> dict[str, Any]: return _call(set_alias, body.키, body.별칭) @router.post("/approve") def post_approve(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]: return _call(decide, body.id, True, _who(session)) @router.post("/reject") def post_reject(body: Decide, session: dict = Depends(verify_session)) -> dict[str, Any]: return _call(decide, body.id, False, _who(session)) @suggest_router.post("/suggest") def post_suggest(body: Suggest, session: dict = Depends(verify_session)) -> dict[str, Any]: args = (body.키, body.이름, body.규격, body.단위, _who(session)) return _call(suggest, *args)