Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]] == ["견본 돌쌓기"]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user