- {kind: "자재", key} 의 key 가 「{」 로 시작하는 JSON 글(또는 묶음)이면 {구분, 상세구분, 검색어, 규격?} 로 풀어 /calc 재료 줄처럼(검색어 · 자동값 차례 · 단위 환산) · 아니면 키
- unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가 · 못 찾으면 쉬운 말(맞는 자재 없음 · 단가가 자료에 비어 있음)
- Api SlQuery 에 unit? · 시험 — 검색어 묶음 단가 = /calc 그 줄 단가(글 · 묶음) · 저장 때도 같은 값
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
397 lines
16 KiB
Python
397 lines
16 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, unit?} → 그 줄 단가(`/calc` 와 같은 셈) · 자재 key = 키 · 검색어 묶음
|
|
# 장비 {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 _pick(key: Any) -> dict | None:
|
|
"""자재 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None."""
|
|
if isinstance(key, dict):
|
|
return key
|
|
if isinstance(key, str) and key.lstrip().startswith("{"):
|
|
try:
|
|
got = json.loads(key)
|
|
except ValueError:
|
|
return None
|
|
return got if isinstance(got, dict) else None
|
|
return None
|
|
|
|
|
|
def _material(cond: dict, unit: str) -> dict:
|
|
"""검색어 묶음 자재 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산)."""
|
|
line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"}
|
|
got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None)
|
|
if not got["ok"]:
|
|
return got
|
|
one = got["lines"][0]
|
|
if one["단가"] is None:
|
|
word = cond.get("검색어") or ""
|
|
empty = "값 없음" in str(one.get("까닭") or "") # 품목은 찾았는데 단가 칸이 빔
|
|
why = (
|
|
"단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름"
|
|
if empty
|
|
else "맞는 자재 없음"
|
|
)
|
|
return {"ok": False, "reason": f"「{word}」 {why}"}
|
|
picked = one.get("품목") or {}
|
|
out = {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None}
|
|
return {**out, **({"reason": one["단위경고"]} if one.get("단위경고") else {})}
|
|
|
|
|
|
def _price(kind: str, key: Any, unit: str = "") -> dict:
|
|
"""노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로 · 자재는 키 또는 검색어 묶음."""
|
|
cond = _pick(key) if kind == "자재" else None
|
|
if cond is not None:
|
|
return _material(cond, unit)
|
|
key = str(key or "")
|
|
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, item.get("key"), str(item.get("unit") 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)
|