- M01 왼쪽 「별칭 장부」 → 「별칭 모아 보기」 — 별칭이 적힌 줄 전부(인력 · 기계 · 재료 · 유가 · 단가산출 기초) · 수기/승인 표시 · 갈래 거름 · 찾기 · 줄 누르면 그 요소 화면 그 줄 · 별칭 칸 고침 → [저장](그 줄 별칭만) - 위 「승인 대기 n」 — [승인] = 그 요소 별칭에 콤마로 더함(속 콤마 = 큰따옴표 조각) · [반려] = 지움 - 서버 M01_MasterData_Alias 새로 — GET /api/m01/alias · POST save · approve · reject(시스템 관리자) · POST suggest(로그인만 · storage/_shared/별칭제안.json) · 풀이 = master_alias - 옛 /api/m01/alias-ledger(모인 후보 · 마스터 장부 · 반려 기억) 걷음 - M01 저장 — 값이 같은 칸은 파일 글 그대로(화면 왕복 9.0 → 9 막음 · 그 줄 별칭만 바뀜) - 대시보드 카드 이름 「별칭 모아 보기」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
9.3 KiB
Python
235 lines
9.3 KiB
Python
"""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)
|