feat(504-2 계산 자리 스위치): 시스템 관리자 「계산 자리」 카드 · 창 · 판 · G1 · D3 연결

- 판 = resources/master_template/calc_sites.json(기본값 = 등록부 자리 그대로 · 전부 both)
- common_util_calc_sites — 묶음 8 줄(등록부 45 #) · 읽기 · 검사(잠금 · 모르는 줄 · 값 = 400) · 통째 쓰기
- GET /api/dashboard/calc-sites(로그인) · PUT /api/dashboard/admin/calc-sites(시스템 관리자) · 바뀐 줄마다 CALC_SITE_CHANGE 시스템 로그
- 대시보드 시스템 설정 「계산 자리」 카드(시스템 관리자만 · ☰ 엔 없음) · 체크 창(칸 둘 · 잠긴 줄 까닭 · 지금 서버 N 줄 · 다음 열기부터)
- design_calc_sites — B05 · B06 열 때 한 번 받음 · G1 server = ground 단계 안에서만 서버 표본(cross 격자 그대로) · D3 server = 워커 안 띄움
- 시험 test_309_2_calc_sites 새 · test_b01_settings_cards · test_225_drawer_shortcuts 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 12:23:11 +09:00
co-authored by Claude Opus 5.5
parent 163ab72881
commit 282b481f81
16 changed files with 667 additions and 10 deletions
+101
View File
@@ -0,0 +1,101 @@
"""계산 자리 판(PLAN 504-2 · 요구 309-2) — 시스템 관리자 「계산 자리」 창이 읽고 쓰는 판 하나.
판 = `resources/master_template/calc_sites.json` `{version, sites: {묶음: "both" | "server"}}`.
both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길.
판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음.
묶음 8 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
PATH = ROOT / "resources" / "master_template" / "calc_sites.json"
VALUES = ("both", "server")
# 묶음 · 등록부 # · 바꿀 수 있음 · 잠금 줄의 고정 자리(both · server · browser) · 까닭 열쇠
GROUPS: tuple[dict[str, Any], ...] = (
{"key": "ground_sample", "nos": ["G1"], "switchable": True, "fixed": None, "why": None},
{"key": "corridor_mesh", "nos": ["D3"], "switchable": True, "fixed": None, "why": None},
{
"key": "quantity",
"nos": ["E1", "F7", "F8", "C4"],
"switchable": False,
"fixed": "both",
"why": "later",
},
{"key": "carry", "nos": ["R9", "F10"], "switchable": False, "fixed": "both", "why": "later"},
{
"key": "stages",
"nos": ["R1", "R3", "R5", "R6", "R7", "G2", "G4", "P1", "P2", "P3", "P4", "P5", "P6"]
+ ["P8", "C1", "C2", "C3", "C5", "C6", "F1", "F2", "F3", "F4"],
"switchable": False,
"fixed": "both",
"why": "pair_later",
},
{
"key": "structure_pair",
"nos": ["F5", "F6", "F9"],
"switchable": False,
"fixed": "both",
"why": "pair",
},
{
"key": "fixed_server",
"nos": ["E2", "G3", "D1", "D2", "N1"],
"switchable": False,
"fixed": "server",
"why": "server",
},
{
"key": "fixed_browser",
"nos": ["R2", "R4", "R8", "P7", "C7", "D4"],
"switchable": False,
"fixed": "browser",
"why": "browser",
},
)
SWITCHABLE = frozenset(g["key"] for g in GROUPS if g["switchable"])
LOCKED = frozenset(g["key"] for g in GROUPS if not g["switchable"])
def read_sites() -> dict[str, Any]:
"""판 — 없음 · 깨짐 · 모르는 값 = 그 줄 기본값(both)."""
try:
raw = json.loads(PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
raw = {}
stored = raw.get("sites") if isinstance(raw, dict) else None
stored = stored if isinstance(stored, dict) else {}
sites = {key: stored.get(key) if stored.get(key) in VALUES else "both" for key in SWITCHABLE}
version = raw.get("version") if isinstance(raw, dict) else None
return {"version": version if isinstance(version, int) else 0, "sites": sites}
def check_changes(changes: dict[str, str]) -> None:
"""잠금 줄 · 모르는 줄 · 모르는 값 = ValueError(창구가 400)."""
for key, value in changes.items():
if key in LOCKED:
raise ValueError(f"잠긴 줄 — {key}")
if key not in SWITCHABLE:
raise ValueError(f"모르는 줄 — {key}")
if value not in VALUES:
raise ValueError(f"모르는 값 — {key}={value}")
def write_sites(changes: dict[str, str]) -> tuple[dict[str, Any], list[str]]:
"""검사 → 바뀐 줄만 얹어 판 번호 +1 · 통째 바꿔 씀(반쯤 쓴 판 없음). 돌려줌 = 새 판 · 바뀐 줄."""
check_changes(changes)
current = read_sites()
changed = sorted(k for k, v in changes.items() if current["sites"][k] != v)
if not changed:
return current, []
board = {"version": current["version"] + 1, "sites": {**current["sites"], **changes}}
temp = PATH.with_suffix(".json.tmp")
temp.write_text(json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(temp, PATH)
return board, changed