"""시트 로직 시험 길(PLAN 35-5) — 목록 · 열기 · 저장 · 지우기 · 바깥 값. GET /api/m01/sl/list GET /api/m01/sl/get?key=SL-0001 POST /api/m01/sl/save {logic} → {key, logic, values} POST /api/m01/sl/delete {key} POST /api/m01/sl/outside {items: SlOutside[]} → {values: SlOutsideValue[]} 정함 `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+)$") FIELDS = (*cm.mf.COST_ITEMS, "계") 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("이름") or "", "결과단위": logic.get("결과단위") 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} # ── 바깥 값 ──────────────────────────────────────────────────────────── def _row(line: dict, names) -> dict: """바깥 단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직.""" return { "키": "", "원문번호": "", "이름": "바깥 값", "결과단위": "원/식", "출처": "자체", "입력": [{"이름": n} for n in names], "중간": [], "호표": [line], "덧줄": [], "끝수": None, } def _price(kind: str, key: str) -> dict: """노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로.""" whole = store.loaded()[1] try: brief = whole.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] return {"ok": one["단가"] is not None, "value": one["단가"], "reason": one.get("까닭")} | { "name": brief.get("이름"), "unit": unit, } 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 _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 ((f"{n}·아래", f"{n}·위") 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: kind, at = item.get("kind"), item.get("id") or "" 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 = _money( store.calc(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": at, **got} def outside(items: list[dict]) -> dict: # ponytail: 하나씩 셈(한 번 0.3초쯤) — 바깥 값이 수십이면 마스터 사본 한 벌로 묶어 셈 return {"values": [one_outside(i) for i in 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("칸", {}) old = cells.get(cell) or {} if value is None: cells.pop(cell, None) return keep = {"서식": old["서식"]} if "서식" in old else {} cells[cell] = {"값": value, **keep} def _fill(book: dict, at: dict, got: dict) -> None: m = _CELL.match(str(at.get("cell") or "").upper()) if not m: raise store.StoreError(400, f"칸 주소 모양이 아님 「{at.get('cell')}」") sheet, col, row = at.get("sheet") or "", _col(m.group(1)), int(m.group(2)) if "rows" in got: for dy, line in enumerate(got["rows"]): for dx, value in enumerate(line): _put(book, sheet, f"{_letters(col + dx)}{row + dy}", value) else: _put(book, sheet, f"{m.group(1)}{row}", got.get("value") if got.get("ok") else None) def save(logic: dict) -> dict: if not isinstance(logic.get("book"), dict): raise store.StoreError(400, "통합문서(book)가 없음") if not str(logic.get("이름") or "").strip(): raise store.StoreError(400, "이름을 넣어 주세요") key = logic.get("key") or None if key: _read(key) # 있는 것만 고침 — 없으면 404 places = logic.get("outside") or [] values = [one_outside(at.get("item") or {}) for at in places] book = _num(logic["book"]) for at, got in zip(places, values): _fill(book, at, _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, "book": 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)