feat(m01-sl): 시트 로직 서버 길 — 목록 · 열기 · 저장(서버 다시 셈) · 지우기 · 바깥 값
- M01_SL_Router.py — /api/m01/sl/list · get · save · delete · outside · 자리 SheetLogic/_data(git 밖) · 키 SL-0001 꼴 - 저장 때 바깥 값(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 common_util_spreadsheet(Node)로 다시 셈 · 브라우저 계산값 버림 - 단가 · 장비 · 기존 로직은 /calc(Store.calc)와 같은 셈 · 표는 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」) - main.py 등록 한 문장(시스템 관리자 · 폴더째 지우면 이 줄도 지움) - M01_SL_Api.ts 몸 — slList · slGet · slSave · slDelete · slOutside · slPlain(EasyCalc plainError) - 시험 test_m01_sl_router.py — 저장 · 열기 · 목록 · 지우기 · 서버 다시 셈 · 바깥 값 · 403 · 마스터 사본 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -5,7 +5,10 @@
|
||||
* 서버 = `M01_SL_Router.py`(`/api/m01/sl/*`) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { ApiError } from "../M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음)
|
||||
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸)
|
||||
@@ -78,31 +81,47 @@ export interface SlSaved {
|
||||
values: SlOutsideValue[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
};
|
||||
async function send<T>(path: string, body?: unknown): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/m01/sl${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 내 시트 로직 목록(저장 차례) */
|
||||
export async function slList(): Promise<SlListItem[]> {
|
||||
return notReady();
|
||||
return (await send<{ logics: SlListItem[] }>("/list")).logics;
|
||||
}
|
||||
|
||||
export async function slGet(_key: string): Promise<SlLogic> {
|
||||
return notReady();
|
||||
export async function slGet(key: string): Promise<SlLogic> {
|
||||
return (await send<{ logic: SlLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic;
|
||||
}
|
||||
|
||||
export async function slSave(_logic: SlLogic): Promise<SlSaved> {
|
||||
return notReady();
|
||||
/** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */
|
||||
export async function slSave(logic: SlLogic): Promise<SlSaved> {
|
||||
return send<SlSaved>("/save", { logic });
|
||||
}
|
||||
|
||||
export async function slDelete(_key: string): Promise<void> {
|
||||
return notReady();
|
||||
export async function slDelete(key: string): Promise<void> {
|
||||
await send<{ key: string }>("/delete", { key });
|
||||
}
|
||||
|
||||
export async function slOutside(_items: SlOutside[]): Promise<SlOutsideValue[]> {
|
||||
return notReady();
|
||||
/** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */
|
||||
export async function slOutside(items: SlOutside[]): Promise<SlOutsideValue[]> {
|
||||
return (await send<{ values: SlOutsideValue[] }>("/outside", { items })).values;
|
||||
}
|
||||
|
||||
/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */
|
||||
export function slPlain(_e: unknown): string {
|
||||
return notReady();
|
||||
export function slPlain(e: unknown): string {
|
||||
return plainError(e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""시트 로직 시험 길(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)
|
||||
@@ -643,6 +643,11 @@ app.include_router(
|
||||
__import__("M01_MasterData.LogicBuilder.M01_LB_Calc", fromlist=["router"]).router,
|
||||
dependencies=system_admin_only,
|
||||
)
|
||||
# 시트 로직 시험 길 — 폴더째 지우면 이 줄도 지움
|
||||
app.include_router(
|
||||
__import__("M01_MasterData.SheetLogic.M01_SL_Router", fromlist=["router"]).router,
|
||||
dependencies=system_admin_only,
|
||||
)
|
||||
# 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서).
|
||||
app.include_router(m02_master_template_router, dependencies=system_admin_only)
|
||||
app.include_router(m02_layers_router, dependencies=protected)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""시트 로직 시험 길(`M01_MasterData/SheetLogic/M01_SL_Router.py`) 시험 — PLAN 35-5.
|
||||
|
||||
· 임시 `_data` 로 저장 → 열기 → 목록 → 지우기 · 번호 SL-0001 꼴
|
||||
· 저장 때 바깥 값을 다시 넣고 서버가 같은 엔진(Node)으로 다시 셈 — 브라우저 계산값은 버림
|
||||
· 바깥 값 — 견본 표(범위 조건은 두 칸) · 노임 · 기존 로직 결과 · 틀린 종류
|
||||
· 권한 없음 403 · 마스터 사본 파일은 안 바뀜
|
||||
견본 표 · 로직 · 통합문서는 시험 안에 둠(임시 폴더 사본에 견본 표를 더함).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
from M01_MasterData import M01_MasterData_Router as router_module
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData.SheetLogic import M01_SL_Router as sl
|
||||
|
||||
REAL = store.FOLDER
|
||||
OWN = "로직_자체.json"
|
||||
SAMPLE = "QF999997"
|
||||
TABLE = {
|
||||
"키": SAMPLE,
|
||||
"원문번호": "",
|
||||
"구분": "",
|
||||
"상세구분": "",
|
||||
"이름": "견본 높이 표",
|
||||
"기준": "1㎡",
|
||||
"출처": "자체",
|
||||
"조건": {"돌": "고르기", "높이": "범위"},
|
||||
"범위규칙": "초과·이하",
|
||||
"값칸": {"석공": "인"},
|
||||
"줄": [
|
||||
{"돌": "깬돌", "높이": [None, 3], "석공": 0.5},
|
||||
{"돌": "깬돌", "높이": [3, None], "석공": 0.7},
|
||||
],
|
||||
"주": [],
|
||||
}
|
||||
NO_NODE = shutil.which("node") is None
|
||||
|
||||
|
||||
def _labor() -> tuple[str, Decimal]:
|
||||
rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"]
|
||||
row = next(
|
||||
r
|
||||
for r in rows
|
||||
if r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float))
|
||||
)
|
||||
return row["키"], Decimal(str(row["값"]))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def folder(tmp_path: Path, monkeypatch) -> Path:
|
||||
master = tmp_path / "master"
|
||||
master.mkdir()
|
||||
for path in REAL.glob("*.json"):
|
||||
if path.name == OWN:
|
||||
continue
|
||||
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
|
||||
shutil.copy(path, master / path.name)
|
||||
host = sorted(master.glob("소요량_*.json"))[0]
|
||||
data = json.loads(host.read_text(encoding="utf-8"))
|
||||
data["표"].append(TABLE)
|
||||
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
monkeypatch.setattr(store, "FOLDER", master)
|
||||
monkeypatch.setattr(sl, "DATA", tmp_path / "_data")
|
||||
return master
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(folder: Path) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
app.include_router(sl.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
|
||||
res = client.post(url, json=body)
|
||||
assert res.status_code == want, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _logic(labor: str, key: str | None = None) -> dict:
|
||||
"""노임 바깥 값(A1) × 2 = B1 · 표 바깥 값은 둘째 시트 A1 부터 · 브라우저 계산값은 엉터리."""
|
||||
return {
|
||||
"key": key,
|
||||
"이름": "견본 시트 로직",
|
||||
"결과단위": "원/㎡",
|
||||
"book": {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"서식": [{}],
|
||||
"시트": [
|
||||
{"id": "s1", "이름": "계산", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}},
|
||||
{"id": "s2", "이름": "표", "칸": {}},
|
||||
],
|
||||
"계산값": {"s1": {"B1": {"수": "999"}}},
|
||||
},
|
||||
"outside": [
|
||||
{"item": {"id": "a", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "A1"},
|
||||
{"item": {"id": "b", "kind": "표", "table": SAMPLE}, "sheet": "s2", "cell": "A1"},
|
||||
],
|
||||
"상자": [{"id": "x", "종류": "계산"}], # 다른 창 칸 — 그대로 저장 · 돌려줌
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||||
def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient, folder: Path) -> None:
|
||||
labor, price = _labor()
|
||||
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
|
||||
|
||||
saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||||
assert saved["key"] == "SL-0001"
|
||||
book = saved["logic"]["book"]
|
||||
assert book["시트"][0]["칸"]["A1"]["값"] == price # 바깥 값을 마스터에서 다시 넣음
|
||||
assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림)
|
||||
table = book["시트"][1]["칸"]
|
||||
assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [
|
||||
"돌",
|
||||
"높이·아래",
|
||||
"높이·위",
|
||||
"석공",
|
||||
]
|
||||
assert (
|
||||
"B2" not in table and table["C2"]["값"] == 3 and table["D3"]["값"] == 0.7
|
||||
) # 빈 끝은 빈 칸
|
||||
assert saved["logic"]["상자"] == [{"id": "x", "종류": "계산"}]
|
||||
assert [v["id"] for v in saved["values"]] == ["a", "b"]
|
||||
|
||||
second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||||
assert second["key"] == "SL-0002"
|
||||
renamed = {**saved["logic"], "이름": "고친 이름"}
|
||||
assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001"
|
||||
|
||||
listed = client.get("/api/m01/sl/list").json()["logics"]
|
||||
assert [(x["key"], x["이름"]) for x in listed] == [
|
||||
("SL-0001", "고친 이름"),
|
||||
("SL-0002", "견본 시트 로직"),
|
||||
]
|
||||
got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"]
|
||||
assert got["book"]["계산값"] == book["계산값"]
|
||||
|
||||
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"})
|
||||
assert client.get("/api/m01/sl/get", params={"key": "SL-0002"}).status_code == 404
|
||||
_post(client, "/api/m01/sl/delete", {"key": "SL-0002"}, want=404)
|
||||
_post(client, "/api/m01/sl/save", {"logic": _logic(labor, "SL-0009")}, want=404)
|
||||
_post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "이름": " "}}, want=400)
|
||||
assert client.get("/api/m01/sl/get", params={"key": "../x"}).status_code == 400
|
||||
|
||||
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로
|
||||
assert sorted(p.name for p in sl.DATA.iterdir()) == ["SL-0001.json"]
|
||||
|
||||
|
||||
def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None:
|
||||
labor, price = _labor()
|
||||
made = _post(
|
||||
client,
|
||||
"/api/m01/logic/new",
|
||||
{
|
||||
"logic": {
|
||||
"원문번호": "",
|
||||
"이름": "견본 노무",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "자체",
|
||||
"입력": [{"이름": "품"}],
|
||||
"중간": [],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": labor,
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "품",
|
||||
"비목": "노무비",
|
||||
}
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
items = [
|
||||
{"id": "t", "kind": "표", "table": SAMPLE},
|
||||
{"id": "l", "kind": "노임", "key": labor},
|
||||
{"id": "g", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}},
|
||||
{"id": "g2", "kind": "로직", "key": made["key"], "inputs": {"품": 0.5}, "field": "재료비"},
|
||||
{"id": "w", "kind": "자재", "key": labor},
|
||||
{"id": "x", "kind": "표", "table": "QF999996"},
|
||||
{"id": "z", "kind": "모름"},
|
||||
]
|
||||
got = {v["id"]: v for v in _post(client, "/api/m01/sl/outside", {"items": items})["values"]}
|
||||
assert got["t"]["rows"][0] == ["돌", "높이·아래", "높이·위", "석공"]
|
||||
assert got["t"]["rows"][1:] == [["깬돌", None, 3, 0.5], ["깬돌", 3, None, 0.7]]
|
||||
assert Decimal(str(got["l"]["value"])) == price and got["l"]["unit"]
|
||||
assert Decimal(str(got["g"]["value"])) == Decimal("0.5") * price
|
||||
assert got["g2"]["value"] == 0
|
||||
assert [got[k]["ok"] for k in ("w", "x", "z")] == [False, False, False]
|
||||
|
||||
|
||||
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
sl.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
|
||||
)
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/m01/sl/list").status_code == 403
|
||||
assert client.post("/api/m01/sl/outside", json={"items": []}).status_code == 403
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
|
||||
assert client.get("/api/m01/sl/list").json() == {"logics": []}
|
||||
Reference in New Issue
Block a user