"""시트 로직 시험 길(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: """품셈 표 통째 → 머리 줄 + 줄 — 표 블록 칸 차례의 정본(브레인 정함 · 화면도 이것을 따름). 조건 칸 → 값 칸 · 범위가 있는 칸(범위 조건 · 「20~45」 같은 범위 값)은 「이름·아래」 「이름·위」 두 칸 · 그 칸에 수 하나면 아래 · 위에 같은 수 · 빈 칸은 빈 칸. """ 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}」"} conds, lines = table.get("조건") or {}, table.get("줄") or [] names = [*conds, *(table.get("값칸") or {})] split = { n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) } 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 lines: line = [] for n in names: v = r.get(n) if n in split: # 범위 칸 — 수 하나면 아래 · 위 같은 수 line += list(v) if isinstance(v, list) else [v, v] else: line.append(None if v == "" else v if not isinstance(v, 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)