feat(M01): 흐름 로직 입력 약속 하나 — 저장 때 「입력」 은 흐름의 설계 때 정할 것으로 다시 씀 · 지우는 것은 계산 칸만 · 시험판 길 지움
- 흐름으로 저장 = 계산 칸(중간 · 호표 · 결과 · 덧줄)만 지우고 「입력」(이름 · 설명 · 단위 · 고르기 · 범위)은 흐름에서 같은 꼴로 · 조합 · 구조물 · 미리 보기 · 다시 적용은 「입력」 을 그대로 읽음(따로 갈래 없음) - 엔진 갈래: 안 준 입력 = 멈춤 · 빈 값으로 준 입력 · 단가 없는 품목 · 표에 없는 조건 = 그 값만 비움(알림) · 결과 꼴 = 돈 출력 줄 · 값들 · 알림 - 프로젝트에서 고른 자재 = 흐름 재료 자리 이름으로(고름 · 다시 적용 때 살림) - 시험판 길 지움: /list · /get · /save · /delete · /user-elements(서버) · cfList · cfGet · cfSave · cfDelete · cfUserElement*(Api) · 빈 _data 폴더 - 시험: 조합에 담은 로직을 흐름으로 바꿔도 입력 목록 · 로직별 결과 · 비목 합 · 단가 묶음 셈이 같음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -187,11 +187,7 @@ def get_logics(
|
||||
|
||||
@router.get("/logic")
|
||||
def get_logic(key: str) -> dict:
|
||||
"""흐름 로직은 「입력」 칸 없음 — 보기용 「설계 때 정할 것」 을 입력 꼴로(파일 그대로)."""
|
||||
got = _call(store.logic, key)
|
||||
if isinstance(got["logic"].get("흐름"), dict):
|
||||
got["logic"] = {**got["logic"], "입력": _num(store.cm.mf.inputs_of(got["logic"]))}
|
||||
return got
|
||||
return _call(store.logic, key)
|
||||
|
||||
|
||||
@router.get("/logic/auto")
|
||||
|
||||
@@ -185,7 +185,11 @@ def run(flow: dict, key: str, inputs: dict, strict: bool = False) -> dict:
|
||||
given = {k: _value(v, words) for k, v in (inputs or {}).items()}
|
||||
flow = {**flow, "key": key}
|
||||
values = dc._design_values(flow, given)
|
||||
blank = [d for d in dc.design_inputs(flow) if strict and given.get(d["port"]) is None]
|
||||
designs = dc.design_inputs(flow) if strict else []
|
||||
lack = [d["port"] for d in designs if d["port"] not in given]
|
||||
if lack: # 안 준 입력 — 엔진 `_inputs` 와 같이 멈춤(빈 값으로 준 입력은 아래 「빈 입력」)
|
||||
raise store.cm.mf.FormulaError(f"「{key}」 입력 「{lack[0]}」 없음")
|
||||
blank = [d for d in designs if given.get(d["port"]) is None]
|
||||
for d in blank:
|
||||
values.setdefault(d["box"], {})[d["port"]] = None
|
||||
got = dc.run(flow, values, load, flow.get("userElements") or [], [key])
|
||||
@@ -200,14 +204,22 @@ def _engine(master, ref: str, row: dict, given: dict) -> dict:
|
||||
|
||||
단가 · 값 · 표 = 부른 쪽이 넘긴 master(프로젝트 사본이면 그 단가 · `M02_DF_Values.SOURCE`).
|
||||
"""
|
||||
picks = (
|
||||
store.cm.mf.mp.CHOSEN.get() or {}
|
||||
) # 고른 자재 — 흐름은 재료 자리 이름으로(호표 차례 아님)
|
||||
slots = {d["port"] for d in dc.design_inputs(row[FLOW]) if d.get("kind") == "재료 자리"}
|
||||
given = {**given, **{k: v for k, v in picks.items() if k in slots}}
|
||||
token = dv.SOURCE.set(master)
|
||||
try:
|
||||
got = run(row[FLOW], ref, given, strict=True)
|
||||
finally:
|
||||
dv.SOURCE.reset(token)
|
||||
if got["errors"]:
|
||||
raise store.cm.mf.FormulaError(f"「{ref}」 흐름 셈 — {' · '.join(got['errors'])}")
|
||||
cost, notes = got["cost"], got.get("notes") or []
|
||||
# 빈 값(빈 입력 · 단가 없는 품목 · 표에 없는 조건 → #N/A)을 쓴 칸은 엔진처럼 그 값만 비움 — 알림
|
||||
empty = [e for e in got["errors"] if "#N/A" in e or "단가 없는 품목" in e]
|
||||
stop = [e for e in got["errors"] if e not in empty]
|
||||
if stop:
|
||||
raise store.cm.mf.FormulaError(f"「{ref}」 흐름 셈 — {' · '.join(stop)}")
|
||||
cost, notes = got["cost"], [*(got.get("notes") or []), *empty]
|
||||
outs = [b for b in row[FLOW].get("boxes") or [] if b.get("kind") == "output"]
|
||||
extra = {"중간": {}, "값들": cost["값들"], **({"알림": notes} if notes else {})}
|
||||
if outs and all((b.get("output") or {}).get("cost") in (None, "값") for b in outs):
|
||||
@@ -216,6 +228,7 @@ def _engine(master, ref: str, row: dict, given: dict) -> dict:
|
||||
lines = [] # 옛 「줄」 꼴 — 돈 출력 상자마다 한 줄(수량 1 · 단가 = 금액)
|
||||
for b in outs:
|
||||
kind, value = (b.get("output") or {}).get("cost"), got["outputs"].get(b["id"])
|
||||
value = None if value == dc.NA_VALUE else value
|
||||
if kind in dc.COSTS:
|
||||
unit = (b.get("output") or {}).get("unit") or ""
|
||||
lines.append({"이름": b.get("name") or "", "단위": unit,
|
||||
@@ -346,10 +359,20 @@ def _redo(row: dict, key: str) -> list[str]:
|
||||
|
||||
|
||||
def _bare(row: dict) -> dict:
|
||||
"""흐름 로직 줄 — 옛 식 칸(입력 · 중간 · 호표 · 결과 · 덧줄)은 지움(흐름이 정본)
|
||||
"""흐름 로직 줄 — 계산 칸(중간 · 호표 · 결과 · 덧줄)은 지움(흐름이 정본) · 「입력」 은 흐름의
|
||||
「설계 때 정할 것」 으로 같은 꼴로 다시 씀(로직 입력 약속 하나 · 설명은 이름이 같으면 살림)
|
||||
· 끝수 · 시험입력 · 메타 칸은 둠 · 출처 없으면 「자체」."""
|
||||
kept = {k: v for k, v in row.items() if k not in store.cm.mf.OLD_FIELDS}
|
||||
return {"끝수": None, **kept, "출처": row.get("출처") or "자체"}
|
||||
kept = {k: v for k, v in row.items() if k not in store.cm.mf.CALC_FIELDS}
|
||||
said = {s.get("이름"): s.get("설명") for s in row.get("입력") or [] if s.get("설명")}
|
||||
inputs = []
|
||||
for spec in store.cm.mf.inputs_of(row):
|
||||
one = {
|
||||
"이름": spec["이름"],
|
||||
**({"설명": said[spec["이름"]]} if spec["이름"] in said else {}),
|
||||
}
|
||||
one |= {k: spec[k] for k in ("단위", "고르기", "범위") if spec.get(k) is not None}
|
||||
inputs.append(one)
|
||||
return {"끝수": None, **kept, "입력": inputs, "출처": row.get("출처") or "자체"}
|
||||
|
||||
|
||||
def save(batch: list[dict]) -> dict:
|
||||
@@ -360,6 +383,7 @@ def save(batch: list[dict]) -> dict:
|
||||
row = ch.get("row")
|
||||
if not isinstance(row, dict) or FLOW not in row:
|
||||
continue
|
||||
check(row[FLOW]) # 꼴부터 — 입력 약속을 흐름에서 다시 쓰기 전에
|
||||
new = ch.get("op") == "add" or (ch.get("op") == "edit" and not ch.get("key"))
|
||||
if new:
|
||||
if part.get("file") != make.OWN:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Api.ts
|
||||
* 단가산출 흐름도 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 받아 화면과
|
||||
* 같은 차례 · 같은 규칙으로 셈하고 받는 칸에 값만 되돌려 넣음) · 지우기 · 입력 값 한 번에 받기 · 사용자 요소 ·
|
||||
* 자재 찾기 · 다른 로직 값 · 설계 때 정할 것 · 단위 · 금액 표준 표. 모양 = sub7 Model · 아래는 Api 몫만.
|
||||
* 단가산출 흐름도 Api — 입력 값 한 번에 받기 · 자재 찾기 · 다른 로직 값 · 설계 때 정할 것 ·
|
||||
* 단위 · 금액 표준 표(흐름 저장은 로직 줄 「흐름」 칸 · M01 `/save`). 모양 = sub7 Model · 아래는 Api 몫만.
|
||||
* 서버 = `M02_DF_Router.py`(`/api/m02/df/*`) · 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md`
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -90,22 +89,6 @@ export interface CfComputed {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */
|
||||
export interface CfListItem {
|
||||
key: string;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
saved: string;
|
||||
sample: boolean;
|
||||
}
|
||||
|
||||
/** 저장 답 — `logic` 은 받는 칸에 서버 값이 들어간 다이어그램 */
|
||||
export interface CfSaved {
|
||||
key: string;
|
||||
logic: CfLogic;
|
||||
values: CfComputed;
|
||||
}
|
||||
|
||||
/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */
|
||||
export class CfError extends Error {
|
||||
constructor(
|
||||
@@ -133,25 +116,6 @@ async function send<T>(path: string, body?: unknown, method?: string): Promise<T
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 내 다이어그램(저장 차례) + 견본 */
|
||||
export async function cfList(): Promise<CfListItem[]> {
|
||||
return (await send<{ logics: CfListItem[] }>("/list")).logics;
|
||||
}
|
||||
|
||||
export async function cfGet(key: string): Promise<CfLogic> {
|
||||
return (await send<{ logic: CfLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic;
|
||||
}
|
||||
|
||||
/** 저장 — 서버가 입력 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) ·
|
||||
* 견본을 저장하면 새 번호 */
|
||||
export async function cfSave(logic: CfLogic): Promise<CfSaved> {
|
||||
return send<CfSaved>("/save", { logic });
|
||||
}
|
||||
|
||||
export async function cfDelete(key: string): Promise<void> {
|
||||
await send<{ key: string }>("/delete", { key });
|
||||
}
|
||||
|
||||
/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 ·
|
||||
* userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */
|
||||
export async function cfValues(
|
||||
@@ -161,20 +125,6 @@ export async function cfValues(
|
||||
return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values;
|
||||
}
|
||||
|
||||
/** 사용자 요소 목록 */
|
||||
export async function cfUserElements(): Promise<CfUserElement[]> {
|
||||
return (await send<{ elements: CfUserElement[] }>("/user-elements")).elements;
|
||||
}
|
||||
|
||||
/** 사용자 요소 만들기(공용 목록 `_data`) — id(`UE-0001`)는 서버가 줌 · 마스터에 안 씀 */
|
||||
export async function cfUserElementAdd(element: Omit<CfUserElement, "id">): Promise<CfUserElement> {
|
||||
return (await send<{ element: CfUserElement }>("/user-elements", { op: "add", element })).element;
|
||||
}
|
||||
|
||||
export async function cfUserElementDelete(id: string): Promise<void> {
|
||||
await send<{ id: string }>("/user-elements", { op: "delete", id });
|
||||
}
|
||||
|
||||
const PLAIN: Record<number, string> = {
|
||||
0: "서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요",
|
||||
400: "넣은 값의 모양이 맞지 않음",
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
"""단가산출 다이어그램 시험 길(PLAN 35-7) — 목록 · 열기 · 저장 · 지우기 · 입력 값 · 사용자 요소.
|
||||
"""단가산출 흐름도 서버 길 — 입력 값 · 다른 로직 값 · 설계 때 정할 것 · 자재 찾기 · 표준 표.
|
||||
|
||||
GET /api/m02/df/list → {logics: [{key, name, resultUnit, saved, sample}]}
|
||||
GET /api/m02/df/get?key=DF-0001 → {logic, sample}
|
||||
POST /api/m02/df/save {logic} → {key, logic, values: {ports, outputs, cost, errors}}
|
||||
POST /api/m02/df/delete {key}
|
||||
POST /api/m02/df/values {items, userElements?} → {values: CfAnswer[]}
|
||||
GET /api/m02/df/user-elements → {elements}
|
||||
POST /api/m02/df/user-elements {op: add | delete, element? | id?}
|
||||
POST /api/m02/df/values {items, userElements?} → {values: CfAnswer[]}
|
||||
POST /api/m02/df/logic-value {key, designs} → {key, outputs, cost, errors}
|
||||
GET /api/m02/df/design-inputs?key= → {key, designs: CfDesign[]}
|
||||
GET /api/m02/df/materials?q=&sub=&detail=&page=&size= → {total, page, size, items}
|
||||
GET · PUT · DELETE /api/m02/df/standards → {unit, money, saved}
|
||||
|
||||
모양 = sub7 Model `M02_DF_Model.ts`
|
||||
(CfLogic = key · name · resultUnit · boxes · links · userElements).
|
||||
알약 이름(브레인 정함): 입력 상자가 내보내는 알약 = 요소 name · 표 「표」 · 변수 name /
|
||||
함수 상자 받는 알약 = inputs[].port · 내보내는 알약 = output.name / 출력 상자 받는 알약 = 「값」.
|
||||
저장 = 입력 값(노임 · 재료 · 유가전력 · 기계 · 환율 · 요율 · 소요량 / 계수 표 · 사용자 요소)을
|
||||
마스터(사용자 요소는 다이어그램 안 · `_data`)에서 다시 받아 → 함수 상자 시트들을 한 통합문서로
|
||||
묶어(받는 칸에 값 · 함수 → 함수는 시트 사이 참조) 우리 스프레드시트 엔진(Node ·
|
||||
`common_util_spreadsheet`)으로 셈 → 받는 칸에 값만 되돌려 넣어 `_data` 에 씀 · 브라우저 값은
|
||||
안 받아 적음. 단가는 기존 `/calc` 와 같은 셈.
|
||||
자리 = 이 폴더 `_data/`(git 밖) · 견본 `_sample/`(git 안 · 읽기만 · 저장하면 새 번호) ·
|
||||
키 `DF-0001` · 사용자 요소 `UE-0001` · 마스터 파일은 안 씀. 권한 = 시스템 관리자(`main.py` 한 줄).
|
||||
옛 시험판(SheetLogic) 서버를 옮겨 고쳐 씀 — 그 폴더를 import 하지 않음.
|
||||
정함 = `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`.
|
||||
모양 = sub7 Model `M02_DF_Model.ts` CfLogic.
|
||||
흐름 저장 = 로직 줄 「흐름」 칸(M01 `/save` · `M01_MasterData_Store_Flow`) — 이 길은 쓰지 않음.
|
||||
다른 로직 = 마스터 로직 키(흐름이면 흐름 셈 · 아니면 지금 계산 · `M01_MasterData_Store_Flow.load`).
|
||||
사용자 요소 = 흐름 안 `userElements` 만. 권한 = 시스템 관리자(`main.py` 한 줄).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -41,30 +28,10 @@ from M01_MasterData.M01_MasterData_Router import _num
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
|
||||
|
||||
cm = store.cm
|
||||
router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow (시험)"])
|
||||
router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow"])
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
DATA = HERE / "_data" # 시험은 임시 폴더로 바꿈
|
||||
SAMPLE = HERE / "_sample"
|
||||
USERS = "_user_elements.json" # 사용자 요소 공용 목록 — `_data` 안
|
||||
_KEY = re.compile(r"^DF-(\d{4,})$")
|
||||
_UKEY = re.compile(r"^UE-(\d{4,})$")
|
||||
_SAMPLE_KEY = re.compile(r"^[\w가-힣·() -]+$")
|
||||
_CELL = re.compile(r"^([A-Z]+)(\d+)$")
|
||||
RANGE_LOW, RANGE_HIGH = "·아래", "·위"
|
||||
FIELDS = (*cm.mf.COST_ITEMS, "계")
|
||||
GROUPS = ("인력", "재료", "유가전력", "기계", "소요량", "계수", "환율", "요율")
|
||||
TABLE_PORT, OUTPUT_PORT = "표", "값"
|
||||
COSTS = ("노무비", "재료비", "경비")
|
||||
|
||||
|
||||
class LogicBody(BaseModel):
|
||||
logic: dict[str, Any]
|
||||
|
||||
|
||||
class KeyBody(BaseModel):
|
||||
key: str
|
||||
STANDARD = HERE / "_standard" # 단위 · 금액 표준 표 — 고치면 이 파일에 바로(git 이 따라감)
|
||||
|
||||
|
||||
class ValuesBody(BaseModel):
|
||||
@@ -72,158 +39,30 @@ class ValuesBody(BaseModel):
|
||||
userElements: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
class UserBody(BaseModel):
|
||||
op: str
|
||||
element: dict[str, Any] | None = None
|
||||
id: str | None = None
|
||||
class LogicValueBody(BaseModel):
|
||||
key: str
|
||||
designs: dict[str, Any] = {}
|
||||
|
||||
|
||||
# ── 자리 ──────────────────────────────────────────────────────────────
|
||||
def _write(path: Path, data: Any) -> None:
|
||||
DATA.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f".{path.name}.tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
|
||||
os.replace(tmp, path)
|
||||
class StandardsBody(BaseModel):
|
||||
unit: dict[str, Any]
|
||||
money: dict[str, Any]
|
||||
|
||||
|
||||
def _mine(key: str) -> Path:
|
||||
if not _KEY.match(key or ""):
|
||||
raise store.StoreError(400, f"다이어그램 번호 모양이 아님 「{key}」")
|
||||
return DATA / f"{key}.json"
|
||||
|
||||
|
||||
def _sample(key: str) -> Path | None:
|
||||
if not key or _KEY.match(key) or not _SAMPLE_KEY.match(key):
|
||||
return None
|
||||
path = SAMPLE / f"{key}.json"
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _read(key: str) -> tuple[dict, bool]:
|
||||
"""(다이어그램, 견본인가) — 내 것은 `_data` · 견본은 `_sample`."""
|
||||
path = _sample(key)
|
||||
if path is not None:
|
||||
return json.loads(path.read_text(encoding="utf-8")), True
|
||||
path = _mine(key)
|
||||
if not path.is_file():
|
||||
raise store.StoreError(404, f"없는 다이어그램 「{key}」")
|
||||
return json.loads(path.read_text(encoding="utf-8")), False
|
||||
|
||||
|
||||
def listing() -> dict:
|
||||
out = []
|
||||
for path, sample in [
|
||||
*((p, False) for p in sorted(DATA.glob("DF-*.json"))),
|
||||
*((p, True) for p in sorted(SAMPLE.glob("*.json"))),
|
||||
]:
|
||||
logic = json.loads(path.read_text(encoding="utf-8"))
|
||||
out.append(
|
||||
{
|
||||
"key": path.stem,
|
||||
"name": logic.get("name") or path.stem,
|
||||
"resultUnit": logic.get("resultUnit") or "",
|
||||
"saved": logic.get("saved") or "",
|
||||
"sample": sample,
|
||||
}
|
||||
)
|
||||
return {"logics": out}
|
||||
|
||||
|
||||
def get(key: str) -> dict:
|
||||
logic, sample = _read(key)
|
||||
return {"logic": {**logic, "key": key}, "sample": sample}
|
||||
|
||||
|
||||
def delete(key: str) -> dict:
|
||||
if _sample(key) is not None:
|
||||
raise store.StoreError(403, "견본은 지울 수 없음")
|
||||
_read(key)
|
||||
_mine(key).unlink()
|
||||
return {"key": key}
|
||||
|
||||
|
||||
# ── 사용자 요소(Model CfUserElement — id · group · name · spec · unit · value) ─────
|
||||
def _users() -> list[dict]:
|
||||
path = DATA / USERS
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else []
|
||||
|
||||
|
||||
def user_elements() -> dict:
|
||||
return {"elements": _users()}
|
||||
|
||||
|
||||
def user_edit(op: str, element: dict | None, uid: str | None) -> dict:
|
||||
rows = _users()
|
||||
if op == "delete":
|
||||
if not any(r["id"] == uid for r in rows):
|
||||
raise store.StoreError(404, f"없는 사용자 요소 「{uid}」")
|
||||
_write(DATA / USERS, [r for r in rows if r["id"] != uid])
|
||||
return {"id": uid}
|
||||
if op != "add" or not isinstance(element, dict):
|
||||
raise store.StoreError(400, "op 는 add(element) 또는 delete(id)")
|
||||
name = str(element.get("name") or "").strip()
|
||||
if not name:
|
||||
raise store.StoreError(400, "이름을 넣어 주세요")
|
||||
group = element.get("group") or "재료"
|
||||
if group not in GROUPS:
|
||||
raise store.StoreError(400, f"모르는 그룹 「{group}」")
|
||||
value = element.get("value")
|
||||
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
||||
raise store.StoreError(400, "값은 수 또는 빈 값")
|
||||
used = [int(m.group(1)) for r in rows if (m := _UKEY.match(r["id"]))]
|
||||
one = {
|
||||
"id": f"UE-{max(used, default=0) + 1:04d}",
|
||||
"group": group,
|
||||
"name": name,
|
||||
"spec": str(element.get("spec") or ""),
|
||||
"unit": str(element.get("unit") or ""),
|
||||
"value": value,
|
||||
}
|
||||
_write(DATA / USERS, [*rows, one])
|
||||
return {"element": one}
|
||||
|
||||
|
||||
# ── 저장 · 계산(`M02_DF_Compute`) ─────────────────────────────────────
|
||||
# ── 다른 로직(`M02_DF_Compute`) ────────────────────────────────────────
|
||||
def _load(key: str) -> dict:
|
||||
"""다른 로직 입력이 부르는 로직 — 내 것(`_data`) · 견본(`_sample`) · 마스터 키(GX · GF …)는
|
||||
M01 계산 길(흐름이면 흐름 · 옛 로직이면 옛 셈 · 정함 11)."""
|
||||
if not _KEY.match(key or "") and _sample(key) is None:
|
||||
return sflow.load(key)
|
||||
logic, _ = _read(key)
|
||||
return {**logic, "key": key}
|
||||
|
||||
|
||||
def _local(logic: dict) -> list[dict]:
|
||||
return [*(logic.get("userElements") or []), *_users()]
|
||||
|
||||
|
||||
def save(logic: dict) -> dict:
|
||||
"""저장 — 입력 값 다시 받기 → 함수 상자 시트를 Node 로 셈 → 받는 칸에 값만 되돌려 `_data`."""
|
||||
if not str(logic.get("name") or "").strip():
|
||||
raise store.StoreError(400, "이름을 넣어 주세요")
|
||||
if not isinstance(logic.get("boxes"), list) or not isinstance(logic.get("links"), list):
|
||||
raise store.StoreError(400, "상자(boxes) · 선(links) 이 없음")
|
||||
key = logic.get("key") or None
|
||||
if key and _sample(key) is not None:
|
||||
key = None # 견본은 그대로 두고 새 번호로
|
||||
elif key:
|
||||
_read(key) # 있는 것만 고침 — 없으면 404
|
||||
got = dc.run(logic, {}, _load, _local(logic), [key or ""])
|
||||
if not key:
|
||||
used = [int(m.group(1)) for p in DATA.glob("DF-*.json") if (m := _KEY.match(p.stem))]
|
||||
key = f"DF-{max(used, default=0) + 1:04d}"
|
||||
done = {**got.pop("filled"), "key": key, "saved": datetime.now().isoformat(timespec="seconds")}
|
||||
_write(DATA / f"{key}.json", _num(done))
|
||||
return {"key": key, "logic": done, "values": got}
|
||||
"""다른 로직 입력이 부르는 마스터 로직 — 흐름이면 흐름 · 아니면 지금 계산(정함 11)."""
|
||||
return sflow.load(key)
|
||||
|
||||
|
||||
def logic_value(key: str, designs: dict) -> dict:
|
||||
"""다른 로직 값 — 저장 로직 · 견본을 받은 설계 값(없으면 예시값)으로 셈 · 출력 이름 → 값."""
|
||||
"""다른 로직 값 — 받은 설계 값(없으면 예시값)으로 셈 · 출력 이름 → 값."""
|
||||
other = _load(key)
|
||||
if callable(other.get("calc")): # 옛 마스터 로직 — 노무비 · 재료비 · 경비 · 계
|
||||
if callable(other.get("calc")): # 흐름 없는 로직 — 노무비 · 재료비 · 경비 · 계
|
||||
got = other["calc"](designs)
|
||||
return {"key": key, "outputs": got["outputs"], "cost": {}, "errors": got["errors"]}
|
||||
got = dc.run(other, dc._design_values(other, designs), _load, _local(other), [key])
|
||||
local = other.get("userElements") or []
|
||||
got = dc.run(other, dc._design_values(other, designs), _load, local, [key])
|
||||
names = {b["id"]: b.get("name") for b in other.get("boxes") or []}
|
||||
outputs = {names[i]: v for i, v in got["outputs"].items() if i in names}
|
||||
return {"key": key, "outputs": outputs, "cost": got["cost"], "errors": got["errors"]}
|
||||
@@ -237,83 +76,7 @@ def design_inputs(key: str) -> dict:
|
||||
return {"key": key, "designs": dc.design_inputs(other)}
|
||||
|
||||
|
||||
# ── 길 ────────────────────────────────────────────────────────────────
|
||||
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: LogicBody) -> dict:
|
||||
return _call(save, body.logic)
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
def post_delete(body: KeyBody) -> dict:
|
||||
return _call(delete, body.key)
|
||||
|
||||
|
||||
@router.post("/values")
|
||||
def post_values(body: ValuesBody) -> dict:
|
||||
return _call(dv.values, body.items, [*body.userElements, *_users()])
|
||||
|
||||
|
||||
@router.get("/user-elements")
|
||||
def get_user_elements() -> dict:
|
||||
return _call(user_elements)
|
||||
|
||||
|
||||
@router.post("/user-elements")
|
||||
def post_user_elements(body: UserBody) -> dict:
|
||||
return _call(user_edit, body.op, body.element, body.id)
|
||||
|
||||
|
||||
# ── 자재 찾기 · 표준 표(정함 5 · 11) ─────────────────────────────────────
|
||||
STANDARD = HERE / "_standard"
|
||||
|
||||
|
||||
class LogicValueBody(BaseModel):
|
||||
key: str
|
||||
designs: dict[str, Any] = {}
|
||||
|
||||
|
||||
@router.post("/logic-value")
|
||||
def post_logic_value(body: LogicValueBody) -> dict:
|
||||
return _call(logic_value, body.key, body.designs)
|
||||
|
||||
|
||||
@router.get("/design-inputs")
|
||||
def get_design_inputs(key: str) -> dict:
|
||||
return _call(design_inputs, key)
|
||||
|
||||
|
||||
class StandardsBody(BaseModel):
|
||||
unit: dict[str, Any]
|
||||
money: dict[str, Any]
|
||||
|
||||
|
||||
@router.get("/materials")
|
||||
def get_materials(q: str = "", sub: str = "", detail: str = "", page: int = 1, size: int = 30):
|
||||
return _call(dv.materials, q, sub, detail, page, size)
|
||||
|
||||
|
||||
@router.get("/standards")
|
||||
def get_standards() -> dict:
|
||||
return _call(dv.standards, STANDARD)
|
||||
|
||||
|
||||
# ── 표준 표 ────────────────────────────────────────────────────────────
|
||||
def _standards_put(body: dict) -> dict:
|
||||
"""고친 표준 표 = 본 프로그램 값 — git 이 따라가는 `_standard/` 파일에 바로(시스템 관리자)."""
|
||||
dv.standards_check(body)
|
||||
@@ -330,6 +93,39 @@ def _standards_reset() -> dict:
|
||||
return dv.standards(STANDARD)
|
||||
|
||||
|
||||
# ── 길 ────────────────────────────────────────────────────────────────
|
||||
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.post("/values")
|
||||
def post_values(body: ValuesBody) -> dict:
|
||||
return _call(dv.values, body.items, body.userElements)
|
||||
|
||||
|
||||
@router.post("/logic-value")
|
||||
def post_logic_value(body: LogicValueBody) -> dict:
|
||||
return _call(logic_value, body.key, body.designs)
|
||||
|
||||
|
||||
@router.get("/design-inputs")
|
||||
def get_design_inputs(key: str) -> dict:
|
||||
return _call(design_inputs, key)
|
||||
|
||||
|
||||
@router.get("/materials")
|
||||
def get_materials(q: str = "", sub: str = "", detail: str = "", page: int = 1, size: int = 30):
|
||||
return _call(dv.materials, q, sub, detail, page, size)
|
||||
|
||||
|
||||
@router.get("/standards")
|
||||
def get_standards() -> dict:
|
||||
return _call(dv.standards, STANDARD)
|
||||
|
||||
|
||||
@router.put("/standards")
|
||||
def put_standards(body: StandardsBody) -> dict:
|
||||
return _call(_standards_put, body.model_dump())
|
||||
|
||||
@@ -211,7 +211,7 @@ def write_file(
|
||||
def _kept_values(values: Any, labor: dict[str, Any]) -> dict[str, Any]:
|
||||
"""같은 로직 · 같은 입력 이름만 살림."""
|
||||
names = {
|
||||
str(row.get("키")): {spec["이름"] for spec in prices.mf.inputs_of(row)}
|
||||
str(row.get("키")): {spec["이름"] for spec in row.get("입력", [])}
|
||||
for row in labor.get("로직", [])
|
||||
}
|
||||
out: dict[str, Any] = {}
|
||||
@@ -225,9 +225,18 @@ def _kept_values(values: Any, labor: dict[str, Any]) -> dict[str, Any]:
|
||||
def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
|
||||
"""같은 로직 · 있는 호표 차례만 살림."""
|
||||
lines = {str(row.get("키")): len(row.get("호표", [])) for row in labor.get("로직", [])}
|
||||
slots = { # 흐름 로직 — 고른 자재는 재료 자리 이름(= 입력 이름)
|
||||
str(row.get("키")): {s["이름"] for s in row.get("입력", [])}
|
||||
for row in labor.get("로직", [])
|
||||
if isinstance(row.get("흐름"), dict)
|
||||
}
|
||||
out: dict[str, Any] = {}
|
||||
for logic, chosen in (picks or {}).items():
|
||||
kept = {k: v for k, v in (chosen or {}).items() if int(k) < lines.get(logic, 0)}
|
||||
kept = {
|
||||
k: v
|
||||
for k, v in (chosen or {}).items()
|
||||
if (str(k).isdigit() and int(k) < lines.get(logic, 0)) or k in slots.get(logic, ())
|
||||
}
|
||||
if kept:
|
||||
out[logic] = kept
|
||||
return out
|
||||
|
||||
@@ -248,7 +248,7 @@ def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any])
|
||||
except mf.FormulaError:
|
||||
continue
|
||||
mine = values.get(key) or {}
|
||||
for spec in mf.inputs_of(row):
|
||||
for spec in row.get("입력", []):
|
||||
got = mine.get(spec["이름"])
|
||||
if not isinstance(got, dict) or got.get("값") in (None, ""):
|
||||
out.append({"로직": key, "입력": spec["이름"]})
|
||||
|
||||
@@ -84,7 +84,7 @@ def inputs(key: str) -> dict[str, Any]:
|
||||
for logic in logics:
|
||||
auto = m01.mau.values(whole, logic)
|
||||
specs = []
|
||||
for spec in m01.cm.mf.inputs_of(logic):
|
||||
for spec in logic.get("입력") or []:
|
||||
one = {k: spec[k] for k in _SPEC if k in spec}
|
||||
one.setdefault("설명", None)
|
||||
one.setdefault("단위", None)
|
||||
@@ -126,9 +126,7 @@ def write_file(column: str, doc: dict[str, Any]) -> None:
|
||||
def _known(key: str) -> dict[str, set[str]]:
|
||||
"""{로직 키: {입력 이름}} — 그 항목 조합이 지금 담은 것."""
|
||||
_, logics, _ = combo_logics(_item(key).get("일위대가"))
|
||||
return {
|
||||
str(lg.get("키")): {str(s.get("이름")) for s in m01.cm.mf.inputs_of(lg)} for lg in logics
|
||||
}
|
||||
return {str(lg.get("키")): {str(s.get("이름")) for s in lg.get("입력") or []} for lg in logics}
|
||||
|
||||
|
||||
def read_links(key: str) -> dict[str, Any]:
|
||||
@@ -206,7 +204,7 @@ def pills(key: str, place: str) -> dict[str, Any]:
|
||||
if row.get("자리") != place or row.get("사라짐"):
|
||||
continue
|
||||
logic = by_key[str(row["로직"])]
|
||||
spec = next(s for s in m01.cm.mf.inputs_of(logic) if s.get("이름") == row["입력"])
|
||||
spec = next(s for s in logic.get("입력") or [] if s.get("이름") == row["입력"])
|
||||
short = combo_store._TAIL.sub("", str(logic.get("이름") or "")) or row["로직"]
|
||||
linked.append(
|
||||
{
|
||||
|
||||
@@ -297,7 +297,7 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
cols = COLS.get(book)
|
||||
flow = isinstance(row.get("흐름"), dict) # 흐름 로직 — 옛 식 칸 없음
|
||||
for need in cols or ROW_KEYS[kind]:
|
||||
if need not in row and not (flow and need in mf.OLD_FIELDS):
|
||||
if need not in row and not (flow and need in mf.CALC_FIELDS):
|
||||
out.append(f"{where} · 칸 「{need}」 없음")
|
||||
if "출처" in row and not _source_ok(row["출처"]):
|
||||
out.append(f"{where} · 출처 모양 「{row['출처']}」")
|
||||
@@ -498,7 +498,7 @@ def 시험입력(row: dict) -> dict:
|
||||
"""일괄 시험 계산용 입력 한 벌 — 고르기 첫 값 · 범위 위끝 · 줄의 「시험입력」 이 이김.
|
||||
시험 계산 전용 — 정본 계산은 화면이 준 값만 씀."""
|
||||
given = {}
|
||||
for spec in mf.inputs_of(row):
|
||||
for spec in row.get("입력", []):
|
||||
if "고르기" in spec:
|
||||
given[spec["이름"]] = spec["고르기"][0]
|
||||
elif "범위" in spec:
|
||||
|
||||
@@ -36,7 +36,7 @@ _ARITH = re.compile(r"[\d.\s+\-*/()]*\d[\d.\s+\-*/()]*") # 분수·식 값 칸(
|
||||
FLOW_RUN = None
|
||||
# 흐름 로직 입력 = 「설계 때 정할 것」 → 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시) — 서버가 꽂음
|
||||
FLOW_INPUTS = None
|
||||
OLD_FIELDS = ("입력", "중간", "호표", "결과", "덧줄") # 흐름 로직 줄에는 없음(흐름 하나가 정본)
|
||||
CALC_FIELDS = ("중간", "호표", "결과", "덧줄") # 계산 칸 — 흐름 로직 줄엔 없음(입력은 둠)
|
||||
|
||||
|
||||
def _flow_hooks() -> None:
|
||||
@@ -49,11 +49,9 @@ def _flow_hooks() -> None:
|
||||
|
||||
|
||||
def inputs_of(row: dict) -> list[dict]:
|
||||
"""로직 입력 — 흐름 로직이면 흐름의 「설계 때 정할 것」 · 아니면 줄의 「입력」."""
|
||||
if isinstance(row.get("흐름"), dict):
|
||||
_flow_hooks()
|
||||
return FLOW_INPUTS(row)
|
||||
return row.get("입력", [])
|
||||
"""흐름의 「설계 때 정할 것」 → 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시)."""
|
||||
_flow_hooks()
|
||||
return FLOW_INPUTS(row)
|
||||
|
||||
|
||||
class FormulaError(ValueError):
|
||||
@@ -508,7 +506,7 @@ def fill_empty(row: dict, given: dict) -> dict:
|
||||
"""화면이 안 보낸 가격 입력(단위에 「원」)은 null 로 채움 — 빈 입력과 같이 그 줄만 비움."""
|
||||
gap = {
|
||||
s["이름"]: None
|
||||
for s in inputs_of(row)
|
||||
for s in row.get("입력", [])
|
||||
if s["이름"] not in given and "원" in str(s.get("단위") or "")
|
||||
}
|
||||
return {**given, **gap}
|
||||
@@ -665,7 +663,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
# ── 검사 ──────────────────────────────────────────────────────────────
|
||||
def _choices(row: dict, name: str, master: Master):
|
||||
"""이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None."""
|
||||
for spec in inputs_of(row):
|
||||
for spec in row.get("입력", []):
|
||||
if spec["이름"] == name:
|
||||
return spec.get("고르기")
|
||||
for step in row.get("중간", []):
|
||||
@@ -753,7 +751,7 @@ def _check_node(row, node, known: set, master: Master, where: str, out: list, ca
|
||||
except FormulaError as e:
|
||||
out.append(f"{where} · {e}")
|
||||
continue
|
||||
names = {s["이름"] for s in inputs_of(target)}
|
||||
names = {s["이름"] for s in target.get("입력", [])}
|
||||
given = {k for k, _ in part[2]}
|
||||
for extra in sorted(given - names):
|
||||
out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음")
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import shutil
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -134,9 +135,41 @@ def test_흐름_셈_단가는_부른_쪽_자리_프로젝트_사본(client: Test
|
||||
}
|
||||
_save_flow(client, KEY, flow)
|
||||
files = store.cm.load(folder=store.FOLDER)
|
||||
master = store.cm.mf.run(store.cm.mf.Master(files), KEY, {})
|
||||
master = store.cm.mf.run(store.cm.mf.Master(files), KEY, {"길이": 1, "폭": 1})
|
||||
labor = next(r for d in files.values() for r in d.get("줄") or [] if r.get("키") == "LB000001")
|
||||
assert master["노무비"] == labor["값"]
|
||||
labor["값"] = Decimal(100000) # 프로젝트 사본 노임
|
||||
copy = store.cm.mf.run(store.cm.mf.Master(files), KEY, {})
|
||||
copy = store.cm.mf.run(store.cm.mf.Master(files), KEY, {"길이": 1, "폭": 1})
|
||||
assert copy["노무비"] == 100000 and master["노무비"] != 100000
|
||||
|
||||
|
||||
def test_프로젝트에서_고른_자재는_흐름_재료_자리로(client: TestClient) -> None:
|
||||
"""고름(`mp.choosing`) — 흐름 로직은 호표 차례 대신 재료 자리 이름으로 · 고른 품목 단가로 셈."""
|
||||
items = (r["키"] for r in dv._group_rows("재료") if not str(r["키"]).startswith("MO"))
|
||||
first, other = list(itertools.islice((k for k in items if dv._price("재료", k).get("ok")), 2))
|
||||
flow = _flow()
|
||||
el = {
|
||||
"ref": "",
|
||||
"name": "자재",
|
||||
"spec": "",
|
||||
"unit": "",
|
||||
"mode": "choice",
|
||||
"default": first,
|
||||
"slot": "자재 자리",
|
||||
}
|
||||
flow["boxes"].append({"id": "mat", "kind": "input", "name": "재료", "color": "", "unit": "-", "x": 0, "y": 0,
|
||||
"input": {"mode": "group", "group": "재료", "elements": [el], "variables": []}}) # fmt: skip
|
||||
flow["boxes"][2]["output"]["cost"] = "재료비"
|
||||
flow["links"][2] = {
|
||||
"id": "l3",
|
||||
"from": {"box": "mat", "name": "자재 자리"},
|
||||
"to": {"box": "out", "name": "값"},
|
||||
}
|
||||
_save_flow(client, KEY, flow)
|
||||
row = store.logic(KEY)["logic"]
|
||||
assert "자재 자리" in [s["이름"] for s in row["입력"]] # 재료 자리도 입력 약속에
|
||||
whole = store.cm.mf.Master(store.cm.load(folder=store.FOLDER))
|
||||
given = {"길이": Decimal(1), "폭": Decimal(1), "자재 자리": None}
|
||||
with store.cm.mf.mp.choosing({"자재 자리": other}):
|
||||
got = store.cm.mf.run(whole, KEY, given)
|
||||
assert got["재료비"] == dv._price("재료", other)["value"]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# ruff: noqa: E501
|
||||
"""흐름 로직(옛 식 칸 지운 줄)이 조합 · 프로젝트 길에서도 옛 셈과 같음(PLAN 36 · 사용자 결정 · 사본 폴더).
|
||||
"""흐름 로직이 일위대가 조합 · 프로젝트 길에서도 그대로(PLAN 36 · 사용자 원칙 「조합은 로직을 가져가 더하기만」 · 사본 폴더).
|
||||
|
||||
못박는 것 — 옛 로직 하나를 변환해 흐름으로 저장(옛 칸 지움)한 뒤
|
||||
- 옛 엔진 `mf.run`(조합 · 프로젝트 길) 합계 = 바꾸기 전 옛 셈 합계
|
||||
- 프로젝트 단가 묶음(`M02_Item_Prices.collect`)만으로 셈 = 같은 합계(흐름이 쓰는 요소 · 표 · 다른 로직이 담김)
|
||||
- 입력 목록(`mf.inputs_of`) = 옛 입력 이름 · 안 준 입력은 「빈 입력」 알림(예시값으로 안 넘어감)
|
||||
못박는 것 — 조합에 담은 로직을 모두 흐름으로 저장(계산 칸 지움 · 「입력」 은 흐름에서 같은 꼴로)한 뒤
|
||||
- 로직마다 입력 목록(이름 · 단위 · 고르기 · 범위) = 바꾸기 전
|
||||
- 조합 셈(`master_combo.run`) 로직별 결과 · 비목 합 = 바꾸기 전
|
||||
- 프로젝트 단가 묶음(`M02_Item_Prices.collect`)만으로 셈 = 바꾸기 전(흐름이 쓰는 요소 · 표 · 다른 로직이 담김)
|
||||
- 빈 값으로 준 입력은 「빈 입력」 알림(예시값으로 안 넘어감) · 안 준 입력은 멈춤
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,44 +23,74 @@ from M02_MasterTemplete.DataFlow import M02_DF_Convert as cv
|
||||
|
||||
REAL = store.FOLDER
|
||||
KEYS = ("GF000185", "GF000219") # 레미콘 검색어 · 펌프차 기계 · 인력 / 표 찾기 · 인력
|
||||
COMBO = {
|
||||
"키": "UA999999",
|
||||
"이름": "시험 조합",
|
||||
"담은로직": [{"로직": k, "메모": None} for k in KEYS],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def folder(tmp_path: Path, monkeypatch) -> Path:
|
||||
def _specs(row: dict) -> list:
|
||||
return [
|
||||
(s["이름"], s.get("단위"), s.get("고르기"), s.get("범위")) for s in row.get("입력") or []
|
||||
]
|
||||
|
||||
|
||||
def _round(v):
|
||||
return round(Decimal(str(v)), 6) if v is not None else None
|
||||
|
||||
|
||||
def _combo(folder: Path) -> tuple[list, dict]:
|
||||
mf, mcb = store.cm.mf, store.cm.mcb
|
||||
given = {k: dict(store.cm.시험입력(store.logic(k)["logic"])) for k in KEYS}
|
||||
got = mcb.run(mf.Master(store.cm.load(folder=folder)), COMBO, given)
|
||||
lines = [
|
||||
(x["로직"], x["ok"], {k: _round(x.get(k)) for k in ("노무비", "재료비", "경비", "계")})
|
||||
for x in got["줄"]
|
||||
]
|
||||
return lines, {k: _round(got.get(k)) for k in ("노무비", "재료비", "경비", "계")}
|
||||
|
||||
|
||||
def _bundle_sums() -> dict:
|
||||
mf = store.cm.mf
|
||||
snapshot = prices.load_prices(prices.to_json(prices.collect(list(KEYS))))
|
||||
out = {}
|
||||
for k in KEYS:
|
||||
got = mf.run(snapshot, k, dict(store.cm.시험입력(store.logic(k)["logic"])))
|
||||
out[k] = {n: _round(got[n]) for n in ("노무비", "재료비", "경비", "계")}
|
||||
return out
|
||||
|
||||
|
||||
def test_조합에_담은_로직을_흐름으로_바꿔도_같음(tmp_path: Path, monkeypatch) -> None:
|
||||
for path in REAL.glob("*.json"):
|
||||
shutil.copy(path, tmp_path / path.name)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _sums(got: dict) -> dict:
|
||||
return {k: round(Decimal(str(got[k])), 6) for k in ("노무비", "재료비", "경비", "계")}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", KEYS)
|
||||
def test_흐름으로_바꾼_로직_조합_프로젝트_길도_옛_셈과_같음(folder: Path, key: str) -> None:
|
||||
specs = {k: _specs(store.logic(k)["logic"]) for k in KEYS}
|
||||
lines, sums = _combo(tmp_path)
|
||||
assert all(ok for _, ok, _ in lines) and sums["계"] > 0
|
||||
bundle = _bundle_sums()
|
||||
for key in KEYS:
|
||||
got = store.logic(key)
|
||||
flow = cv.convert(got["logic"], {"key": key, "master": store.loaded()[1]})["flow"]
|
||||
flow.pop("key", None)
|
||||
change = {"op": "edit", "key": key, "row": {**got["logic"], "흐름": flow}}
|
||||
sflow.save(
|
||||
[store._dec({"file": got["file"], "version": got["version"], "changes": [change]})]
|
||||
)
|
||||
for key in KEYS:
|
||||
row = store.logic(key)["logic"]
|
||||
assert not set(row) & set(store.cm.mf.CALC_FIELDS) and row["흐름"] # 계산 칸만 지움
|
||||
assert _specs(row) == specs[key] # 입력 약속 그대로
|
||||
assert _combo(tmp_path) == (lines, sums)
|
||||
assert "_빠진" not in prices.collect(list(KEYS))
|
||||
assert _bundle_sums() == bundle
|
||||
mf = store.cm.mf
|
||||
got = store.logic(key)
|
||||
old_row = got["logic"]
|
||||
names = [s["이름"] for s in old_row.get("입력") or []]
|
||||
given = {k: v for k, v in store.cm.시험입력(old_row).items()}
|
||||
before = _sums(mf.run(mf.Master(store.cm.load(folder=folder)), key, dict(given)))
|
||||
flow = cv.convert(old_row, {"key": key, "master": store.loaded()[1]})["flow"]
|
||||
flow.pop("key", None)
|
||||
part = {
|
||||
"file": got["file"],
|
||||
"version": got["version"],
|
||||
"changes": [{"op": "edit", "key": key, "row": {**old_row, "흐름": flow}}],
|
||||
}
|
||||
sflow.save([store._dec(part)])
|
||||
row = store.logic(key)["logic"]
|
||||
assert not set(row) & set(mf.OLD_FIELDS)
|
||||
assert [s["이름"] for s in mf.inputs_of(row)] == names
|
||||
assert _sums(mf.run(mf.Master(store.cm.load(folder=folder)), key, dict(given))) == before
|
||||
bundle = prices.collect([key])
|
||||
assert "_빠진" not in bundle, bundle.get("_빠진")
|
||||
snapshot = prices.load_prices(prices.to_json(bundle))
|
||||
assert _sums(mf.run(snapshot, key, dict(given))) == before # 단가 묶음만으로 같은 합계
|
||||
if key == "GF000185": # 빈 입력 — 재료 찾기 자리(표 찾기 빈 조건은 sub7 lookup 몫)
|
||||
blank = mf.run(snapshot, key, {k: v for k, v in given.items() if k != names[0]})
|
||||
assert any(f"「{names[0]}」 빈 입력" in n for n in blank.get("알림") or [])
|
||||
whole = mf.Master(store.cm.load(folder=tmp_path))
|
||||
given = dict(store.cm.시험입력(store.logic(KEYS[0])["logic"]))
|
||||
first = next(iter(given))
|
||||
blank = mf.run(
|
||||
whole, KEYS[0], {**given, first: None}
|
||||
) # 빈 값으로 준 입력 = 그 값을 쓰는 곳만 비움
|
||||
assert any(f"「{first}」 빈 입력" in n for n in blank.get("알림") or [])
|
||||
with pytest.raises(mf.FormulaError, match="없음"): # 안 준 입력 = 멈춤(엔진과 같음)
|
||||
mf.run(whole, KEYS[0], {k: v for k, v in given.items() if k != first})
|
||||
|
||||
@@ -72,7 +72,6 @@ def folder(tmp_path: Path, monkeypatch) -> Path:
|
||||
for path in REAL.glob("*.json"):
|
||||
shutil.copy(path, master / path.name)
|
||||
monkeypatch.setattr(store, "FOLDER", master)
|
||||
monkeypatch.setattr(df, "DATA", tmp_path / "_data")
|
||||
return master
|
||||
|
||||
|
||||
@@ -169,7 +168,7 @@ def test_신규_흐름_로직은_자체_GX_복사는_흐름도(client: TestClien
|
||||
saved = _get(client, "/api/m01/logic", key=key)["logic"]
|
||||
assert saved["구분"] == "자체" and saved["흐름"]["name"] == "시험 흐름 로직"
|
||||
assert not set(store.logic(key)["logic"]) & set(
|
||||
store.cm.mf.OLD_FIELDS
|
||||
store.cm.mf.CALC_FIELDS
|
||||
) # 파일 줄에 옛 식 칸 없음
|
||||
assert (
|
||||
Decimal(str(_post(client, "/api/m01/calc", {"key": key, "inputs": {}})["sums"]["계"]))
|
||||
@@ -262,7 +261,7 @@ def test_흐름_로직_줄은_옛_식_칸_없음_읽는_곳은_흐름에서(clie
|
||||
mf = store.cm.mf
|
||||
_save_flow(client, KEY, _flow())
|
||||
row = store.logic(KEY)["logic"] # 파일 줄 그대로
|
||||
assert not set(row) & set(mf.OLD_FIELDS) and "끝수" in row and row["흐름"]
|
||||
assert not set(row) & set(mf.CALC_FIELDS) and "끝수" in row and row["흐름"]
|
||||
view = _get(client, "/api/m01/logic", key=KEY)[
|
||||
"logic"
|
||||
] # 화면 보기 = 설계 때 정할 것을 입력 꼴로
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -26,6 +27,7 @@ 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 m01_router
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
from M01_MasterData import M01_MasterData_Store_Flow as sflow
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Router as cf
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
|
||||
|
||||
@@ -137,14 +139,7 @@ def folder(tmp_path: Path, monkeypatch) -> Path:
|
||||
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")
|
||||
sample = tmp_path / "_sample"
|
||||
sample.mkdir()
|
||||
one = {"key": None, "name": "견본 돌쌓기", "resultUnit": "원/㎡", "boxes": [], "links": [],
|
||||
"userElements": []} # fmt: skip
|
||||
(sample / "견본 돌쌓기.json").write_text(json.dumps(one, ensure_ascii=False), encoding="utf-8")
|
||||
monkeypatch.setattr(store, "FOLDER", master)
|
||||
monkeypatch.setattr(cf, "DATA", tmp_path / "_data")
|
||||
monkeypatch.setattr(cf, "SAMPLE", sample)
|
||||
return master
|
||||
|
||||
|
||||
@@ -166,13 +161,34 @@ def _d(v) -> Decimal:
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _save(logic: dict) -> dict:
|
||||
"""서버 셈(`M02_DF_Compute.run`) — 받는 칸에 값을 되돌린 흐름 · 값(흐름 저장은 M01 로직 줄 「흐름」 칸)."""
|
||||
got = cf.dc.run(logic, {}, cf._load, logic.get("userElements") or [], [logic.get("key") or ""])
|
||||
return {"key": logic.get("key"), "logic": got.pop("filled"), "values": got}
|
||||
|
||||
|
||||
def _store(logic: dict) -> str:
|
||||
"""흐름을 마스터 사본의 새 자체 로직 줄 「흐름」 칸에 저장(M01 `/save` 와 같은 길) — 다른 로직 입력이 부름."""
|
||||
own = store.FOLDER / "로직_자체.json"
|
||||
version = store.read(own.name)[1] if own.is_file() else ""
|
||||
row = {
|
||||
"키": "",
|
||||
"이름": logic["name"],
|
||||
"결과단위": logic.get("resultUnit") or "원",
|
||||
"흐름": {**logic, "key": None},
|
||||
}
|
||||
done = sflow.save(
|
||||
[{"file": own.name, "version": version, "changes": [{"op": "add", "row": row}]}]
|
||||
)
|
||||
return done["added"][0]["key"]
|
||||
|
||||
|
||||
@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/m02/df/save", {"logic": _logic(labor)})
|
||||
assert saved["key"] == "DF-0001"
|
||||
saved = _save(_logic(labor))
|
||||
v = saved["values"]
|
||||
labor_cost = price * 2 * 3 * Decimal("1.1")
|
||||
assert _d(v["ports"]["f1"]["노무비"]) == labor_cost
|
||||
@@ -193,27 +209,7 @@ def test_저장_서버가_다시_셈_값만_되돌림(client: TestClient, folder
|
||||
assert [c3[f"{c}1"]["값"] for c in "EFGHIJ"] == HEAD and c3["F3"]["값"] == c3["G3"]["값"] == 4
|
||||
assert saved["logic"]["links"] == _logic(labor)["links"]
|
||||
|
||||
# 열기 · 목록 · 견본 · 고치기 · 지우기
|
||||
listed = client.get("/api/m02/df/list").json()["logics"]
|
||||
assert [(x["key"], x["sample"]) for x in listed] == [("DF-0001", False), ("견본 돌쌓기", True)]
|
||||
sample = client.get("/api/m02/df/get", params={"key": "견본 돌쌓기"}).json()
|
||||
assert sample["sample"] is True
|
||||
assert _post(client, "/api/m02/df/save", {"logic": sample["logic"]})["key"] == "DF-0002"
|
||||
_post(client, "/api/m02/df/delete", {"key": "견본 돌쌓기"}, want=403)
|
||||
renamed = {**saved["logic"], "name": "고친 이름"}
|
||||
assert _post(client, "/api/m02/df/save", {"logic": renamed})["key"] == "DF-0001"
|
||||
got = client.get("/api/m02/df/get", params={"key": "DF-0001"}).json()["logic"]
|
||||
assert (
|
||||
got["name"] == "고친 이름" and got["boxes"][3]["func"]["sheet"]["칸"]["A1"]["값"] == price
|
||||
)
|
||||
_post(client, "/api/m02/df/delete", {"key": "DF-0002"})
|
||||
assert client.get("/api/m02/df/get", params={"key": "DF-0002"}).status_code == 404
|
||||
_post(client, "/api/m02/df/save", {"logic": _logic(labor, "DF-0009")}, want=404)
|
||||
_post(client, "/api/m02/df/save", {"logic": {**_logic(labor), "name": " "}}, want=400)
|
||||
_post(client, "/api/m02/df/save", {"logic": {"name": "x"}}, want=400)
|
||||
|
||||
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터 사본 그대로
|
||||
assert sorted(p.name for p in cf.DATA.glob("*.json")) == ["DF-0001.json"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||||
@@ -221,7 +217,7 @@ def test_빈_입력_값은_쉬운_말(client: TestClient) -> None:
|
||||
labor, _ = _labor()
|
||||
logic = _logic(labor)
|
||||
logic["boxes"][0]["input"]["elements"][0]["ref"] = "LB999999"
|
||||
v = _post(client, "/api/m02/df/save", {"logic": logic})["values"]
|
||||
v = _save(logic)["values"]
|
||||
assert v["errors"] and "석공" in v["errors"][0]
|
||||
|
||||
|
||||
@@ -278,23 +274,17 @@ def test_입력_값_그룹마다(client: TestClient) -> None:
|
||||
assert got[k]["ok"] is False and got[k]["reason"], got[k]
|
||||
|
||||
|
||||
def test_사용자_요소_만들기_지우기(client: TestClient, folder: Path) -> None:
|
||||
def test_사용자_요소는_흐름_안_것만(client: TestClient, folder: Path) -> None:
|
||||
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
|
||||
url = "/api/m02/df/user-elements"
|
||||
a = _post(client, url, {"op": "add", "element": {"name": "가", "group": "재료", "value": 1.5}})
|
||||
b = _post(client, url, {"op": "add", "element": {"name": "나", "unit": "회"}})
|
||||
assert [a["element"]["id"], b["element"]["id"]] == ["UE-0001", "UE-0002"]
|
||||
assert b["element"]["group"] == "재료" and b["element"]["value"] is None
|
||||
_post(client, url, {"op": "add", "element": {"name": " "}}, want=400)
|
||||
_post(client, url, {"op": "add", "element": {"name": "다", "value": "x"}}, want=400)
|
||||
_post(client, url, {"op": "add", "element": {"name": "라", "group": "모름"}}, want=400)
|
||||
_post(client, url, {"op": "delete", "id": "UE-0001"})
|
||||
_post(client, url, {"op": "delete", "id": "UE-0001"}, want=404)
|
||||
assert [e["id"] for e in client.get(url).json()["elements"]] == ["UE-0002"]
|
||||
got = _post(
|
||||
client, "/api/m02/df/values", {"items": [{"id": "x", "group": "재료", "ref": "UE-0002"}]}
|
||||
)
|
||||
assert got["values"][0]["ok"] is False # 값이 비어 있으면 쉬운 말
|
||||
mine = [{"id": "UE-0002", "group": "재료", "name": "나", "spec": "", "unit": "회", "value": None},
|
||||
{"id": "UE-0003", "group": "재료", "name": "다", "spec": "", "unit": "개", "value": 2.5}] # fmt: skip
|
||||
items = [
|
||||
{"id": "x", "group": "재료", "ref": "UE-0002"},
|
||||
{"id": "y", "group": "재료", "ref": "UE-0003"},
|
||||
]
|
||||
got = _post(client, "/api/m02/df/values", {"items": items, "userElements": mine})["values"]
|
||||
assert got[0]["ok"] is False and _d(got[1]["value"]) == Decimal("2.5") # 빈 값은 쉬운 말
|
||||
assert client.get("/api/m02/df/user-elements").status_code in (404, 405) # 공용 목록 길 없음
|
||||
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터에 안 씀
|
||||
|
||||
|
||||
@@ -305,11 +295,10 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||||
)
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/m02/df/list").status_code == 403
|
||||
assert client.post("/api/m02/df/values", json={"items": []}).status_code == 403
|
||||
assert client.get("/api/m02/df/user-elements").status_code == 403
|
||||
assert client.get("/api/m02/df/design-inputs", params={"key": "GF000219"}).status_code == 403
|
||||
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
|
||||
assert [x["key"] for x in client.get("/api/m02/df/list").json()["logics"]] == ["견본 돌쌓기"]
|
||||
assert client.post("/api/m02/df/values", json={"items": []}).json() == {"values": []}
|
||||
|
||||
|
||||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||||
@@ -353,9 +342,8 @@ def test_기계_키_칸_꼴과_시간당_경비(client: TestClient) -> None:
|
||||
body = {"key": None, "name": "기계 견본", "resultUnit": "원/시간", "boxes": [box, f1, o1],
|
||||
"links": [_link("a", "m1", "기계 · 시간당 경비", "f1", "단가"),
|
||||
_link("b", "f1", "경비", "o1", "값")], "userElements": []} # fmt: skip
|
||||
saved = _post(client, "/api/m02/df/save", {"logic": body})
|
||||
saved = _save(body)
|
||||
assert _d(saved["values"]["cost"]["경비"]) == 2 * _d(want)
|
||||
_post(client, "/api/m02/df/delete", {"key": saved["key"]})
|
||||
|
||||
|
||||
# ── 35-9 넓힘 — 재료 셋 · 자재 찾기 · 다른 로직 · 설계 때 정할 것 · 선 규칙 · 끝수 · 표준 표 ─────
|
||||
@@ -450,7 +438,7 @@ def _area(x: int = 100) -> dict:
|
||||
|
||||
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
|
||||
def test_다른_로직_값_설계_값_순환(client: TestClient) -> None:
|
||||
a = _post(client, "/api/m02/df/save", {"logic": _area()})["key"]
|
||||
a = _store(_area())
|
||||
assert _post(client, "/api/m02/df/logic-value", {"key": a})["outputs"] == {"결과": 200}
|
||||
assert _post(client, "/api/m02/df/logic-value", {"key": a, "designs": {"면적": 5}})[
|
||||
"outputs"
|
||||
@@ -467,7 +455,7 @@ def test_다른_로직_값_설계_값_순환(client: TestClient) -> None:
|
||||
"links": [_link("a", "L", "결과", "o", "값")],
|
||||
"userElements": [],
|
||||
} # fmt: skip
|
||||
saved = _post(client, "/api/m02/df/save", {"logic": b})
|
||||
saved = _save({**b, "key": _store(b)})
|
||||
assert (
|
||||
saved["values"]["ports"]["L"] == {"결과": 200}
|
||||
and _d(saved["values"]["cost"]["경비"]) == 200
|
||||
@@ -478,9 +466,9 @@ def test_다른_로직_값_설계_값_순환(client: TestClient) -> None:
|
||||
assert [(d["box"], d["port"], d["kind"]) for d in designs] == [("L", "면적", "다른 로직")]
|
||||
|
||||
# 순환 — 로직이 자기를 부름
|
||||
loop = {**b, "key": saved["key"]}
|
||||
loop = copy.deepcopy({**b, "key": saved["key"]})
|
||||
loop["boxes"][0]["input"]["logic"] = {**ref, "key": saved["key"]}
|
||||
got = _post(client, "/api/m02/df/save", {"logic": loop})["values"]
|
||||
got = _save(loop)["values"]
|
||||
assert any("서로를 부름" in e for e in got["errors"])
|
||||
|
||||
|
||||
@@ -526,7 +514,7 @@ def test_선_규칙_끝수_재료_조건_설계_때_정할_것(client: TestClien
|
||||
],
|
||||
"userElements": [],
|
||||
} # fmt: skip
|
||||
got = _post(client, "/api/m02/df/save", {"logic": lg})
|
||||
got = _save(lg)
|
||||
v = got["values"]
|
||||
assert v["ports"]["m"] == {
|
||||
"관 자리": 30000,
|
||||
@@ -542,21 +530,18 @@ def test_선_규칙_끝수_재료_조건_설계_때_정할_것(client: TestClien
|
||||
"식": "A1+A2+A3"
|
||||
} # 받는 칸에 값만
|
||||
|
||||
designs = client.get("/api/m02/df/design-inputs", params={"key": got["key"]}).json()["designs"]
|
||||
key = _store(lg) # 로직 줄 「흐름」 칸에 저장한 뒤 길로 읽음
|
||||
designs = client.get("/api/m02/df/design-inputs", params={"key": key}).json()["designs"]
|
||||
assert [(d["port"], d["kind"]) for d in designs] == [
|
||||
("돌", "변수"), ("높이", "변수"), ("관경", "변수"), ("골라 쓰는 관", "재료 자리")] # fmt: skip
|
||||
|
||||
lg["boxes"][0]["input"]["variables"][1]["example"] = 3 # 「~3」 줄(위 끝 포함)
|
||||
assert _d(
|
||||
_post(client, "/api/m02/df/save", {"logic": lg})["values"]["ports"]["f"]["합"]
|
||||
) == int(Decimal("0.5") + 33000 + 3)
|
||||
assert _d(_save(lg)["values"]["ports"]["f"]["합"]) == int(Decimal("0.5") + 33000 + 3)
|
||||
lg["boxes"][0]["input"]["variables"][0]["example"] = (
|
||||
"견치돌" # 「높이 4」 줄 = 아래 = 위 → 같음(정함)
|
||||
)
|
||||
lg["boxes"][0]["input"]["variables"][1]["example"] = 4
|
||||
assert _d(
|
||||
_post(client, "/api/m02/df/save", {"logic": lg})["values"]["ports"]["f"]["합"]
|
||||
) == int(Decimal("0.7") + 33000 + 4)
|
||||
assert _d(_save(lg)["values"]["ports"]["f"]["합"]) == int(Decimal("0.7") + 33000 + 4)
|
||||
lookup = {
|
||||
"kind": "lookup",
|
||||
"col": "석공",
|
||||
@@ -570,7 +555,7 @@ def test_선_규칙_끝수_재료_조건_설계_때_정할_것(client: TestClien
|
||||
]
|
||||
lg["boxes"][0]["input"]["variables"][2]["example"] = "D999" # 맞는 재료 없음
|
||||
lg["boxes"][0]["input"]["variables"][0]["example"] = "없는돌" # 표에 없는 조건 → 함수 멈춤
|
||||
bad = _post(client, "/api/m02/df/save", {"logic": lg})["values"]
|
||||
bad = _save(lg)["values"]
|
||||
assert any("맞는 재료 없음" in e for e in bad["errors"]) and any(
|
||||
"표에 없는 조건" in e for e in bad["errors"]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user