Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Router.py
T
eomsangdonandClaude Opus 5.5 5f9240c9e3 fix(m01-sl): 시트 로직 서버 · Api 를 sub7 Model 모양으로 — SheetLogic · SlSource · SlOutsideAnswer
- 저장 몸 = Model SheetLogic(workbook · nodes · edges) · 바깥 값 칸 = Eval outsideCells 규칙(알약 outside · 상자 source · 표 · 로직 ref 짐작 · id 「상자!칸」)
- 몸의 outside: SlOutsideAt[](Item 항목 단가 — 칸마다 비목) 도 받음 · 같은 칸은 그쪽이 이김 · 값은 늘 마스터에서
- /outside 물음 = {id, ...SlSource} · 답 = Eval SlOutsideAnswer · 표 범위 칸 「·아래」 「·위」(Model RANGE_LOW · RANGE_HIGH)
- Api 겹친 타입(SlLogic · SlField · SlOutsideValue) 지우고 Model · Eval 것을 씀 · Item 이 쓰는 SlOutside · SlOutsideAt 는 SlQuery 별칭으로 남김
- 시험 test_m01_sl_router.py 를 Model 모양으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:14:02 +09:00

358 lines
14 KiB
Python

"""시트 로직 시험 길(PLAN 35-5) — 목록 · 열기 · 저장 · 지우기 · 바깥 값.
GET /api/m01/sl/list
GET /api/m01/sl/get?key=SL-0001
POST /api/m01/sl/save {logic: SheetLogic} → {key, logic, values: SlOutsideAnswer[]}
POST /api/m01/sl/delete {key}
POST /api/m01/sl/outside {items: [{id, ...SlSource}]} → {values: SlOutsideAnswer[]}
모양은 sub7 Model(`M01_SL_Model.ts` — SheetLogic · SlSource) · 답은 Eval `SlOutsideAnswer`.
정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 — 저장 때 바깥 값(품셈 표 · 노임 ·
자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 우리 스프레드시트 엔진(Node ·
`common_util_spreadsheet`)으로 다시 셈 · 브라우저 계산값은 버림. 단가는 `/calc`(`Store.calc`)와
같은 셈. 자리 = 이 폴더 `_data/`(git 밖) · 키 `SL-0001` 꼴(`_키대장.json` 안 씀) · 마스터 파일은
안 씀. 권한 = `/api/m01` 과 같음(`main.py` 등록 한 줄).
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from common_util import common_util_spreadsheet as spreadsheet
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.M01_MasterData_Router import _num
cm = store.cm
router = APIRouter(prefix="/api/m01/sl", tags=["M01 SheetLogic (시험)"])
DATA = Path(__file__).resolve().parent / "_data" # 시험은 임시 폴더로 바꿈
_KEY = re.compile(r"^SL-(\d{4,})$")
_CELL = re.compile(r"^([A-Z]+)(\d+)$")
class SaveBody(BaseModel):
logic: dict[str, Any]
class KeyBody(BaseModel):
key: str
class OutsideBody(BaseModel):
items: list[dict[str, Any]]
# ── 자리 ──────────────────────────────────────────────────────────────
def _path(key: str) -> Path:
if not _KEY.match(key or ""):
raise store.StoreError(400, f"시트 로직 번호 모양이 아님 「{key}」")
return DATA / f"{key}.json"
def _read(key: str) -> dict:
path = _path(key)
if not path.is_file():
raise store.StoreError(404, f"없는 시트 로직 「{key}」")
return json.loads(path.read_text(encoding="utf-8"))
def _next_key() -> str:
used = [int(m.group(1)) for p in DATA.glob("SL-*.json") if (m := _KEY.match(p.stem))]
return f"SL-{max(used, default=0) + 1:04d}"
def listing() -> dict:
out = []
for path in sorted(DATA.glob("SL-*.json")):
logic = json.loads(path.read_text(encoding="utf-8"))
out.append(
{
"key": path.stem,
"이름": logic.get("name") or "",
"결과단위": logic.get("resultUnit") or "",
"saved": logic.get("saved") or "",
}
)
return {"logics": out}
def get(key: str) -> dict:
return {"logic": _read(key)}
def delete(key: str) -> dict:
_read(key)
_path(key).unlink()
return {"key": key}
# ── 바깥 값 ────────────────────────────────────────────────────────────
# 물음 한 줄 = `{id, ...SlSource}`(Model) · 답 = Eval `SlOutsideAnswer {id, ok, value | rows}`
# 표 {kind: "표", table} → rows = 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」 — `rangeHead`)
# 노임 · 자재 {kind, key} → 그 줄 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값)
# 장비 {kind: "장비", element, inputs, field?} → 기계경비 로직 부름 글 한 줄(수량 1)
# 로직 {kind: "로직", key, inputs, field?} → 기존 로직 결과(빈 입력은 자동값으로 채움)
RANGE_LOW, RANGE_HIGH = "·아래", "·위" # Model `RANGE_LOW` · `RANGE_HIGH` 와 같음
FIELDS = (*cm.mf.COST_ITEMS, "계")
def _row(line: dict, names) -> dict:
"""바깥 단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직."""
return {
"키": "",
"원문번호": "",
"이름": "바깥 값",
"결과단위": "원/식",
"출처": "자체",
"입력": [{"이름": n} for n in names],
"중간": [],
"호표": [line],
"덧줄": [],
"끝수": None,
}
def _money(got: dict, field: str | None) -> dict:
if not got["ok"]:
return got
if "result" in got:
return {"ok": True, "value": got["result"]}
pick = field or "계"
if pick not in FIELDS:
return {"ok": False, "reason": f"모르는 칸 「{pick}」"}
return {"ok": True, "value": got["sums"][pick]}
def _price(kind: str, key: str) -> dict:
"""노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로."""
try:
brief = store.loaded()[1].get(key)
except cm.mf.FormulaError as e:
return {"ok": False, "reason": str(e)}
group = store.mk.GROUP_OF.get(store.mk.id_of(key))
want = {"노임": ("인력", "노무비"), "자재": ("재료", "재료비")}[kind]
if group != want[0]:
return {"ok": False, "reason": f"「{key}」 는 {kind} 아님"}
unit = brief.get("단위")
line = {"종류": want[0], "요소": key, "이름": brief.get("이름") or key, "수량": "1"}
got = store.calc("", {}, _row({**line, "단위": unit or "", "비목": want[1]}, ()), None)
if not got["ok"]:
return got
one = got["lines"][0]
out = {"ok": one["단가"] is not None, "value": one["단가"], "name": brief.get("이름")}
return {**out, "unit": unit, **({"reason": one["까닭"]} if one.get("까닭") else {})}
def _logic(key: str, inputs: dict, field: str | None) -> dict:
"""기존 로직 결과 — 준 입력이 이기고 빈 입력은 자동값으로."""
try:
auto = store.auto(key)["값"]
except store.StoreError:
auto = {}
return _money(store.calc(key, {**auto, **inputs}), field)
def _table(key: str) -> dict:
"""품셈 표 통째 → 머리 줄 + 줄(범위 · 범위 값은 「이름·아래」 「이름·위」 두 칸)."""
files = store.loaded()[0]
table = next(
(
t
for d in files.values()
if d.get("그룹") in cm.mf.TABLE_GROUPS
for t in d.get("표") or []
if str(t.get("키")) == key
),
None,
)
if table is None:
return {"ok": False, "reason": f"없는 표 「{key}」"}
names = [*(table.get("조건") or {}), *(table.get("값칸") or {})]
split = {
n
for n in names
if (table.get("조건") or {}).get(n) == "범위"
or any(isinstance(r.get(n), list) for r in table.get("줄") or [])
}
head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))]
rows = []
for r in table.get("줄") or []:
line = []
for n in names:
v = r.get(n)
if n in split:
low, high = v if isinstance(v, list) else (v, v)
line += [low, high]
else:
line.append(v if not isinstance(v, (list, dict)) else str(v))
rows.append(line)
return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")}
def one_outside(item: dict) -> dict:
"""물음 한 줄 `{id, ...SlSource}` → 답 `SlOutsideAnswer`."""
kind = item.get("kind")
try:
if kind == "표":
got = _table(str(item.get("table") or ""))
elif kind in ("노임", "자재"):
got = _price(kind, str(item.get("key") or ""))
elif kind == "장비":
inputs = item.get("inputs") or {}
line = {"종류": "로직", "요소": item.get("element") or "", "이름": "장비"}
row = _row({**line, "단위": "시간", "수량": "1"}, inputs)
got = _money(store.calc("", inputs, row, None), item.get("field"))
elif kind == "로직":
got = _logic(str(item.get("key") or ""), item.get("inputs") or {}, item.get("field"))
else:
got = {"ok": False, "reason": f"모르는 바깥 값 「{kind}」"}
except store.StoreError as e:
got = {"ok": False, "reason": str(e.detail)}
return {"id": item.get("id") or "", **got}
def outside(items: list[dict]) -> dict:
# ponytail: 하나씩 셈(한 번 0.3초쯤) — 바깥 값이 수십이면 마스터 사본 한 벌로 묶어 셈
return {"values": [one_outside(i) for i in items]}
def _source(node: dict) -> dict | None:
"""Eval `outsideCells` 와 같은 규칙 — 상자 source · 없으면 표 · 로직은 ref 로 짐작."""
if node.get("source"):
return node["source"]
ref = node.get("ref")
if ref and node.get("kind") == "table":
return {"kind": "표", "table": ref}
if ref and node.get("kind") == "logic":
return {"kind": "로직", "key": ref, "inputs": {}}
return None
def outside_cells(logic: dict) -> list[tuple[dict, str]]:
"""바깥 값 칸 → [({id, ...source, _cell}, 시트)].
① 알약 outside 참 — id 「상자!칸」 · 출처 없으면 뺌(Eval `outsideCells` 규칙)
② 몸의 `outside: [{item, sheet, cell}]` — Item 항목 단가(칸마다 비목) · 같은 칸은 ② 가 이김
"""
found: dict[tuple[str, str], dict] = {}
for node in logic.get("nodes") or []:
source = _source(node)
for pill in [*(node.get("pillsIn") or []), *(node.get("pillsOut") or [])]:
if pill.get("outside") and source:
sheet, cell = node.get("sheetId") or "", pill.get("cell") or ""
found[(sheet, cell)] = {"id": f"{node.get('id')}!{cell}", **source}
for at in logic.get("outside") or []:
found[(at.get("sheet") or "", at.get("cell") or "")] = dict(at.get("item") or {})
return [({**item, "_cell": cell}, sheet) for (sheet, cell), item in found.items()]
# ── 저장 ──────────────────────────────────────────────────────────────
def _col(letters: str) -> int:
n = 0
for ch in letters:
n = n * 26 + ord(ch) - 64
return n
def _letters(n: int) -> str:
out = ""
while n:
n, rest = divmod(n - 1, 26)
out = chr(65 + rest) + out
return out
def _put(book: dict, sheet_id: str, cell: str, value: Any) -> None:
"""바깥 값 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠)."""
sheet = next((s for s in book.get("시트") or [] if s.get("id") == sheet_id), None)
if sheet is None:
raise store.StoreError(400, f"없는 시트 「{sheet_id}」")
cells = sheet.setdefault("칸", {})
keep = {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {}
if value is None and not keep:
cells.pop(cell, None)
else:
cells[cell] = {**({"값": value} if value is not None else {}), **keep}
def _fill(book: dict, sheet: str, cell: str, got: dict) -> None:
m = _CELL.match(str(cell or "").upper())
if not m:
raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」")
col, row = _col(m.group(1)), int(m.group(2))
if "rows" in got: # 표 — cell 이 왼위
for dy, line in enumerate(got["rows"]):
for dx, one in enumerate(line):
_put(book, sheet, f"{_letters(col + dx)}{row + dy}", one)
else:
_put(book, sheet, f"{m.group(1)}{row}", got.get("value") if got.get("ok") else None)
def save(logic: dict) -> dict:
"""SheetLogic 저장 — 바깥 값 다시 넣기 → Node 로 다시 셈(브라우저 계산값 버림) → `_data`."""
if not isinstance(logic.get("workbook"), dict):
raise store.StoreError(400, "통합문서(workbook)가 없음")
if not str(logic.get("name") or "").strip():
raise store.StoreError(400, "이름을 넣어 주세요")
key = logic.get("key") or None
if key:
_read(key) # 있는 것만 고침 — 없으면 404
places = outside_cells(logic)
values = [one_outside({k: v for k, v in item.items() if k != "_cell"}) for item, _ in places]
book = _num(logic["workbook"])
for (item, sheet), got in zip(places, values):
_fill(book, sheet, item["_cell"], _num(got))
try:
book = spreadsheet.with_server_values(book) # 브라우저 계산값은 버리고 서버 값
except spreadsheet.RecalcError as e:
raise store.StoreError(503, str(e)) from e
DATA.mkdir(parents=True, exist_ok=True)
key = key or _next_key()
stamp = datetime.now().isoformat(timespec="seconds")
done = {**logic, "key": key, "workbook": book, "saved": stamp}
tmp = DATA / f".{key}.tmp"
tmp.write_text(json.dumps(done, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
os.replace(tmp, DATA / f"{key}.json")
return {"key": key, "logic": done, "values": values}
# ── 길 ────────────────────────────────────────────────────────────────
def _call(fn, *args):
try:
return _num(fn(*args))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/list")
def get_list() -> dict:
return _call(listing)
@router.get("/get")
def get_one(key: str) -> dict:
return _call(get, key)
@router.post("/save")
def post_save(body: SaveBody) -> dict:
return _call(save, body.logic)
@router.post("/delete")
def post_delete(body: KeyBody) -> dict:
return _call(delete, body.key)
@router.post("/outside")
def post_outside(body: OutsideBody) -> dict:
return _call(outside, body.items)