Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-01 16:11:27 +09:00
8 changed files with 1046 additions and 98 deletions
+102 -80
View File
@@ -1,40 +1,35 @@
/* =============================================================================
* M02_CF_Api.ts
* 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 넣고
* 같은 엔진으로 함수 상자 시트를 다시 셈) · 지우기 · 입력 값 한 번에 받기 · 사용자 요소
* 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 받아
* 함수 상자 시트를 같은 엔진으로 셈하고 받는 칸에 값만 되돌려 넣음) · 지우기 · 입력 값 한 번에 받기 ·
* 사용자 요소. 모양 = sub7 Model(CfLogic · CfGroup · CfUserElement) · 아래는 Api 몫만.
* 서버 = `M02_CF_Router.py`(`/api/m02/cf/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`
* ========================================================================== */
import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { API_BASE_URL } from "@config/config_frontend";
import type { CfGroup, CfLogic, CfUserElement } from "./M02_CF_Model";
/** 입력 그룹 — M01 저장 그룹 + 사용자 요소 */
export type CfGroup =
"인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율" | "사용자";
/** 돈 로직 결과에서 꺼낼 칸(기계 시간당 경비) */
export type CfField = "노무비" | "재료비" | "경비" | "계";
/** 비용 종류 칸 */
export type CfCost = "노무비" | "재료비" | "경비" | "계";
/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소 `UE-…` 는 다이어그램 안 · `_data`)에서
* · 인력 · 재료 ref = 키 → 그 줄 단가(기존 계산과 같은 셈) · 재료 ref = 검색어 묶음 JSON 글도 됨(unit 을 주면
* 기존 로직 재료 줄과 같은 단가)
* · 유가전력 · 환율 = 그 줄 값 · 요율 = 값 묶음의 field(기본 rate_percent · %)
* · 기계 ref = EQ 키 + field(값 칸 — 가격 · 손료계수 …) 또는 `로직(GC…, 기계='…', 지역=지역)` + inputs → 한 시간 값
* · 소요량 · 계수 table = {key, row?} → 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「·아래」 「·위」) */
export interface CfQuery {
id: string;
group: CfGroup;
ref?: string;
table?: { key: string; row?: number };
unit?: string;
field?: string;
inputs?: Record<string, unknown>;
}
/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소는 `_data`)에서 새로
* · 인력 · 재료 = 그 줄 단가(기존 계산과 같은 셈 · 준용 · 낮은 값) — 재료 key 는 키 또는 검색어 묶음 JSON 글,
* unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가
* · 유가전력 · 환율 · 요율 · 사용자 = 그 줄 값
* · 기계 = key(EQ…) + field(값 칸 이름 — 가격 · 손료계수 …) 또는 element(기계 시간당 경비 로직 부름 글) + inputs → 한 시간 값
* · 소요량 · 계수 = 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「이름·아래」 「이름·위」) · row 를 주면 그 줄 하나 */
export type CfQuery =
| { id: string; group: "인력" | "유가전력" | "환율" | "요율" | "사용자"; key: string }
| { id: string; group: "재료"; key: string; unit?: string }
| { id: string; group: "기계"; key: string; field?: string }
| {
id: string;
group: "기계";
element: string;
inputs: Record<string, unknown>;
field?: CfCost;
}
| { id: string; group: "소요량" | "계수"; table: string; row?: number };
/** 입력 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
export interface CfValue {
/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
export interface CfAnswer {
id: string;
ok: boolean;
value?: number | string | null;
@@ -44,23 +39,21 @@ export interface CfValue {
reason?: string;
}
/** 저장 때 입력 값을 넣을 자리 — 함수 상자 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */
export interface CfFill {
item: CfQuery;
sheet: string;
cell: string;
}
/** 다이어그램 한 벌 = 통합문서(함수 상자 = 시트) + 상자 · 선(다른 창 칸은 그대로 저장 · 돌려줌) */
export interface CfLogic {
/** 새 것 null · 저장하면 서버가 `CF-0001` 꼴로 줌 · 견본은 그 이름(저장하면 새 번호) */
key: string | null;
name: string;
resultUnit?: string;
workbook: Workbook;
/** 입력 값 자리 — 서버가 저장 때 마스터에서 다시 넣음 */
fills?: CfFill[];
[extra: string]: unknown;
/** 서버 계산 결과 — 저장 답 */
export interface CfComputed {
/** 상자 id → 내보내는 알약 이름 → 값(표는 칸 덩어리) */
ports: Record<string, Record<string, unknown>>;
/** 출력 상자 id → 값 */
outputs: Record<string, number | string | boolean | null>;
cost: {
노무비: number;
재료비: number;
경비: number;
값들: Record<string, number | string | boolean | null>;
계: number;
};
/** 쉬운 말 */
errors: string[];
}
/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */
@@ -72,65 +65,94 @@ export interface CfListItem {
sample: boolean;
}
/** 저장 답 — `logic.workbook.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 입력 값 */
/** 저장 답 — `logic` 은 받는 칸에 서버 값이 들어간 다이어그램 */
export interface CfSaved {
key: string;
logic: CfLogic;
values: CfValue[];
values: CfComputed;
}
/** 사용자 요소 — 마스터에 없는 요소(시험 폴더 `_data` 에만) · 키 `UE-0001` 꼴 */
export interface CfUserElement {
key: string;
group: CfGroup;
이름: string;
규격: string;
단위: string;
값: number | null;
비고?: string;
/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */
export class CfError extends Error {
constructor(
public status: number,
public detail: unknown,
) {
super(typeof detail === "string" ? detail : JSON.stringify(detail));
}
}
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}/m02/cf${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data;
}
/** 내 다이어그램(저장 차례) + 견본 */
export async function cfList(): Promise<CfListItem[]> {
return notReady();
return (await send<{ logics: CfListItem[] }>("/list")).logics;
}
export async function cfGet(_key: string): Promise<CfLogic> {
return notReady();
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 notReady();
/** 저장 — 서버가 입력 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) ·
* 견본을 저장하면 새 번호 */
export async function cfSave(logic: CfLogic): Promise<CfSaved> {
return send<CfSaved>("/save", { logic });
}
export async function cfDelete(_key: string): Promise<void> {
return notReady();
export async function cfDelete(key: string): Promise<void> {
await send<{ key: string }>("/delete", { key });
}
export async function cfValues(_items: CfQuery[]): Promise<CfValue[]> {
return notReady();
/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 ·
* userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */
export async function cfValues(
items: CfQuery[],
userElements: CfUserElement[] = [],
): Promise<CfAnswer[]> {
return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values;
}
/** 사용자 요소 목록 */
export async function cfUserElements(): Promise<CfUserElement[]> {
return notReady();
return (await send<{ elements: CfUserElement[] }>("/user-elements")).elements;
}
/** 사용자 요소 만들기 — 키는 서버가 줌 */
export async function cfUserElementAdd(
_element: Omit<CfUserElement, "key">,
): Promise<CfUserElement> {
return notReady();
/** 사용자 요소 만들기(공용 목록 `_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(_key: string): Promise<void> {
return notReady();
export async function cfUserElementDelete(id: string): Promise<void> {
await send<{ id: string }>("/user-elements", { op: "delete", id });
}
/** 실패 → 쉬운 말 */
export function cfPlain(_e: unknown): string {
return notReady();
const PLAIN: Record<number, string> = {
0: "서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요",
400: "넣은 값의 모양이 맞지 않음",
403: "견본은 바꾸거나 지울 수 없음 — 저장하면 새 다이어그램으로 남음",
404: "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
503: "엑셀 계산을 서버에서 못 함 — 잠시 뒤 다시 해 주세요",
};
/** 실패 → 쉬운 말(키 · 날 글이 섞인 서버 글은 뭉뚱그림) */
export function cfPlain(e: unknown): string {
if (!(e instanceof CfError)) return e instanceof Error ? e.message : PLAIN[0];
const text = typeof e.detail === "string" ? e.detail : "";
if (e.status === 400 && text && !/[A-Z]{2}\d{6}|[{}]/.test(text)) return text;
return PLAIN[e.status] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요";
}
@@ -0,0 +1,602 @@
"""단가산출 다이어그램 시험 길(PLAN 35-7) — 목록 · 열기 · 저장 · 지우기 · 입력 값 · 사용자 요소.
GET /api/m02/cf/list → {logics: [{key, name, resultUnit, saved, sample}]}
GET /api/m02/cf/get?key=CF-0001 → {logic, sample}
POST /api/m02/cf/save {logic} → {key, logic, values: {ports, outputs, cost, errors}}
POST /api/m02/cf/delete {key}
POST /api/m02/cf/values {items, userElements?} → {values: CfAnswer[]}
GET /api/m02/cf/user-elements → {elements}
POST /api/m02/cf/user-elements {op: add | delete, element? | id?}
모양 = sub7 Model `M02_CF_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 안 · 읽기만 · 저장하면 새 번호) ·
키 `CF-0001` · 사용자 요소 `UE-0001` · 마스터 파일은 안 씀. 권한 = 시스템 관리자(`main.py` 한 줄).
옛 시험판(SheetLogic) 서버를 옮겨 고쳐 씀 — 그 폴더를 import 하지 않음.
정함 = `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`.
"""
from __future__ import annotations
import copy
import json
import os
import re
from datetime import datetime
from decimal import Decimal
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/m02/cf", tags=["M02 CostFlow (시험)"])
HERE = Path(__file__).resolve().parent
DATA = HERE / "_data" # 시험은 임시 폴더로 바꿈
SAMPLE = HERE / "_sample"
USERS = "_user_elements.json" # 사용자 요소 공용 목록 — `_data` 안
_KEY = re.compile(r"^CF-(\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
class ValuesBody(BaseModel):
items: list[dict[str, Any]]
userElements: list[dict[str, Any]] = []
class UserBody(BaseModel):
op: str
element: dict[str, Any] | None = None
id: str | None = None
# ── 자리 ──────────────────────────────────────────────────────────────
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)
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("CF-*.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}
# ── 입력 값 ────────────────────────────────────────────────────────────
def _row(line: dict, names) -> dict:
"""단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직."""
return {
"키": "",
"원문번호": "",
"이름": "입력 값",
"결과단위": "원/식",
"출처": "자체",
"입력": [{"이름": n} for n in names],
"중간": [],
"호표": [line],
"덧줄": [],
"끝수": None,
}
def _money(got: dict, field: str | None) -> dict:
if not got["ok"]:
return got
if "result" in got:
return {"ok": True, "value": got["result"]}
pick = field or "계"
if pick not in FIELDS:
return {"ok": False, "reason": f"모르는 칸 「{pick}」"}
return {"ok": True, "value": got["sums"][pick]}
def _pick(key: Any) -> dict | None:
"""재료 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None."""
if isinstance(key, dict):
return key
if isinstance(key, str) and key.lstrip().startswith("{"):
try:
got = json.loads(key)
except ValueError:
return None
return got if isinstance(got, dict) else None
return None
def _material(cond: dict, unit: str) -> dict:
"""검색어 묶음 재료 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산)."""
line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"}
got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None)
if not got["ok"]:
return got
one = got["lines"][0]
if one["단가"] is None:
word = cond.get("검색어") or ""
empty = "값 없음" in str(one.get("까닭") or "")
why = (
"단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름"
if empty
else "맞는 재료 없음"
)
return {"ok": False, "reason": f"「{word}」 {why}"}
picked = one.get("품목") or {}
return {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None}
def _brief(key: str, want: str) -> tuple[dict | None, str | None]:
"""마스터 줄 하나 + 그룹 확인 — (줄, 까닭) · 유가전력은 재료 그룹 안 MO 키."""
try:
row = store.loaded()[1].get(key)
except cm.mf.FormulaError as e:
return None, str(e)
group = store.mk.GROUP_OF.get(store.mk.id_of(key))
if not (group == want or (want == "유가전력" and key.startswith("MO"))):
return None, f"「{key}」 는 {want} 아님"
return row, None
def _price(group: str, key: str, unit: str = "") -> dict:
"""인력 노임 · 재료 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로."""
row, why = _brief(key, group)
if row is None:
return {"ok": False, "reason": why}
kind, cost = {"인력": ("인력", "노무비"), "재료": ("재료", "재료비")}[group]
line = {"종류": kind, "요소": key, "이름": row.get("이름") or key, "수량": "1"}
line = {**line, "단위": unit or row.get("단위") or "", "비목": cost}
got = store.calc("", {}, _row(line, ()), None)
if not got["ok"]:
return got
one = got["lines"][0]
out = {"ok": one["단가"] is not None, "value": one["단가"], "name": row.get("이름")}
return {**out, "unit": row.get("단위"), **({"reason": one["까닭"]} if one.get("까닭") else {})}
def _plain_value(group: str, key: str, field: str | None) -> dict:
"""유가전력 · 환율 · 요율 · 기계 값 칸 — 그 줄 값(묶음이면 field · 요율 기본 rate_percent)."""
row, why = _brief(key, group)
if row is None:
return {"ok": False, "reason": why}
value, unit = row.get("값"), row.get("단위")
if isinstance(value, dict):
pick = field or ("rate_percent" if group == "요율" else "")
if pick not in value:
return {
"ok": False,
"reason": f"「{row.get('이름')}」 에서 쓸 칸을 고름 — {', '.join(value)}",
}
value = value[pick]
unit = (
unit.get(pick) if isinstance(unit, dict) else ("%" if pick == "rate_percent" else unit)
)
if value is None:
return {"ok": False, "reason": f"「{row.get('이름')}」 값이 자료에 비어 있음"}
return {"ok": True, "value": value, "name": row.get("이름"), "unit": unit}
def _table(key: str, at: Any = None) -> dict:
"""소요량 · 계수 표 → 머리 줄 + 줄(조건 칸 → 값 칸 · 범위가 있는 칸은 「·아래」 「·위」 두 칸 ·
그 칸에 수 하나면 아래 · 위 같은 수) · at = 줄 차례(0부터)면 머리 줄 + 그 줄 하나."""
files = store.loaded()[0]
table = next(
(
t
for d in files.values()
if d.get("그룹") in cm.mf.TABLE_GROUPS
for t in d.get("표") or []
if str(t.get("키")) == key
),
None,
)
if table is None:
return {"ok": False, "reason": f"없는 표 「{key}」"}
conds, lines = table.get("조건") or {}, table.get("줄") or []
names = [*conds, *(table.get("값칸") or {})]
split = {
n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines)
}
head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))]
if at is not None:
if not isinstance(at, int) or not 0 <= at < len(lines):
return {"ok": False, "reason": f"「{table.get('이름')}」 에 {at} 번째 줄 없음"}
lines = [lines[at]]
rows = []
for r in lines:
line = []
for n in names:
v = r.get(n)
if n in split:
line += list(v) if isinstance(v, list) else [v, v]
else:
line.append(None if v == "" else v if not isinstance(v, dict) else str(v))
rows.append(line)
return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")}
def _user(uid: str, local: list[dict]) -> dict:
one = next((r for r in [*local, *_users()] if r.get("id") == uid), None)
if one is None:
return {"ok": False, "reason": f"없는 사용자 요소 「{uid}」"}
if one.get("value") is None:
return {"ok": False, "reason": f"「{one.get('name')}」 값이 비어 있음"}
return {"ok": True, "value": one["value"], "name": one.get("name"), "unit": one.get("unit")}
def one_value(item: dict, local: list[dict] | None = None) -> dict:
"""물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`."""
group, ref = item.get("group"), item.get("ref")
table = item.get("table") or {}
try:
if isinstance(ref, str) and _UKEY.match(ref):
got = _user(ref, local or [])
elif group in ("소요량", "계수"):
got = _table(str(table.get("key") or ref or ""), table.get("row"))
elif group == "재료" and _pick(ref) is not None:
got = _material(_pick(ref), str(item.get("unit") or ""))
elif group in ("인력", "재료"):
got = _price(group, str(ref or ""), str(item.get("unit") or ""))
elif group == "기계" and str(ref or "").startswith("로직("):
inputs = item.get("inputs") or {}
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(store.calc("", inputs, _row(line, inputs), None), item.get("field"))
elif group in ("기계", "유가전력", "환율", "요율"):
got = _plain_value(group, str(ref or ""), item.get("field"))
else:
got = {"ok": False, "reason": f"모르는 그룹 「{group}」"}
except store.StoreError as e:
got = {"ok": False, "reason": str(e.detail)}
return {"id": item.get("id") or "", **got}
def values(items: list[dict], local: list[dict]) -> dict:
# ponytail: 하나씩 셈(단가 하나 0.3초쯤) — 입력 값이 수십이면 마스터 사본 한 벌로 묶어 셈
return {"values": [one_value(i, local) 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 _style(cells: dict, cell: str) -> dict:
return {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {}
def _put(cells: dict, cell: str, value: Any) -> None:
"""받는 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠)."""
keep = _style(cells, cell)
if value is None and not keep:
cells.pop(cell, None)
else:
cells[cell] = {**({"값": value} if value is not None else {}), **keep}
def _place(cells: dict, cell: str, value: Any) -> None:
"""값 하나 · 표 블록(cell 이 왼위) · 수식 `{"식": …}` 을 받는 칸에."""
m = _CELL.match(str(cell or "").upper())
if not m:
raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」")
col, row, at = _col(m.group(1)), int(m.group(2)), f"{m.group(1)}{m.group(2)}"
if isinstance(value, dict) and "식" in value:
cells[at] = {**value, **_style(cells, at)}
elif isinstance(value, list):
for dy, line in enumerate(value):
for dx, one in enumerate(line):
_put(cells, f"{_letters(col + dx)}{row + dy}", one)
else:
_put(cells, at, value)
def _plain(v: Any) -> Any:
"""엔진 계산값 한 칸 → 수 · 글 · 참거짓 · 빈 값(오류는 None)."""
if not isinstance(v, dict):
return v
if "수" in v:
return Decimal(str(v["수"]))
if "글" in v:
return v["글"]
if "참" in v:
return v["참"]
return None
def _source(links: list[dict], box: str, port: str) -> dict | None:
return next((ln["from"] for ln in links if ln.get("to") == {"box": box, "name": port}), None)
def _input_ports(box: dict, local: list[dict], errors: list[str]) -> dict[str, Any]:
"""입력 상자 알약 값 — 요소 name · 표 「표」 · 변수 name(예시값)."""
inp, out = box.get("input") or {}, {}
if inp.get("mode") == "group":
group = inp.get("group")
asks = [
(el.get("name"), {"ref": el.get("ref"), "unit": el.get("unit")})
for el in inp.get("elements") or []
]
if inp.get("table"):
asks.append((TABLE_PORT, {"table": inp["table"]}))
for name, ask in asks:
got = one_value({"group": group, **ask}, local)
out[name] = (got.get("rows") or got.get("value")) if got["ok"] else None
if not got["ok"]:
errors.append(f"「{box.get('name')} · {name}」 {got.get('reason')}")
for v in inp.get("variables") or []:
out[v.get("name")] = v.get("example")
return out
def compute(logic: dict, local: list[dict]) -> tuple[dict, dict]:
"""함수 상자 시트를 한 통합문서로 셈 → (값을 되돌려 넣은 다이어그램, 계산 결과)."""
boxes = {b.get("id"): b for b in logic.get("boxes") or []}
links = logic.get("links") or []
errors: list[str] = []
ports = {
i: _input_ports(b, local, errors) for i, b in boxes.items() if b.get("kind") == "input"
}
funcs = [b for b in boxes.values() if b.get("kind") == "function" and b.get("func")]
sheets, fills = [], []
for b in funcs: # 한 통합문서 — 시트 id · 이름 = 상자 id
f = b["func"]
sheet = {**copy.deepcopy(f.get("sheet") or {}), "id": b["id"], "이름": b["id"]}
cells = sheet.setdefault("칸", {})
for v in f.get("variables") or []:
if v.get("cell"):
_place(cells, v["cell"], v.get("example"))
for p in f.get("inputs") or []:
src = _source(links, b["id"], p.get("port"))
if src is None or src.get("box") not in boxes:
continue
up = boxes[src["box"]]
if up.get("kind") == "function":
at = ((up.get("func") or {}).get("output") or {}).get("cell") or ""
_place(cells, p["cell"], {"식": f"'{up['id']}'!{at}"})
fills.append((b["id"], p["cell"], ("ref", up["id"], at)))
else:
value = ports.get(up["id"], {}).get(src.get("name"))
_place(cells, p["cell"], value)
fills.append((b["id"], p["cell"], ("value", value, None)))
sheets.append(sheet)
top = [c.get("서식", 0) for s in sheets for c in s["칸"].values() if isinstance(c, dict)]
book = _num(
{"종류": "통합문서", "판": 1, "서식": [{}] * (int(max(top, default=0)) + 1), "시트": sheets}
)
try:
calc = spreadsheet.recalc_workbooks([book])[0] if sheets else {}
except spreadsheet.RecalcError as e:
raise store.StoreError(503, str(e)) from e
def cell_value(box_id: str, cell: str) -> Any:
hit = (calc.get(box_id) or {}).get(cell)
if hit is None:
sheet = next(s for s in book["시트"] if s["id"] == box_id)
return (sheet["칸"].get(cell) or {}).get("값")
if isinstance(hit, dict) and "오류" in hit:
errors.append(f"「{boxes[box_id].get('name')}」 {cell} 칸 계산 오류 {hit.get('오류')}")
return _plain(hit)
for b in funcs:
out = b["func"].get("output") or {}
ports[b["id"]] = {out.get("name"): cell_value(b["id"], out.get("cell") or "")}
outputs, cost = {}, {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}}
for b in boxes.values(): # 출력 상자 · 원가
if b.get("kind") != "output":
continue
src = _source(links, b["id"], OUTPUT_PORT)
value = ports.get(src["box"], {}).get(src.get("name")) if src else None
outputs[b["id"]] = value
kind = (b.get("output") or {}).get("cost")
if (
kind in COSTS
and isinstance(value, (int, float, Decimal))
and not isinstance(value, bool)
):
cost[kind] += Decimal(str(value))
elif kind == "값":
cost["값들"][b.get("name")] = value
cost["계"] = sum(cost[k] for k in COSTS)
done = copy.deepcopy(logic) # 받는 칸에 값만 되돌려 넣음 — 시트 사이 참조는 셈한 값으로
for b in done.get("boxes") or []:
if b.get("kind") != "function" or not b.get("func"):
continue
cells = b["func"].setdefault("sheet", {}).setdefault("칸", {})
for v in b["func"].get("variables") or []:
if v.get("cell"):
_place(cells, v["cell"], v.get("example"))
for box_id, cell, (how, a, at) in fills:
if box_id == b["id"]:
_place(cells, cell, cell_value(a, at) if how == "ref" else a)
return done, {"ports": ports, "outputs": outputs, "cost": cost, "errors": errors}
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
done, result = compute(logic, logic.get("userElements") or [])
if not key:
used = [int(m.group(1)) for p in DATA.glob("CF-*.json") if (m := _KEY.match(p.stem))]
key = f"CF-{max(used, default=0) + 1:04d}"
done = {**done, "key": key, "saved": datetime.now().isoformat(timespec="seconds")}
_write(DATA / f"{key}.json", _num(done))
return {"key": key, "logic": done, "values": result}
# ── 길 ────────────────────────────────────────────────────────────────
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(values, body.items, body.userElements)
@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)
@@ -7,7 +7,7 @@
"id": "in-var",
"kind": "input",
"name": "쌓기 조건",
"color": "#3b82f6",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 40,
@@ -49,7 +49,7 @@
"id": "in-poom",
"kind": "input",
"name": "메쌓기 품표",
"color": "#3b82f6",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 300,
@@ -67,7 +67,7 @@
"id": "in-rate",
"kind": "input",
"name": "높이 증가율표",
"color": "#3b82f6",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 460,
@@ -85,7 +85,7 @@
"id": "in-labor",
"kind": "input",
"name": "인력",
"color": "#3b82f6",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 620,
@@ -113,7 +113,7 @@
"id": "fn-rate",
"kind": "function",
"name": "증가율 찾기",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "%",
"x": 340,
"y": 420,
@@ -179,7 +179,7 @@
"id": "fn-poom0",
"kind": "function",
"name": "석공 품",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "인/㎡",
"x": 640,
"y": 80,
@@ -275,7 +275,7 @@
"id": "fn-poom1",
"kind": "function",
"name": "보통인부 품",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "인/㎡",
"x": 640,
"y": 300,
@@ -371,7 +371,7 @@
"id": "fn-pay0",
"kind": "function",
"name": "석공 노무비",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "원/㎡",
"x": 940,
"y": 80,
@@ -434,7 +434,7 @@
"id": "fn-pay1",
"kind": "function",
"name": "보통인부 노무비",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "원/㎡",
"x": 940,
"y": 300,
@@ -497,7 +497,7 @@
"id": "fn-sum",
"kind": "function",
"name": "노무비 합",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "원/㎡",
"x": 1240,
"y": 190,
@@ -560,7 +560,7 @@
"id": "out-pay",
"kind": "output",
"name": "노무비",
"color": "#10b981",
"color": "#27ae60",
"unit": "원/㎡",
"x": 1520,
"y": 190,
@@ -7,7 +7,7 @@
"id": "in-var",
"kind": "input",
"name": "바탕 값",
"color": "#3b82f6",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 80,
@@ -34,7 +34,7 @@
"id": "fn-rate",
"kind": "function",
"name": "공구손료",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "원",
"x": 340,
"y": 80,
@@ -97,7 +97,7 @@
"id": "out-mat",
"kind": "output",
"name": "재료비",
"color": "#10b981",
"color": "#27ae60",
"unit": "원",
"x": 640,
"y": 80,
@@ -7,7 +7,7 @@
"id": "in-var",
"kind": "input",
"name": "덤프트럭 조건",
"color": "#3b82f6",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 80,
@@ -46,7 +46,7 @@
"id": "fn-q",
"kind": "function",
"name": "시간당 작업량",
"color": "#f59e0b",
"color": "#7b4fd6",
"unit": "㎥/시간",
"x": 340,
"y": 80,
@@ -129,7 +129,7 @@
"id": "out-q",
"kind": "output",
"name": "시간당 작업량",
"color": "#10b981",
"color": "#27ae60",
"unit": "㎥/시간",
"x": 640,
"y": 80,
+5
View File
@@ -648,6 +648,11 @@ app.include_router(
__import__("M01_MasterData.SheetLogic.M01_SL_Router", fromlist=["router"]).router,
dependencies=system_admin_only,
)
# 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 줄도 지움
app.include_router(
__import__("M02_MasterTemplete.CostFlow.M02_CF_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)
+307
View File
@@ -0,0 +1,307 @@
"""단가산출 다이어그램 시험 길(`M02_MasterTemplete/CostFlow/M02_CF_Router.py`) 시험 — PLAN 35-7.
· Model CfLogic(boxes · links · userElements) 저장 → 열기 → 목록 → 지우기 · 견본(`_sample`)은 읽기만
· 저장 때 입력 값을 마스터에서 다시 받고 함수 상자 시트를 한 통합문서로 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 m01_router
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete.CostFlow import M02_CF_Router as cf
REAL = store.FOLDER
OWN = "로직_자체.json"
SAMPLE = "QF999995"
TABLE = {
"키": SAMPLE,
"원문번호": "",
"구분": "",
"상세구분": "",
"이름": "견본 높이 표",
"기준": "1㎡",
"출처": "자체",
"조건": {"돌": "고르기", "높이": "범위"},
"범위규칙": "초과·이하",
"값칸": {"석공": "인", "증가율": "%"},
"줄": [
{"돌": "깬돌", "높이": [None, 3], "석공": 0.5, "증가율": [80, 100]},
{"돌": "견치돌", "높이": 4, "석공": 0.7},
],
"주": [],
}
HEAD = ["돌", "높이·아래", "높이·위", "석공", "증가율·아래", "증가율·위"]
NO_NODE = shutil.which("node") is None
def _first(name: str, pred) -> dict:
rows = json.loads((store.FOLDER / name).read_text(encoding="utf-8"))["줄"]
return next(r for r in rows if pred(r))
def _labor() -> tuple[str, Decimal]:
row = _first(
"인력.json",
lambda r: (
r.get("상태") == "공표" and not r.get("준용") and isinstance(r.get("값"), (int, float))
),
)
return row["키"], Decimal(str(row["값"]))
def _box(id_: str, kind: str, name: str, **part) -> dict:
return {"id": id_, "kind": kind, "name": name, "color": "", "unit": "-", "x": 0, "y": 0, **part}
def _func(sheet: dict, inputs: list, output: dict, variables: list | None = None) -> dict:
return {"sheet": sheet, "inputs": inputs, "variables": variables or [], "output": output}
def _link(i: str, a: str, pa: str, b: str, pb: str) -> dict:
return {"id": i, "from": {"box": a, "name": pa}, "to": {"box": b, "name": pb}}
def _logic(labor: str, key: str | None = None) -> dict:
"""노임 × 2 × 면적 × (1 + 할증%) = 노무비 → +1 = 경비(함수 → 함수) · 표 블록 E1 → H3 = 품(값)
· 브라우저가 보낸 받는 칸 값(999)은 엉터리."""
s1 = {"id": "s1", "이름": "노무", "칸": {"A1": {"값": 999}, "B1": {"식": "A1*2*A2*(1+A3/100)"}}}
group = {"mode": "group", "elements": [], "variables": []}
return {
"key": key,
"name": "견본 다이어그램",
"resultUnit": "원/㎡",
"boxes": [
_box("in1", "input", "인력", input={**group, "group": "인력",
"elements": [{"ref": labor, "name": "석공", "spec": "", "unit": "인"}]}),
_box("in2", "input", "표",
input={**group, "group": "소요량", "table": {"key": SAMPLE}}),
_box("in3", "input", "면적", input={"mode": "variable", "elements": [], "variables": [
{"name": "면적", "type": "number", "unit": "㎡", "example": 3}]}),
_box("f1", "function", "노무 셈", func=_func(
s1, [{"port": "노임", "cell": "A1"}, {"port": "면적", "cell": "A2"}],
{"cell": "B1", "name": "노무비", "unit": "원"},
[{"name": "할증", "type": "number", "unit": "%", "example": 10, "cell": "A3"}])),
_box("f2", "function", "더하기", func=_func(
{"id": "s2", "이름": "더하기", "칸": {"B1": {"식": "A1+1"}}},
[{"port": "앞", "cell": "A1"}], {"cell": "B1", "name": "합", "unit": "원"})),
_box("f3", "function", "표 찾기", func=_func(
{"id": "s3", "이름": "표 찾기", "칸": {"A1": {"식": "H3"}}},
[{"port": "표", "cell": "E1"}], {"cell": "A1", "name": "품", "unit": "인"})),
_box("o1", "output", "노무비", output={"cost": "노무비", "unit": "원"}),
_box("o2", "output", "경비", output={"cost": "경비", "unit": "원"}),
_box("o3", "output", "품", output={"cost": "값", "unit": "인"}),
],
"links": [
_link("l1", "in1", "석공", "f1", "노임"),
_link("l2", "in3", "면적", "f1", "면적"),
_link("l3", "f1", "노무비", "f2", "앞"),
_link("l4", "in2", "표", "f3", "표"),
_link("l5", "f1", "노무비", "o1", "값"),
_link("l6", "f2", "합", "o2", "값"),
_link("l7", "f3", "품", "o3", "값"),
],
"userElements": [],
} # fmt: skip
@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")
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
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(m01_router.router)
app.include_router(cf.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 _d(v) -> Decimal:
return Decimal(str(v))
@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/cf/save", {"logic": _logic(labor)})
assert saved["key"] == "CF-0001"
v = saved["values"]
labor_cost = price * 2 * 3 * Decimal("1.1")
assert _d(v["ports"]["f1"]["노무비"]) == labor_cost
assert _d(v["ports"]["f2"]["합"]) == labor_cost + 1 # 함수 → 함수(직렬)
assert _d(v["ports"]["f3"]["품"]) == Decimal("0.7") # 표 블록 E1 부터 → H3
assert _d(v["cost"]["노무비"]) == labor_cost and _d(v["cost"]["경비"]) == labor_cost + 1
assert _d(v["cost"]["계"]) == 2 * labor_cost + 1 and _d(v["cost"]["값들"]["품"]) == Decimal(
"0.7"
)
assert v["errors"] == []
boxes = {b["id"]: b for b in saved["logic"]["boxes"]}
c1 = boxes["f1"]["func"]["sheet"]["칸"]
assert c1["A1"]["값"] == price and c1["A2"]["값"] == 3 and c1["A3"]["값"] == 10 # 999 는 버림
assert c1["B1"] == {"식": "A1*2*A2*(1+A3/100)"} and boxes["f1"]["func"]["sheet"]["id"] == "s1"
assert _d(boxes["f2"]["func"]["sheet"]["칸"]["A1"]["값"]) == labor_cost # 참조 아닌 값으로
c3 = boxes["f3"]["func"]["sheet"]["칸"]
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/cf/list").json()["logics"]
assert [(x["key"], x["sample"]) for x in listed] == [("CF-0001", False), ("견본 돌쌓기", True)]
sample = client.get("/api/m02/cf/get", params={"key": "견본 돌쌓기"}).json()
assert sample["sample"] is True
assert _post(client, "/api/m02/cf/save", {"logic": sample["logic"]})["key"] == "CF-0002"
_post(client, "/api/m02/cf/delete", {"key": "견본 돌쌓기"}, want=403)
renamed = {**saved["logic"], "name": "고친 이름"}
assert _post(client, "/api/m02/cf/save", {"logic": renamed})["key"] == "CF-0001"
got = client.get("/api/m02/cf/get", params={"key": "CF-0001"}).json()["logic"]
assert (
got["name"] == "고친 이름" and got["boxes"][3]["func"]["sheet"]["칸"]["A1"]["값"] == price
)
_post(client, "/api/m02/cf/delete", {"key": "CF-0002"})
assert client.get("/api/m02/cf/get", params={"key": "CF-0002"}).status_code == 404
_post(client, "/api/m02/cf/save", {"logic": _logic(labor, "CF-0009")}, want=404)
_post(client, "/api/m02/cf/save", {"logic": {**_logic(labor), "name": " "}}, want=400)
_post(client, "/api/m02/cf/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")) == ["CF-0001.json"]
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_빈_입력_값은_쉬운_말(client: TestClient) -> None:
labor, _ = _labor()
logic = _logic(labor)
logic["boxes"][0]["input"]["elements"][0]["ref"] = "LB999999"
v = _post(client, "/api/m02/cf/save", {"logic": logic})["values"]
assert v["errors"] and "석공" in v["errors"][0]
def test_입력_값_그룹마다(client: TestClient) -> None:
labor, price = _labor()
mat = _first(
"재료_자재품목.json",
lambda r: isinstance(r.get("물가자료"), (int, float)) and r.get("구분"),
)
word = f"{mat['이름']} {mat['규격'] or ''}".strip()
cond = {"구분": mat["구분"], "상세구분": mat["상세구분"], "검색어": word}
line = {"종류": "재료", "요소": cond, "이름": "재료", "단위": mat["단위"], "수량": "1",
"비목": "재료비"} # fmt: skip
row = {"키": "", "원문번호": "", "이름": "견본", "결과단위": "원/㎡", "출처": "자체",
"입력": [], "중간": [], "호표": [line], "덧줄": [], "끝수": None} # fmt: skip
want = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": row})["lines"][0]["단가"]
oil = _first("재료_유가전력.json", lambda r: isinstance(r.get("값"), (int, float)))
fx = _first("환율_한국은행환율.json", lambda r: isinstance(r.get("값"), (int, float)))
rate = _first("요율_조달청제비율.json", lambda r: isinstance(r.get("값"), dict))
eq = _first("기계.json", lambda r: isinstance((r.get("값") or {}).get("가격"), (int, float)))
local = [{"id": "UE-0007", "group": "재료", "name": "현장 자재", "spec": "", "unit": "개",
"value": 500}] # fmt: skip
items = [
{"id": "l", "group": "인력", "ref": labor},
{
"id": "m",
"group": "재료",
"ref": json.dumps(cond, ensure_ascii=False),
"unit": mat["단위"],
},
{"id": "o", "group": "유가전력", "ref": oil["키"]},
{"id": "f", "group": "환율", "ref": fx["키"]},
{"id": "r", "group": "요율", "ref": rate["키"]},
{"id": "e", "group": "기계", "ref": eq["키"], "field": "가격"},
{"id": "e2", "group": "기계", "ref": eq["키"]},
{"id": "t", "group": "소요량", "table": {"key": SAMPLE}},
{"id": "t1", "group": "소요량", "table": {"key": SAMPLE, "row": 1}},
{"id": "u", "group": "재료", "ref": "UE-0007"},
{"id": "w", "group": "인력", "ref": oil["키"]},
{"id": "z", "group": "모름", "ref": ""},
]
body = {"items": items, "userElements": local}
got = {v["id"]: v for v in _post(client, "/api/m02/cf/values", body)["values"]}
assert _d(got["l"]["value"]) == price
assert got["m"]["value"] == want # 검색어 묶음 = 기존 /calc 재료 줄 단가
assert got["o"]["value"] == oil["값"] and got["f"]["value"] == fx["값"]
assert got["r"]["value"] == rate["값"]["rate_percent"] and got["r"]["unit"] == "%"
assert got["e"]["value"] == eq["값"]["가격"]
assert got["t"]["rows"][0] == HEAD
assert got["t1"]["rows"] == [HEAD, ["견치돌", 4, 4, 0.7, None, None]]
assert got["u"]["value"] == 500 and got["u"]["unit"] == "개"
for k in ("w", "z", "e2"): # 기계 묶음은 칸을 골라야 함
assert got[k]["ok"] is False and got[k]["reason"], got[k]
def test_사용자_요소_만들기_지우기(client: TestClient, folder: Path) -> None:
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
url = "/api/m02/cf/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/cf/values", {"items": [{"id": "x", "group": "재료", "ref": "UE-0002"}]}
)
assert got["values"][0]["ok"] is False # 값이 비어 있으면 쉬운 말
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before # 마스터에 안 씀
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
cf.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/m02/cf/list").status_code == 403
assert client.post("/api/m02/cf/values", json={"items": []}).status_code == 403
assert client.get("/api/m02/cf/user-elements").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/cf/list").json()["logics"]] == ["견본 돌쌓기"]
+13 -1
View File
@@ -10,6 +10,7 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess
from decimal import Decimal
@@ -89,6 +90,17 @@ def _order(lg: dict) -> list[dict]:
return [boxes[i] for i in done]
# Model 기본 색(`M02_CF_Model.ts` BOX_COLOR) — 견본 색은 이것과 같아야 함
BOX_COLOR = dict(
re.findall(
r'(\w+): "(#[0-9a-fA-F]{6})"',
re.search(
r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_CF_Model.ts").read_text(encoding="utf-8")
).group(1),
)
)
@pytest.mark.parametrize("name", ["masonry", "rate", "value"])
def test_견본_모양(name: str) -> None:
lg = SAMPLES[name]
@@ -100,7 +112,7 @@ def test_견본_모양(name: str) -> None:
for b in lg["boxes"]:
assert b["kind"] in ("input", "function", "output")
assert (
b["color"].startswith("#")
b["color"] == BOX_COLOR[b["kind"]]
and b["unit"]
and isinstance(b["x"], int)
and isinstance(b["y"], int)