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
This commit is contained in:
@@ -6,65 +6,29 @@
|
||||
* ========================================================================== */
|
||||
|
||||
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";
|
||||
import type { SlOutsideAnswer } from "./M01_SL_Eval";
|
||||
import type { SheetLogic, SlField, SlSource } from "./M01_SL_Model";
|
||||
|
||||
/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음)
|
||||
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸)
|
||||
* · 노임 · 자재 = 그 줄의 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값)
|
||||
* · 장비 = 기계경비 로직 부름 글(EasyCalc `machineElement` 의 element) + 넘길 입력값 → 한 시간 값
|
||||
* · 로직 = 기존 로직 결과(`/calc` · 돈 로직은 `field` 비목 · 기본 계) */
|
||||
export type SlOutside =
|
||||
| { id: string; kind: "표"; table: string }
|
||||
| { id: string; kind: "노임"; key: string }
|
||||
| { id: string; kind: "자재"; key: string }
|
||||
| {
|
||||
id: string;
|
||||
kind: "장비";
|
||||
element: string;
|
||||
inputs: Record<string, unknown>;
|
||||
field?: SlField;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "로직";
|
||||
key: string;
|
||||
inputs: Record<string, unknown>;
|
||||
field?: SlField;
|
||||
};
|
||||
export type SlField = "노무비" | "재료비" | "경비" | "계";
|
||||
/* 모양 = sub7 Model(SheetLogic · SlSource · SlField) · 답 = Eval(SlOutsideAnswer) · 아래는 Api 몫만.
|
||||
* 저장 때 서버가 바깥 값을 넣는 칸 = ① Model 규칙(알약 outside 참 · 상자 source · 없으면 표 · 로직 ref)
|
||||
* ② 저장 몸의 `outside: SlOutsideAt[]`(Item `itemOutsides` — 장비처럼 칸마다 비목이 다른 것) · 값은 늘 마스터에서. */
|
||||
|
||||
/** 바깥 값이 들어갈 자리 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
|
||||
export type { SlField };
|
||||
|
||||
/** 서버 `/outside` 물음 한 줄 = `{id, ...SlSource}` */
|
||||
export type SlQuery = { id: string } & SlSource;
|
||||
/** Item 이 쓰는 옛 이름 — `SlQuery` 와 같음(Model `SlOutside` 와 다른 것) */
|
||||
export type SlOutside = SlQuery;
|
||||
|
||||
/** 바깥 값 자리 하나 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
|
||||
export interface SlOutsideAt {
|
||||
item: SlOutside;
|
||||
item: SlQuery;
|
||||
sheet: string;
|
||||
cell: string;
|
||||
}
|
||||
|
||||
/** 바깥 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
|
||||
export interface SlOutsideValue {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
value?: number | null;
|
||||
rows?: (string | number | null)[][];
|
||||
name?: string;
|
||||
unit?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** 시트 로직 한 벌 = 통합문서 하나(상자 = 시트) + 바깥 값 자리 */
|
||||
export interface SlLogic {
|
||||
/** 새 로직 null · 저장하면 서버가 `SL-0001` 꼴로 줌 */
|
||||
key: string | null;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
book: Workbook;
|
||||
outside: SlOutsideAt[];
|
||||
/** 다른 창이 더하는 칸(상자 · 알약 · 선 …)은 그대로 저장 · 돌려줌 */
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
|
||||
/** 목록 한 줄 */
|
||||
export interface SlListItem {
|
||||
key: string;
|
||||
@@ -74,11 +38,11 @@ export interface SlListItem {
|
||||
saved: string;
|
||||
}
|
||||
|
||||
/** 저장 답 — `logic.book.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */
|
||||
/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */
|
||||
export interface SlSaved {
|
||||
key: string;
|
||||
logic: SlLogic;
|
||||
values: SlOutsideValue[];
|
||||
logic: SheetLogic;
|
||||
values: SlOutsideAnswer[];
|
||||
}
|
||||
|
||||
async function send<T>(path: string, body?: unknown): Promise<T> {
|
||||
@@ -103,12 +67,13 @@ export async function slList(): Promise<SlListItem[]> {
|
||||
return (await send<{ logics: SlListItem[] }>("/list")).logics;
|
||||
}
|
||||
|
||||
export async function slGet(key: string): Promise<SlLogic> {
|
||||
return (await send<{ logic: SlLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic;
|
||||
export async function slGet(key: string): Promise<SheetLogic> {
|
||||
return (await send<{ logic: SheetLogic }>(`/get?${new URLSearchParams({ key }).toString()}`))
|
||||
.logic;
|
||||
}
|
||||
|
||||
/** 저장 — 서버가 바깥 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) */
|
||||
export async function slSave(logic: SlLogic): Promise<SlSaved> {
|
||||
export async function slSave(logic: SheetLogic & { outside?: SlOutsideAt[] }): Promise<SlSaved> {
|
||||
return send<SlSaved>("/save", { logic });
|
||||
}
|
||||
|
||||
@@ -117,8 +82,8 @@ export async function slDelete(key: string): Promise<void> {
|
||||
}
|
||||
|
||||
/** 바깥 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 */
|
||||
export async function slOutside(items: SlOutside[]): Promise<SlOutsideValue[]> {
|
||||
return (await send<{ values: SlOutsideValue[] }>("/outside", { items })).values;
|
||||
export async function slOutside(items: SlQuery[]): Promise<SlOutsideAnswer[]> {
|
||||
return (await send<{ values: SlOutsideAnswer[] }>("/outside", { items })).values;
|
||||
}
|
||||
|
||||
/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
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/save {logic: SheetLogic} → {key, logic, values: SlOutsideAnswer[]}
|
||||
POST /api/m01/sl/delete {key}
|
||||
POST /api/m01/sl/outside {items: SlOutside[]} → {values: SlOutsideValue[]}
|
||||
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`)와
|
||||
@@ -35,7 +36,6 @@ 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):
|
||||
@@ -76,8 +76,8 @@ def listing() -> dict:
|
||||
out.append(
|
||||
{
|
||||
"key": path.stem,
|
||||
"이름": logic.get("이름") or "",
|
||||
"결과단위": logic.get("결과단위") or "",
|
||||
"이름": logic.get("name") or "",
|
||||
"결과단위": logic.get("resultUnit") or "",
|
||||
"saved": logic.get("saved") or "",
|
||||
}
|
||||
)
|
||||
@@ -95,6 +95,15 @@ def delete(key: str) -> dict:
|
||||
|
||||
|
||||
# ── 바깥 값 ────────────────────────────────────────────────────────────
|
||||
# 물음 한 줄 = `{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 {
|
||||
@@ -111,11 +120,21 @@ def _row(line: dict, names) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
"""노임 · 자재 한 줄 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로."""
|
||||
whole = store.loaded()[1]
|
||||
try:
|
||||
brief = whole.get(key)
|
||||
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))
|
||||
@@ -128,21 +147,17 @@ def _price(kind: str, key: str) -> dict:
|
||||
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,
|
||||
}
|
||||
out = {"ok": one["단가"] is not None, "value": one["단가"], "name": brief.get("이름")}
|
||||
return {**out, "unit": unit, **({"reason": one["까닭"]} if one.get("까닭") else {})}
|
||||
|
||||
|
||||
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 _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:
|
||||
@@ -167,7 +182,7 @@ def _table(key: str) -> dict:
|
||||
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,))]
|
||||
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 = []
|
||||
@@ -183,7 +198,8 @@ def _table(key: str) -> dict:
|
||||
|
||||
|
||||
def one_outside(item: dict) -> dict:
|
||||
kind, at = item.get("kind"), item.get("id") or ""
|
||||
"""물음 한 줄 `{id, ...SlSource}` → 답 `SlOutsideAnswer`."""
|
||||
kind = item.get("kind")
|
||||
try:
|
||||
if kind == "표":
|
||||
got = _table(str(item.get("table") or ""))
|
||||
@@ -195,14 +211,12 @@ def one_outside(item: dict) -> dict:
|
||||
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")
|
||||
)
|
||||
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": at, **got}
|
||||
return {"id": item.get("id") or "", **got}
|
||||
|
||||
|
||||
def outside(items: list[dict]) -> dict:
|
||||
@@ -210,6 +224,36 @@ def outside(items: list[dict]) -> dict:
|
||||
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
|
||||
@@ -227,45 +271,45 @@ def _letters(n: int) -> str:
|
||||
|
||||
|
||||
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:
|
||||
keep = {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {}
|
||||
if value is None and not keep:
|
||||
cells.pop(cell, None)
|
||||
return
|
||||
keep = {"서식": old["서식"]} if "서식" in old else {}
|
||||
cells[cell] = {"값": value, **keep}
|
||||
else:
|
||||
cells[cell] = {**({"값": value} if value is not None else {}), **keep}
|
||||
|
||||
|
||||
def _fill(book: dict, at: dict, got: dict) -> None:
|
||||
m = _CELL.match(str(at.get("cell") or "").upper())
|
||||
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"칸 주소 모양이 아님 「{at.get('cell')}」")
|
||||
sheet, col, row = at.get("sheet") or "", _col(m.group(1)), int(m.group(2))
|
||||
if "rows" in got:
|
||||
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, value in enumerate(line):
|
||||
_put(book, sheet, f"{_letters(col + dx)}{row + dy}", value)
|
||||
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:
|
||||
if not isinstance(logic.get("book"), dict):
|
||||
raise store.StoreError(400, "통합문서(book)가 없음")
|
||||
if not str(logic.get("이름") or "").strip():
|
||||
"""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 = 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))
|
||||
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:
|
||||
@@ -273,7 +317,7 @@ def save(logic: dict) -> dict:
|
||||
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}
|
||||
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")
|
||||
|
||||
@@ -89,26 +89,59 @@ def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
|
||||
|
||||
|
||||
def _logic(labor: str, key: str | None = None) -> dict:
|
||||
"""노임 바깥 값(A1) × 2 = B1 · 표 바깥 값은 둘째 시트 A1 부터 · 브라우저 계산값은 엉터리."""
|
||||
"""Model SheetLogic — 노무 상자(source 노임 · A1) × 2 = B1 · 표 상자(ref 로 짐작 · A1 부터)
|
||||
· 출처 없는 바깥 알약은 뺌 · 몸의 outside 목록(Item 꼴)은 C2 에 · 브라우저 계산값은 엉터리."""
|
||||
return {
|
||||
"key": key,
|
||||
"이름": "견본 시트 로직",
|
||||
"결과단위": "원/㎡",
|
||||
"book": {
|
||||
"name": "견본 시트 로직",
|
||||
"resultUnit": "원/㎡",
|
||||
"workbook": {
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"서식": [{}],
|
||||
"시트": [
|
||||
{"id": "s1", "이름": "계산", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}},
|
||||
{"id": "s1", "이름": "노무", "칸": {"A1": {"값": 1}, "B1": {"식": "A1*2"}}},
|
||||
{"id": "s2", "이름": "표", "칸": {}},
|
||||
],
|
||||
"계산값": {"s1": {"B1": {"수": "999"}}},
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"kind": "item",
|
||||
"name": "노무 석공",
|
||||
"sheetId": "s1",
|
||||
"stage": 3,
|
||||
"pillsIn": [{"name": "노임", "cell": "A1", "outside": True}],
|
||||
"pillsOut": [{"name": "금액", "cell": "B1"}],
|
||||
"source": {"kind": "노임", "key": labor},
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"kind": "table",
|
||||
"name": "견본 표",
|
||||
"sheetId": "s2",
|
||||
"stage": 1,
|
||||
"pillsIn": [],
|
||||
"pillsOut": [{"name": "표", "cell": "A1", "outside": True}],
|
||||
"ref": SAMPLE,
|
||||
},
|
||||
{
|
||||
"id": "n3",
|
||||
"kind": "calc",
|
||||
"name": "계산",
|
||||
"sheetId": "s1",
|
||||
"stage": 2,
|
||||
"pillsIn": [],
|
||||
"pillsOut": [{"name": "x", "cell": "C1", "outside": True}],
|
||||
},
|
||||
], # fmt: skip
|
||||
"edges": [
|
||||
{"id": "e1", "from": {"node": "n2", "pill": "표"}, "to": {"node": "n1", "pill": "노임"}}
|
||||
],
|
||||
"outside": [
|
||||
{"item": {"id": "a", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "A1"},
|
||||
{"item": {"id": "b", "kind": "표", "table": SAMPLE}, "sheet": "s2", "cell": "A1"},
|
||||
{"item": {"id": "n1:노무비", "kind": "노임", "key": labor}, "sheet": "s1", "cell": "C2"}
|
||||
],
|
||||
"상자": [{"id": "x", "종류": "계산"}], # 다른 창 칸 — 그대로 저장 · 돌려줌
|
||||
}
|
||||
|
||||
|
||||
@@ -119,9 +152,11 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient,
|
||||
|
||||
saved = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||||
assert saved["key"] == "SL-0001"
|
||||
book = saved["logic"]["book"]
|
||||
assert book["시트"][0]["칸"]["A1"]["값"] == price # 바깥 값을 마스터에서 다시 넣음
|
||||
book = saved["logic"]["workbook"]
|
||||
cells = book["시트"][0]["칸"]
|
||||
assert cells["A1"]["값"] == price == cells["C2"]["값"] # 바깥 값을 마스터에서 다시 넣음(①②)
|
||||
assert Decimal(book["계산값"]["s1"]["B1"]["수"]) == 2 * price # 서버 다시 셈(999 는 버림)
|
||||
assert "C1" not in cells # 출처 없는 바깥 알약은 안 넣음
|
||||
table = book["시트"][1]["칸"]
|
||||
assert [table[c]["값"] for c in ("A1", "B1", "C1", "D1")] == [
|
||||
"돌",
|
||||
@@ -132,12 +167,15 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient,
|
||||
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"]
|
||||
assert saved["logic"]["edges"] == _logic(labor)["edges"]
|
||||
answers = {v["id"]: v for v in saved["values"]}
|
||||
assert set(answers) == {"n1!A1", "n2!A1", "n1:노무비"} and all(
|
||||
v["ok"] for v in answers.values()
|
||||
)
|
||||
|
||||
second = _post(client, "/api/m01/sl/save", {"logic": _logic(labor)})
|
||||
assert second["key"] == "SL-0002"
|
||||
renamed = {**saved["logic"], "이름": "고친 이름"}
|
||||
renamed = {**saved["logic"], "name": "고친 이름"}
|
||||
assert _post(client, "/api/m01/sl/save", {"logic": renamed})["key"] == "SL-0001"
|
||||
|
||||
listed = client.get("/api/m01/sl/list").json()["logics"]
|
||||
@@ -146,13 +184,13 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient,
|
||||
("SL-0002", "견본 시트 로직"),
|
||||
]
|
||||
got = client.get("/api/m01/sl/get", params={"key": "SL-0001"}).json()["logic"]
|
||||
assert got["book"]["계산값"] == book["계산값"]
|
||||
assert got["workbook"]["계산값"] == 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)
|
||||
_post(client, "/api/m01/sl/save", {"logic": {**_logic(labor), "name": " "}}, 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 # 마스터 사본 그대로
|
||||
@@ -161,32 +199,18 @@ def test_저장_열기_목록_지우기_서버가_다시_셈(client: TestClient,
|
||||
|
||||
def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None:
|
||||
labor, price = _labor()
|
||||
made = _post(
|
||||
client,
|
||||
"/api/m01/logic/new",
|
||||
{
|
||||
"logic": {
|
||||
"원문번호": "",
|
||||
"이름": "견본 노무",
|
||||
"결과단위": "원/㎡",
|
||||
"출처": "자체",
|
||||
"입력": [{"이름": "품"}],
|
||||
"중간": [],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": labor,
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "품",
|
||||
"비목": "노무비",
|
||||
}
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
line = {
|
||||
"종류": "인력",
|
||||
"요소": labor,
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "품",
|
||||
"비목": "노무비",
|
||||
}
|
||||
row = {"원문번호": "", "이름": "견본 노무", "결과단위": "원/㎡", "출처": "자체",
|
||||
"입력": [{"이름": "품"}], "중간": [], "호표": [line], "덧줄": [],
|
||||
"끝수": None} # fmt: skip
|
||||
made = _post(client, "/api/m01/logic/new", {"logic": row})
|
||||
items = [
|
||||
{"id": "t", "kind": "표", "table": SAMPLE},
|
||||
{"id": "l", "kind": "노임", "key": labor},
|
||||
@@ -202,7 +226,8 @@ def test_바깥_값_표_노임_로직_틀린_종류(client: TestClient) -> None:
|
||||
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]
|
||||
for k in ("w", "x", "z"):
|
||||
assert got[k]["ok"] is False and got[k]["reason"], got[k]
|
||||
|
||||
|
||||
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user