diff --git a/M02_MasterTemplete/CostFlow/.gitignore b/M02_MasterTemplete/CostFlow/.gitignore deleted file mode 100644 index c10c784e3..000000000 --- a/M02_MasterTemplete/CostFlow/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_data/ diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Api.ts b/M02_MasterTemplete/CostFlow/M02_CF_Api.ts deleted file mode 100644 index 407791d9d..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Api.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* ============================================================================= - * M02_CF_Api.ts - * 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 받아 - * 함수 상자 시트를 같은 엔진으로 셈하고 받는 칸에 값만 되돌려 넣음) · 지우기 · 입력 값 한 번에 받기 · - * 사용자 요소. 모양 = sub7 Model(CfLogic · CfGroup · CfUserElement) · 아래는 Api 몫만. - * 서버 = `M02_CF_Router.py`(`/api/m02/cf/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import type { CfGroup, CfLogic, CfUserElement } from "./M02_CF_Model"; - -/** 돈 로직 결과에서 꺼낼 칸(기계 시간당 경비) */ -export type CfField = "노무비" | "재료비" | "경비" | "계"; - -/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소 `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; -} - -/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ -export interface CfAnswer { - id: string; - ok: boolean; - value?: number | string | null; - rows?: (string | number | null)[][]; - name?: string; - unit?: string; - reason?: string; -} - -/** 서버 계산 결과 — 저장 답 */ -export interface CfComputed { - /** 상자 id → 내보내는 알약 이름 → 값(표는 칸 덩어리) */ - ports: Record>; - /** 출력 상자 id → 값 */ - outputs: Record; - cost: { - 노무비: number; - 재료비: number; - 경비: number; - 값들: Record; - 계: number; - }; - /** 쉬운 말 */ - errors: string[]; -} - -/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */ -export interface CfListItem { - key: string; - name: string; - resultUnit: string; - saved: string; - sample: boolean; -} - -/** 저장 답 — `logic` 은 받는 칸에 서버 값이 들어간 다이어그램 */ -export interface CfSaved { - key: string; - logic: CfLogic; - values: CfComputed; -} - -/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */ -export class CfError extends Error { - constructor( - public status: number, - public detail: unknown, - ) { - super(typeof detail === "string" ? detail : JSON.stringify(detail)); - } -} - -async function send(path: string, body?: unknown): Promise { - 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 { - return (await send<{ logics: CfListItem[] }>("/list")).logics; -} - -export async function cfGet(key: string): Promise { - return (await send<{ logic: CfLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic; -} - -/** 저장 — 서버가 입력 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) · - * 견본을 저장하면 새 번호 */ -export async function cfSave(logic: CfLogic): Promise { - return send("/save", { logic }); -} - -export async function cfDelete(key: string): Promise { - await send<{ key: string }>("/delete", { key }); -} - -/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 · - * userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */ -export async function cfValues( - items: CfQuery[], - userElements: CfUserElement[] = [], -): Promise { - return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values; -} - -/** 사용자 요소 목록 */ -export async function cfUserElements(): Promise { - return (await send<{ elements: CfUserElement[] }>("/user-elements")).elements; -} - -/** 사용자 요소 만들기(공용 목록 `_data`) — id(`UE-0001`)는 서버가 줌 · 마스터에 안 씀 */ -export async function cfUserElementAdd(element: Omit): Promise { - return (await send<{ element: CfUserElement }>("/user-elements", { op: "add", element })).element; -} - -export async function cfUserElementDelete(id: string): Promise { - await send<{ id: string }>("/user-elements", { op: "delete", id }); -} - -const PLAIN: Record = { - 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] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요"; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css deleted file mode 100644 index 467e29d7a..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css +++ /dev/null @@ -1,276 +0,0 @@ -/* 단가산출 다이어그램 — 자유 배치 */ -.m02-cfdg { - --cfdg-in: var(--color-accent); - --cfdg-out: #c2610c; - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - font-size: var(--text-body-sm); -} - -:root[data-theme="dark"] .m02-cfdg { - --cfdg-out: #e5a866; - --cfdg-lift: #fff; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .m02-cfdg { - --cfdg-out: #e5a866; - --cfdg-lift: #fff; - } -} - -.m02-cfdg__bar, -.m02-cfdg__tools { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m02-cfdg__tools { - min-height: 1.75rem; -} - -.m02-cfdg__title { - font-size: var(--text-body); -} - -.m02-cfdg__gap { - flex: 1; -} - -.m02-cfdg__zoom { - min-width: 3rem; - text-align: center; - font-variant-numeric: tabular-nums; - color: var(--color-text-secondary); -} - -.m02-cfdg__note { - color: var(--color-danger); -} - -.m02-cfdg__hint { - margin: 0; - color: var(--color-text-muted); -} - -/* 판 — 화면 위쪽 큰 영역 */ -.m02-cfdg__view { - position: relative; - height: max(360px, 55vh); - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background-color: var(--color-canvas); - background-image: radial-gradient( - color-mix(in srgb, var(--color-text-muted) 35%, transparent) 1px, - transparent 1px - ); - background-size: 20px 20px; - cursor: grab; - touch-action: none; -} - -.m02-cfdg__world { - position: absolute; - inset: 0 auto auto 0; - width: 1px; - height: 1px; - transform-origin: 0 0; -} - -.m02-cfdg__lines { - position: absolute; - inset: 0 auto auto 0; - width: 1px; - height: 1px; - overflow: visible; - pointer-events: none; -} - -/* 상자 — 종류별 기본 색 · 상자마다 바꾼 색(--cf-color) */ -.m02-cfdg__box { - position: absolute; - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 11rem; - max-width: 16rem; - padding: 0 0 var(--spacing-8); - border: 1px solid color-mix(in srgb, var(--cf-color) 55%, var(--color-border)); - border-radius: var(--radius-lg); - background: var(--color-surface); - box-shadow: 0 1px 3px rgb(0 0 0 / 12%); - cursor: pointer; -} - -.m02-cfdg__box:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - -.m02-cfdg__box.is-selected { - border: 2px solid var(--cf-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-color) 22%, transparent); -} - -.m02-cfdg__head { - display: grid; - grid-template-columns: 1fr auto; - align-items: baseline; - gap: 0 var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; - background: color-mix(in srgb, var(--cf-color) 16%, var(--color-surface)); - cursor: move; -} - -.m02-cfdg__kind { - grid-column: 1; - /* 어두운 화면은 종류 색을 밝게 섞음 */ - color: color-mix(in srgb, var(--cf-color) 55%, var(--cfdg-lift, var(--cf-color))); - font-size: var(--text-caption); - font-weight: 600; -} - -.m02-cfdg__unit { - grid-column: 2; - grid-row: 1; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-cfdg__name { - grid-column: 1 / -1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.m02-cfdg__pills { - display: flex; - justify-content: space-between; - gap: var(--spacing-8); - padding: 0 var(--spacing-8); -} - -.m02-cfdg__ins, -.m02-cfdg__outs { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-cfdg__outs { - align-items: flex-end; -} - -/* 알약 — 받는 파랑 · 내보내는 주황 · 빈 자리 회색 */ -.m02-cfdg__pill { - display: inline-flex; - align-items: baseline; - gap: var(--spacing-4); - max-width: 9rem; - padding: 0 var(--spacing-8); - border: 1px solid var(--cfdg-in); - border-radius: 999px; - background: color-mix(in srgb, var(--cfdg-in) 12%, var(--color-surface)); - color: var(--cfdg-in); - font: inherit; - font-size: var(--text-caption); - cursor: pointer; - white-space: nowrap; -} - -.m02-cfdg__pill.is-out { - border-color: var(--cfdg-out); - background: color-mix(in srgb, var(--cfdg-out) 14%, var(--color-surface)); - color: var(--cfdg-out); - cursor: grab; - touch-action: none; -} - -.m02-cfdg__pill.is-empty { - border-style: dashed; - border-color: var(--color-text-muted); - background: transparent; - color: var(--color-text-muted); -} - -.m02-cfdg__pill.is-chosen { - box-shadow: 0 0 0 2px currentColor; -} - -.m02-cfdg.is-linking .m02-cfdg__pill.is-target { - box-shadow: 0 0 0 2px var(--cfdg-in); -} - -.m02-cfdg__pill-name { - overflow: hidden; - text-overflow: ellipsis; -} - -.m02-cfdg__pill-value { - color: var(--color-text-secondary); - font-variant-numeric: tabular-nums; -} - -.m02-cfdg__pill-value:empty { - display: none; -} - -/* 선 — 굽은 선 */ -.m02-cfdg__link { - pointer-events: auto; - cursor: pointer; -} - -.m02-cfdg__hit { - fill: none; - stroke: transparent; - stroke-width: 12; -} - -.m02-cfdg__wire { - fill: none; - stroke: var(--color-text-muted); - stroke-width: 1.5; -} - -.m02-cfdg__link.is-strong .m02-cfdg__wire { - stroke: var(--color-accent); - stroke-width: 3; -} - -.m02-cfdg__link.is-dim .m02-cfdg__wire { - opacity: 0.25; -} - -.m02-cfdg__drag { - fill: none; - stroke: var(--cfdg-out); - stroke-width: 2; - stroke-dasharray: 5 4; -} - -/* 함수 상자 흐린 「+」 받는 자리 */ -.m02-cfdg__pill.is-plus { - justify-content: center; - min-width: 2rem; - border-style: dashed; - border-color: var(--color-text-muted); - background: transparent; - color: var(--color-text-muted); - opacity: 0.7; -} - -.m02-cfdg__pill.is-plus:hover, -.m02-cfdg.is-linking .m02-cfdg__pill.is-plus.is-target { - opacity: 1; - border-color: var(--cfdg-in); - color: var(--cfdg-in); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts deleted file mode 100644 index 5ba3662b6..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts +++ /dev/null @@ -1,682 +0,0 @@ -/* ============================================================================= - * M02_CF_Diagram.ts - * 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」) - * 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 · - * 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) · - * 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게. - * SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로). - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; -import { - portsIn, - portsOut, - type CfBox, - type CfKind, - type CfLogic, - type CfPill, - type CfPort, -} from "./M02_CF_Model"; -import "./M02_CF_Diagram.css"; - -const TEXT = { - Title: ["다이어그램", "Diagram"], - K_input: ["입력", "Input"], - K_function: ["함수", "Function"], - K_output: ["출력", "Output"], - AddInput: ["+ 입력", "+ Input"], - AddFunc: ["+ 함수", "+ Function"], - AddOutput: ["+ 출력", "+ Output"], - ZoomIn: ["확대", "Zoom in"], - ZoomOut: ["축소", "Zoom out"], - Fit: ["한눈에", "Fit all"], - Arrange: ["줄 맞추기", "Line up"], - Cancel: ["취소", "Cancel"], - Connect: ["잇기", "Connect"], - Disconnect: ["끊기", "Disconnect"], - Remove: ["상자 지우기", "Remove box"], - AskRemove: [ - "「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐", - "Remove box “{v}”? Its lines go too", - ], - PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"], - Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"], - NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"], - SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"], - NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"], - NoMatch: [ - "같은 이름 받는 알약 없음 — 알약에 바로 놓으세요", - "No pill with that name — drop on a pill", - ], - Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"], - From: ["어디서", "From"], - To: ["어디로", "To"], - Value: ["지금 값", "Now"], - PlusIn: [ - "받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김", - "Drop an output pill here to add an input", - ], - LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], - Hint: [ - "상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대", - "Drag head to move · drag space to pan", - ], -} as const; - -const tt = (key: keyof typeof TEXT, fill: Record = {}): string => - TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? ""); - -/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */ -export const KIND_COLOR: Record = { - input: "#2a6fb0", - function: "#7a4fb5", - output: "#c2610c", -}; - -export interface DiagramOn { - select(id: string | null): void; - move(boxId: string, x: number, y: number): void; - /** 잇기 — 막히면 까닭(쉬운 말) */ - link(from: CfPort, to: CfPort): string | null | void; - unlink(linkId: string): void; - add(kind: CfKind, x: number, y: number): void; - remove(boxId: string): void; -} - -export interface DiagramOpts { - /** 상자 id → 알약 이름 → 지금 값 */ - values?: Record>; - /** 고른 상자 id · 선 id */ - selected?: string | null; -} - -/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */ - -/** 알약 — Model 의 받는 · 내보내는 알약 */ -export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } { - return { ins: portsIn(box), outs: portsOut(box) }; -} - -/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */ -export function resolveDrop( - lg: CfLogic, - from: CfPort, - target: { box: string; name: string | null; dir: "in" | "out" | null } | null, -): { to: CfPort } | { why: string } { - if (!target) return { why: tt("NotIn") }; - if (target.box === from.box) return { why: tt("SameBox") }; - if (target.dir === "out") return { why: tt("NotIn") }; - const box = lg.boxes.find((b) => b.id === target.box); - // 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」) - if (box?.kind === "function" && target.name === null) - return { to: { box: box.id, name: from.name } }; - const ins = box ? pillsOf(box).ins : []; - if (!box || !ins.length) return { why: tt("NoIn") }; - const name = - target.name ?? - ins.find((p) => p.name === from.name)?.name ?? - (ins.length === 1 ? ins[0].name : null); - if (!name) return { why: tt("NoMatch") }; - if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") }; - return { to: { box: box.id, name } }; -} - -/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */ -export function arrange( - lg: CfLogic, - gap = { x: 260, y: 130 }, -): Record { - const depth = new Map(lg.boxes.map((b) => [b.id, 0])); - for (let round = 0; round < lg.boxes.length; round++) { - let moved = false; - for (const l of lg.links) { - const d = (depth.get(l.from.box) ?? 0) + 1; - if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) { - depth.set(l.to.box, d); - moved = true; - } - } - if (!moved) break; - } - const rows = new Map(); - const out: Record = {}; - for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) { - const d = depth.get(b.id) ?? 0; - const r = rows.get(d) ?? 0; - rows.set(d, r + 1); - out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y }; - } - return out; -} - -/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */ -export const FIT_MIN = 0.9; - -/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */ -export function fitView( - rects: { x: number; y: number; w: number; h: number }[], - view: { w: number; h: number }, - pad = 40, -): { zoom: number; panX: number; panY: number } { - if (!rects.length) return { zoom: 1, panX: 0, panY: 0 }; - const x0 = Math.min(...rects.map((r) => r.x)) - pad; - const y0 = Math.min(...rects.map((r) => r.y)) - pad; - const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad; - const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad; - const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); - /** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */ - const place = (size: number, from: number, to: number): number => - Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom; - return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) }; -} - -const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3))); - -/* ── 그리기 ─────────────────────────────────────────────────────── */ - -interface HostState { - selected: string | null; - linking: CfPort | null; - pill: { box: string; name: string; dir: "in" | "out" } | null; - note: string; - zoom: number; - panX: number; - panY: number; - fitted: boolean; - watch?: ResizeObserver; -} - -const states = new WeakMap(); -const SVG = "http://www.w3.org/2000/svg"; - -/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */ -export const showValue = (v: unknown): string => { - if (v === null || v === undefined || v === "") return ""; - if (typeof v === "number") - return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : ""; - if (Array.isArray(v)) { - const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0; - return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`; - } - return String(v).slice(0, 12); -}; - -export function renderDiagram( - host: HTMLElement, - lg: CfLogic, - on: DiagramOn, - opts: DiagramOpts = {}, -): void { - const st: HostState = states.get(host) ?? { - selected: null, - linking: null, - pill: null, - note: "", - zoom: 1, - panX: 0, - panY: 0, - fitted: false, - }; - if (opts.selected !== undefined) st.selected = opts.selected; - states.set(host, st); - const values = opts.values ?? {}; - const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? ""; - const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected }); - const select = (id: string | null): void => { - st.selected = id; - st.pill = null; - st.note = ""; - on.select(id); - redraw(); - }; - const tryLink = (from: CfPort, target: Parameters[2]): void => { - const got = resolveDrop(lg, from, target); - st.linking = null; - st.note = "why" in got ? got.why : on.link(from, got.to) || ""; - redraw(); - }; - - /* 판 · 세상(확대 · 옮김을 한 번에) */ - const world = el("div", { className: "m02-cfdg__world" }); - const svg = document.createElementNS(SVG, "svg"); - svg.classList.add("m02-cfdg__lines"); - world.append(svg); - const view = el("div", { - className: "m02-cfdg__view", - attrs: { "aria-label": tt("Title") }, - children: [world], - }); - const applyView = (): void => { - world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`; - zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`; - }; - /** 화면 점 → 세상 자리 */ - const toWorld = (cx: number, cy: number): { x: number; y: number } => { - const r = view.getBoundingClientRect(); - return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom }; - }; - - /* 상자 */ - const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => { - const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name); - const chosen = st.pill?.box === b.id && st.pill.name === p.name && st.pill.dir === dir; - const btn = el("button", { - className: [ - "m02-cfdg__pill", - `is-${dir}`, - wired ? "" : "is-empty", - chosen ? "is-chosen" : "", - st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "", - ].join(" "), - attrs: { - type: "button", - "data-box": b.id, - "data-name": p.name, - "data-dir": dir, - title: `${p.name} (${p.unit || "-"})`, - }, - children: [ - el("span", { className: "m02-cfdg__pill-name", text: wired ? p.name : `? ${p.name}` }), - el("span", { className: "m02-cfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }), - ], - }); - btn.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir }); - st.pill = chosen ? null : { box: b.id, name: p.name, dir }; - st.linking = null; - st.note = ""; - redraw(); - }); - if (dir === "out") - btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name })); - return btn; - }; - - /** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */ - const plusSlot = (b: CfBox): HTMLElement => { - const slot = el("button", { - className: `m02-cfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`, - text: "+", - attrs: { - type: "button", - "data-box": b.id, - "data-dir": "in", - "data-plus": "1", - title: tt("PlusIn"), - }, - }); - slot.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" }); - st.note = tt("PlusIn"); - redraw(); - }); - return slot; - }; - - const boxEl = (b: CfBox): HTMLElement => { - const { ins, outs } = pillsOf(b); - const head = el("div", { - className: "m02-cfdg__head", - children: [ - el("span", { className: "m02-cfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }), - el("strong", { className: "m02-cfdg__name", text: b.name }), - el("span", { className: "m02-cfdg__unit", text: b.unit || "-" }), - ], - }); - const node = el("div", { - className: `m02-cfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`, - attrs: { - "data-box": b.id, - tabindex: "0", - role: "button", - "aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`, - }, - children: [ - head, - el("div", { - className: "m02-cfdg__pills", - children: [ - el("div", { - className: "m02-cfdg__ins", - children: [ - ...ins.map((p) => pillEl(b, p, "in")), - ...(b.kind === "function" ? [plusSlot(b)] : []), - ], - }), - el("div", { - className: "m02-cfdg__outs", - children: outs.map((p) => pillEl(b, p, "out")), - }), - ], - }), - ], - }); - node.style.left = `${b.x}px`; - node.style.top = `${b.y}px`; - node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]); - node.addEventListener("click", (ev) => { - ev.stopPropagation(); - if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null }); - select(st.selected === b.id ? null : b.id); - }); - node.addEventListener("keydown", (ev) => { - if (ev.target !== node) return; - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - node.click(); - } - const step = ev.shiftKey ? 50 : 10; - const d = { - ArrowLeft: [-step, 0], - ArrowRight: [step, 0], - ArrowUp: [0, -step], - ArrowDown: [0, step], - }[ev.key]; - if (d && st.selected === b.id) { - ev.preventDefault(); - on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); - } - }); - head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node)); - return node; - }; - - world.append(...lg.boxes.map(boxEl)); - - /* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */ - const zoomLabel = el("span", { className: "m02-cfdg__zoom" }); - const setZoom = (z: number, cx?: number, cy?: number): void => { - const r = view.getBoundingClientRect(); - const px = cx ?? r.left + r.width / 2; - const py = cy ?? r.top + r.height / 2; - const at = toWorld(px, py); - st.zoom = clampZoom(z); - st.panX = px - r.left - at.x * st.zoom; - st.panY = py - r.top - at.y * st.zoom; - applyView(); - drawLines(); - }; - const fit = (): void => { - const rects = lg.boxes.map((b) => { - const n = world.querySelector(`.m02-cfdg__box[data-box="${CSS.escape(b.id)}"]`); - return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 }; - }); - const r = view.getBoundingClientRect(); - Object.assign(st, fitView(rects, { w: r.width, h: r.height })); - applyView(); - drawLines(); - }; - const freeSpot = (): { x: number; y: number } => { - const r = view.getBoundingClientRect(); - const c = toWorld(r.left + r.width / 3, r.top + r.height / 3); - let y = c.y; - while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100; - return { x: Math.round(c.x), y: Math.round(y) }; - }; - /** 작은 단추 */ - const sm = ( - label: keyof typeof TEXT, - onClick: () => void, - variant: "ghost" | "filled" | "danger" = "ghost", - ) => createButton({ label: tt(label), size: "sm", variant, onClick }); - const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement => - sm(label, () => { - const at = freeSpot(); - on.add(kind, at.x, at.y); - }); - const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`; - - const selLink = lg.links.find((l) => l.id === st.selected) ?? null; - const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null; - const askRemove = async (b: CfBox): Promise => { - if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id); - }; - const tools: HTMLElement[] = []; - if (st.linking) { - tools.push( - el("span", { text: tt("PickTarget", { v: st.linking.name }) }), - sm("Cancel", () => ((st.linking = null), redraw())), - ); - } else if (st.pill) { - const p = st.pill; - const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.name; - const where = lg.links - .filter((l) => mine(p.dir === "in" ? l.to : l.from)) - .map((l) => endName(p.dir === "in" ? l.from : l.to)) - .join(", "); - const now = `${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`; - const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""; - tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") })); - if (p.dir === "out") - tools.push( - sm( - "Connect", - () => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()), - "filled", - ), - ); - } else if (selLink) { - const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); - tools.push( - el("span", { text: line }), - sm("Disconnect", () => on.unlink(selLink.id), "danger"), - ); - } else if (selBox) { - tools.push(sm("Remove", () => void askRemove(selBox), "danger")); - } - - const bar = el("div", { - className: "m02-cfdg__bar", - children: [ - el("strong", { className: "m02-cfdg__title", text: tt("Title") }), - addBtn("input", "AddInput"), - addBtn("function", "AddFunc"), - addBtn("output", "AddOutput"), - el("span", { className: "m02-cfdg__gap" }), - sm("ZoomOut", () => setZoom(st.zoom / 1.2)), - zoomLabel, - sm("ZoomIn", () => setZoom(st.zoom * 1.2)), - sm("Fit", fit), - sm("Arrange", () => { - for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y); - }), - ], - }); - const root = el("div", { - className: `m02-cfdg${st.linking ? " is-linking" : ""}`, - children: [ - bar, - el("div", { - className: "m02-cfdg__tools", - children: [ - ...tools, - el("span", { className: "m02-cfdg__note", text: st.note }), - el("span", { className: "m02-cfdg__hint", text: tools.length ? "" : tt("Hint") }), - ], - }), - lg.boxes.length ? view : el("p", { className: "m02-cfdg__hint", text: tt("Empty") }), - ], - }); - root.addEventListener("keydown", (ev) => { - if (ev.key === "Escape" && (st.linking || st.pill)) { - st.linking = null; - st.pill = null; - redraw(); - } - if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; - if (selLink) on.unlink(selLink.id); - else if (selBox) void askRemove(selBox); - }); - view.addEventListener("click", (ev) => { - if (ev.target === view || ev.target === world) select(null); - }); - view.addEventListener( - "wheel", - (ev) => { - ev.preventDefault(); - setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); - }, - { passive: false }, - ); - view.addEventListener("pointerdown", (ev) => { - if (ev.target !== view && ev.target !== world && ev.target !== svg) return; - dragPan(ev); - }); - host.replaceChildren(root); - applyView(); - - /* 선 — 알약 자리를 재서 세상 좌표로 */ - function drawLines(): void { - svg.replaceChildren(); - const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => { - const pill = world.querySelector( - `.m02-cfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`, - ); - if (!pill) return null; - const r = pill.getBoundingClientRect(); - return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2); - }; - for (const l of lg.links) { - const a = at(l.from, "out"); - const b = at(l.to, "in"); - if (!a || !b) continue; - const dx = Math.max(40, Math.abs(b.x - a.x) / 2); - const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; - const strong = - st.selected === l.id || - (selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id)); - const g = document.createElementNS(SVG, "g"); - g.setAttribute( - "class", - `m02-cfdg__link${strong ? " is-strong" : ""}${st.selected !== null && !strong ? " is-dim" : ""}`, - ); - g.setAttribute("data-link", l.id); - g.setAttribute("data-ends", [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(",")); - for (const cls of ["m02-cfdg__hit", "m02-cfdg__wire"]) { - const p = document.createElementNS(SVG, "path"); - p.setAttribute("d", d); - p.setAttribute("class", cls); - g.append(p); - } - g.addEventListener("click", (ev) => { - ev.stopPropagation(); - select(st.selected === l.id ? null : l.id); - }); - svg.append(g); - } - } - const firstFit = (): void => { - if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines(); - st.fitted = true; - fit(); - }; - st.watch?.disconnect(); - st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit); - st.watch?.observe(view); - if (view.isConnected) firstFit(); - - /* 끌기 셋 — 상자 · 판 · 선 */ - function follow( - ev: PointerEvent, - move: (m: PointerEvent) => void, - end: (u: PointerEvent, moved: boolean) => void, - ): void { - if (ev.button !== 0) return; - const x0 = ev.clientX; - const y0 = ev.clientY; - let moved = false; - const mv = (m: PointerEvent): void => { - if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return; - moved = true; - move(m); - }; - const up = (u: PointerEvent): void => { - window.removeEventListener("pointermove", mv); - window.removeEventListener("pointerup", up); - if (moved) { - // 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로 - const swallow = (c: MouseEvent): void => c.stopPropagation(); - window.addEventListener("click", swallow, { capture: true, once: true }); - setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); - } - end(u, moved); - }; - window.addEventListener("pointermove", mv); - window.addEventListener("pointerup", up); - } - - function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void { - const start = toWorld(ev.clientX, ev.clientY); - let x = b.x; - let y = b.y; - follow( - ev, - (m) => { - const now = toWorld(m.clientX, m.clientY); - x = Math.round(b.x + now.x - start.x); - y = Math.round(b.y + now.y - start.y); - node.style.left = `${x}px`; - node.style.top = `${y}px`; - drawLines(); - }, - (_u, moved) => { - if (moved) on.move(b.id, x, y); - }, - ); - } - - function dragPan(ev: PointerEvent): void { - const p0 = { x: st.panX, y: st.panY }; - follow( - ev, - (m) => { - st.panX = p0.x + m.clientX - ev.clientX; - st.panY = p0.y + m.clientY - ev.clientY; - applyView(); - }, - () => undefined, - ); - } - - function dragLink(ev: PointerEvent, from: CfPort): void { - ev.stopPropagation(); - const pill = ev.currentTarget as HTMLElement; - const r = pill.getBoundingClientRect(); - const a = toWorld(r.right, r.top + r.height / 2); - let line: SVGPathElement | null = null; - follow( - ev, - (m) => { - const b = toWorld(m.clientX, m.clientY); - if (!line) { - line = document.createElementNS(SVG, "path"); - line.setAttribute("class", "m02-cfdg__drag"); - svg.append(line); - } - line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`); - }, - (u, moved) => { - if (!moved) return; - line?.remove(); - const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null; - const p = hit?.closest(".m02-cfdg__pill"); - const bx = hit?.closest(".m02-cfdg__box"); - tryLink( - from, - p - ? { - box: p.dataset.box ?? "", - name: p.dataset.name ?? null, - dir: (p.dataset.dir as "in" | "out") ?? null, - } - : bx - ? { box: bx.dataset.box ?? "", name: null, dir: null } - : null, - ); - }, - ); - } -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css deleted file mode 100644 index 63f66e9fc..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css +++ /dev/null @@ -1,220 +0,0 @@ -/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */ - -.m02-cf { - position: fixed; - inset: 0; - z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */ - overflow-x: hidden; - overflow-y: auto; - background: var(--color-canvas); - color: var(--color-text); - font-size: var(--text-body); -} - -.m02-cf:focus { - outline: none; -} - -.m02-cf__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -/* 위 띠 — 밀어도 위에 붙어 있음 */ -.m02-cf__head { - position: sticky; - top: 0; - z-index: 2; - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); - padding: var(--spacing-12) var(--spacing-24); - border-bottom: 1px solid var(--color-border); - background: var(--color-surface-raised); -} - -.m02-cf__title { - margin: 0; - font-size: var(--text-subheading); -} - -.m02-cf__trial { - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m02-cf__strip { - display: flex; - flex: 1 1 480px; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-12); -} - -.m02-cf__name { - flex: 1 1 260px; -} - -.m02-cf__actions { - display: flex; - gap: var(--spacing-8); - margin-left: auto; -} - -.m02-cf__body { - padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); -} - -.m02-cf__big { - min-height: 56px; - padding: 0 var(--spacing-24); - font-size: var(--text-body); -} - -/* 시작 화면 */ -.m02-cf__start { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: var(--spacing-24); -} - -.m02-cf__start-col { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m02-cf__start-col h3 { - margin: var(--spacing-8) 0 0; -} - -/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */ -.m02-cf__layout { - display: flex; - flex-direction: column; - gap: var(--spacing-16); -} - -.m02-cf__part { - display: flex; - flex-direction: column; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); -} - -.m02-cf__part h3 { - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-bottom: 1px solid var(--color-border); -} - -.m02-cf__part-body { - padding: var(--spacing-12); -} - -.m02-cf__lower { - display: grid; - grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); - gap: var(--spacing-16); - align-items: start; -} - -.m02-cf__box .m02-cf__part-body { - min-height: 50vh; -} - -.m02-cf__box-slots { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--spacing-12); -} - -.m02-cf__slot { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m02-cf__slot h4 { - margin: 0; -} - -@media (max-width: 1100px) { - .m02-cf__lower, - .m02-cf__start, - .m02-cf__box-slots { - grid-template-columns: minmax(0, 1fr); - } -} - -.m02-cf__list { - display: flex; - flex-direction: column; - gap: var(--spacing-4); -} - -.m02-cf__item { - display: flex; - align-items: center; - gap: var(--spacing-8); - width: 100%; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.m02-cf__item:hover { - border-color: var(--color-accent); -} - -.m02-cf__item-name { - flex: 1; - min-width: 0; -} - -.m02-cf__item-unit { - color: var(--color-text-muted); - font-size: var(--text-body-sm); - white-space: nowrap; -} - -.m02-cf__note { - margin: 0 0 var(--spacing-12); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); - white-space: pre-line; -} - -.m02-cf__note:empty { - display: none; -} - -.m02-cf__color { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - margin-bottom: var(--spacing-8); - font-size: var(--text-body-sm); -} - -.m02-cf__color[hidden] { - display: none; -} - -.m02-cf__color-input { - width: 44px; - height: 32px; - padding: 0; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: none; - cursor: pointer; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts deleted file mode 100644 index d28a72125..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ /dev/null @@ -1,579 +0,0 @@ -/* ============================================================================= - * M02_CF_Entry.ts - * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 - * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 - * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게. - * 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary - * → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S. - * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - createTag, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { CfListItem, CfQuery } from "./M02_CF_Api"; -import type { CfEvalResult, CfValues } from "./M02_CF_Eval"; -import type { CfKind, CfLogic, CfPort } from "./M02_CF_Model"; -import "./M02_CF_Entry.css"; - -/** CostFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ -const loadMods = () => - Promise.all([ - import("./M02_CF_Model"), - import("./M02_CF_Eval"), - import("./M02_CF_Api"), - import("./M02_CF_Diagram"), - import("./M02_CF_FuncPanel"), - import("./M02_CF_OutputPanel"), - import("./M02_CF_Summary"), - import("./M02_CF_Sample"), - import("./M02_CF_InputPanel"), - ]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({ - model, - ev, - api, - diagram, - func, - output, - summary, - sample, - input, - })); -type Mods = Awaited>; - -const TEXT = { - Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], - Title: ["단가산출 다이어그램", "Cost diagram"], - Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], - Close: ["닫기", "Close"], - Loading: ["불러오는 중", "Loading"], - OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], - Start_New: ["새로 만들기", "New"], - Start_Mine: ["내 로직", "My logics"], - Start_MineNone: ["아직 없음", "None yet"], - Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], - Name: ["로직 이름", "Logic name"], - NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], - Unit: ["결과 단위", "Result unit"], - Undo: ["실행 취소", "Undo"], - Save: ["저장", "Save"], - Delete: ["지우기", "Delete"], - Unsaved: ["저장 안 함", "Not saved"], - Diagram: ["다이어그램", "Diagram"], - Box: ["고른 상자", "Chosen box"], - PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], - Summary: ["원가", "Cost"], - Color: ["상자 색", "Box colour"], - NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], - SaveAnyway: [ - "고칠 곳이 있음 — {v} · 그래도 저장할까요?", - "There are issues — {v} · Save anyway?", - ], - Saving: ["저장 중", "Saving"], - Saved: ["저장했음", "Saved"], - SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], - Differs: [ - "저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름", - "Recalculated on save: {s} — differs from the screen value {c}", - ], - Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], - Deleted: ["지웠음", "Deleted"], - Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], - Leave_Ask: [ - "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", - "There are unsaved changes — close without saving?", - ], - Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], -} as const; - -type TextKey = keyof typeof TEXT; - -const te = (key: TextKey, vars: Record = {}): string => - Object.entries(vars).reduce( - (text, [k, v]) => text.replace(`{${k}}`, String(v)), - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, - ); - -const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text }); - -/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ -async function unitChoices(): Promise { - const count = new Map(); - try { - for (const x of await fetchLogics()) { - if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); - } - } catch { - /* 못 받으면 기본 하나 */ - } - const units = [...count.entries()] - .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) - .sort((a, b) => b[1] - a[1]) - .map(([u]) => u); - return units.length ? units : ["원/㎡"]; -} - -/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ -function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] { - return lg.boxes.flatMap((b) => { - const inp = b.input; - if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; - const group = inp.group; - const els = inp.elements - .filter((e) => e.ref && !e.ref.startsWith("UE-")) - .map((e) => { - // 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김 - const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined]; - const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit }; - if (field) q.field = field; - return { q, box: b.id, port: e.name }; - }); - const table = inp.table - ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] - : []; - return [...els, ...table]; - }); -} - -/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ -async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> { - const asks = queriesOf(lg); - if (!asks.length) return { values: {}, errors: [] }; - const answers = await M.api.cfValues( - asks.map((a) => a.q), - lg.userElements as never, - ); - const values: CfValues = {}; - const errors: string[] = []; - for (const a of asks) { - const got = answers.find((x) => x.id === a.q.id); - if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); - else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; - } - return { values, errors }; -} - -/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ -export function createCostFlowButton(): HTMLButtonElement { - return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow }); -} - -function openCostFlow(): void { - /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; - let units: string[] | null = null; - const mods = loadMods(); - - const mayLeave = async (): Promise => - !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - - const body = el("main", { className: "m02-cf__body" }); - const strip = el("div", { className: "m02-cf__strip" }); - const close = createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) paper ? void showStart() : shut(); - })(), - }); - const page = el("div", { - className: "m02-cf", - attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, - children: [ - el("header", { - className: "m02-cf__head", - children: [ - el("h2", { className: "m02-cf__title", text: te("Title") }), - el("span", { className: "m02-cf__trial", text: te("Trial") }), - strip, - close, - ], - }), - body, - ], - }); - const prevOverflow = document.body.style.overflow; - const shut = (): void => { - page.remove(); - document.body.style.overflow = prevOverflow; - }; - page.addEventListener("keydown", (ev) => { - if (ev.defaultPrevented) return; - if (ev.key === "Escape") { - void (async () => { - if (await mayLeave()) shut(); - })(); - return; - } - if (!paper || !(ev.ctrlKey || ev.metaKey)) return; - const key = ev.key.toLowerCase(); - if (key === "z" && !ev.shiftKey) { - ev.preventDefault(); - paper.undo(); - } else if (key === "s") { - ev.preventDefault(); - void paper.save(); - } - }); - - const run = (job: (M: Mods) => Promise): void => { - body.replaceChildren(muted(te("Loading"))); - void mods.then(job).catch((e: unknown) => { - showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error"); - void showStart(); - }); - }; - - /* ---------- 시작 화면 ---------- */ - - const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { - const row = el("button", { - className: "m02-cf__item", - attrs: { type: "button", title }, - children: [ - el("span", { className: "m02-cf__item-name", text: name }), - el("span", { className: "m02-cf__item-unit", text: unit }), - ], - }); - row.addEventListener("click", onClick); - return row; - }; - - const showStart = async (): Promise => { - paper = null; - strip.replaceChildren(); - const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); - const sampleHost = el("div", { className: "m02-cf__list" }); - body.replaceChildren( - el("div", { - className: "m02-cf__start", - children: [ - el("section", { - className: "m02-cf__start-col", - children: [ - bigButton(te("Start_New"), () => - run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), - ), - el("h3", { text: te("Start_Mine") }), - mineHost, - ], - }), - el("section", { - className: "m02-cf__start-col", - children: [el("h3", { text: te("Start_Samples") }), sampleHost], - }), - ], - }), - ); - const M = await mods; - sampleHost.replaceChildren( - ...M.sample.sampleList().map((s) => - listRow( - s.name, - s.resultUnit, - () => - run(async (M2) => { - const lg = M2.sample.sampleLoad(s.id); - if (lg) await showPaper(M2, lg, false); - }), - s.note, - ), - ), - ); - try { - const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); - mineHost.replaceChildren( - ...(mine.length - ? mine.map((x) => - listRow( - x.name, - x.resultUnit, - () => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)), - x.saved, - ), - ) - : [muted(te("Start_MineNone"))]), - ); - } catch (e) { - mineHost.replaceChildren(muted(M.api.cfPlain(e))); - } - }; - const units_ = async (): Promise => (units ??= await unitChoices()); - - /* ---------- 고치는 화면 ---------- */ - - const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise => { - const choices = await units_(); - let lg = first; - let savedLg: CfLogic | null = saved ? first : null; - let stack: CfLogic[] = []; - let selected: string | null = null; - let values: CfValues = {}; - let valueSig = ""; - let notes: string[] = []; - let result: CfEvalResult; - let busy = false; - - const unsaved = createTag(te("Unsaved"), "warning"); - const note = el("p", { className: "m02-cf__note", attrs: { role: "status" } }); - const diagramHost = el("div"); - const pickHint = muted(te("PickBox")); - // 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나 - const colorBox = el("input", { - className: "m02-cf__color-input", - attrs: { type: "color", "aria-label": te("Color") }, - }); - colorBox.addEventListener("change", () => { - if (selected) edit(M.model.setColor(lg, selected, colorBox.value)); - }); - const colorRow = el("label", { - className: "m02-cf__color", - children: [el("span", { text: te("Color") }), colorBox], - }); - // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) - const funcHost = el("div"); - const outHost = el("div"); - const inHost = el("div"); - const summaryHost = el("div"); - - const evalNow = (): CfEvalResult => { - try { - return M.ev.evalLogic(lg, values); - } catch (e) { - const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; - } - }; - - const edit = (next: CfLogic): void => { - if (next === lg) return; - stack = [...stack, lg]; - lg = next; - redraw(); - }; - /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */ - const editFromDiagram = (next: CfLogic): void => { - edit(next); - setTimeout(redraw); - }; - - /** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ - const refreshValues = (): void => { - const sig = JSON.stringify(queriesOf(lg).map((a) => a.q)); - if (sig === valueSig) return; - valueSig = sig; - void fetchValues(M, lg).then( - (got) => { - if (sig !== valueSig) return; - values = got.values; - notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : []; - redraw(); - }, - (e: unknown) => { - notes = [te("Values", { v: M.api.cfPlain(e) })]; - redraw(); - }, - ); - }; - - const name = createInputField({ - label: te("Name"), - placeholder: te("NamePlaceholder"), - value: lg.name, - onInput: (v) => edit({ ...lg, name: v }), - }); - name.root.classList.add("m02-cf__name"); - const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices]; - const unit = createSelectField({ - label: te("Unit"), - options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: lg.resultUnit, - onChange: (v) => edit({ ...lg, resultUnit: v }), - }); - - const diagramOn = { - select: (id: string | null): void => { - selected = id; - drawPanel(); - }, - move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), - link: (from: CfPort, to: CfPort): string | null => { - const got = M.model.link(lg, from, to); - if (got.why) return got.why; - editFromDiagram(got.lg); - return null; - }, - unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)), - add: (kind: CfKind, x: number, y: number): void => { - const got = M.model.addBox(lg, kind, x, y); - selected = got.box.id; - editFromDiagram(got.lg); - }, - remove: (id: string): void => { - if (selected === id) selected = null; - editFromDiagram(M.model.removeBox(lg, id)); - }, - }; - - const drawPanel = (): void => { - const box = lg.boxes.find((b) => b.id === selected); - pickHint.hidden = !!box; - colorRow.hidden = !box; - if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; - funcHost.hidden = box?.kind !== "function"; - outHost.hidden = box?.kind !== "output"; - inHost.hidden = box?.kind !== "input"; - if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit }); - if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }); - if (box?.kind === "output") - M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); - }; - - const redraw = (): void => { - result = evalNow(); - refreshValues(); - unsaved.hidden = lg === savedLg; - undo.disabled = !stack.length; - deleteButton.hidden = !lg.key; - M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected }); - drawPanel(); - // 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임 - // ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠) - (M.summary.renderSummary as (...args: unknown[]) => void)( - summaryHost, - lg, - result, - { change: edit }, - { values }, - ); - note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n"); - }; - - const save = async (): Promise => { - if (busy) return; - if (!lg.name.trim()) { - note.textContent = te("NoName"); - return void name.input.focus(); - } - let problems: string[] = []; - try { - problems = M.ev.checkLogic(lg, values); - } catch { - problems = []; - } - const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); - if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return; - busy = true; - note.textContent = te("Saving"); - const screen = result.cost.계; - try { - const got = await M.api.cfSave(lg); - lg = got.logic; - savedLg = lg; - stack = []; - redraw(); - const server = got.values.cost.계; - if (Math.abs(server - screen) > 0.5) - notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; - note.textContent = [...notes, ...result.errors].join("\n"); - showToast(te("Saved"), "success"); - } catch (e) { - note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) }); - } finally { - busy = false; - } - }; - - const doUndo = (): void => { - const back = stack[stack.length - 1]; - if (!back) return; - stack = stack.slice(0, -1); - lg = back; - name.input.value = lg.name; - unit.select.value = lg.resultUnit; - redraw(); - }; - - const doDelete = async (): Promise => { - if (!lg.key || busy) return; - if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return; - try { - await M.api.cfDelete(lg.key); - savedLg = lg; - showToast(te("Deleted"), "success"); - void showStart(); - } catch (e) { - note.textContent = M.api.cfPlain(e); - } - }; - - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); - const deleteButton = createButton({ - label: te("Delete"), - variant: "danger", - onClick: () => void doDelete(), - }); - strip.replaceChildren( - name.root, - unit.root, - unsaved, - el("div", { - className: "m02-cf__actions", - children: [ - undo, - createButton({ label: te("Save"), onClick: () => void save() }), - deleteButton, - ], - }), - ); - body.replaceChildren( - note, - el("div", { - className: "m02-cf__layout", - children: [ - part(te("Diagram"), "m02-cf__diagram", [diagramHost]), - el("div", { - className: "m02-cf__lower", - children: [ - part(te("Box"), "m02-cf__box", [pickHint, colorRow, inHost, funcHost, outHost]), - part(te("Summary"), "m02-cf__summary", [summaryHost]), - ], - }), - ], - }), - ); - paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; - redraw(); - if (!lg.name) name.input.focus(); - }; - - void showStart(); - document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 - document.body.append(page); - page.tabIndex = -1; - page.focus(); -} - -/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */ -function part(title: string, className: string, children: HTMLElement[]): HTMLElement { - return el("section", { - className: `m02-cf__part ${className}`, - children: [el("h3", { text: title }), el("div", { className: "m02-cf__part-body", children })], - }); -} - -function bigButton(label: string, onClick: () => void): HTMLButtonElement { - const button = createButton({ label, variant: "filled", onClick }); - button.classList.add("m02-cf__big"); - return button; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts deleted file mode 100644 index 7c3209560..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts +++ /dev/null @@ -1,210 +0,0 @@ -/* ============================================================================= - * M02_CF_Eval.ts - * 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈 - * (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음 - * 엔진을 정적으로 가져옴 — 화면(Entry)은 CostFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; -import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; -import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_CF_Model"; - -/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ -export type CfValue = number | string | boolean | null | (number | string | null)[][]; - -/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */ -export type CfValues = Record>; - -type Scalar = number | string | boolean | null; - -export interface CfEvalResult { - /** 상자 id → 내보내는 알약 이름 → 값 */ - ports: Record>; - /** 출력 상자 id → 값 */ - outputs: Record; - cost: { - 노무비: number; - 재료비: number; - 경비: number; - /** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */ - 값들: Record; - 계: number; - }; - /** 쉬운 말 */ - errors: string[]; -} - -const TEXT = { - /** {box} {cell} {err} */ - CellError: [ - "「{box}」 {cell} 칸 계산이 막힘 — {err}", - "「{box}」 cell {cell} can't be worked out — {err}", - ], - Cycle: ["돌고 도는 참조", "circular reference"], - /** {box} */ - Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"], - NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"], - /** {box} {pill} */ - EmptyIn: [ - "「{box}」 의 받는 알약 「{pill}」 에 선이 없음", - "Pill 「{pill}」 of 「{box}」 has no line", - ], - NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"], - NoOutput: ["출력 상자가 없음", "There is no output box"], - NoInCell: [ - "「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음", - "Pill 「{pill}」 of 「{box}」 is not placed in a cell", - ], - NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], -} as const; - -const tl = (key: keyof typeof TEXT, fill: Record = {}): string => - Object.entries(fill).reduce( - (s, [k, v]) => s.replace(`{${k}}`, v), - TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, - ); - -/* ── 차례 ───────────────────────────────────────────────────────────────── */ - -/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */ -function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } { - const funcs = lg.boxes.filter((b) => b.kind === "function"); - const done: CfBox[] = []; - const left = new Set(funcs.map((f) => f.id)); - for (let moved = true; moved && left.size;) { - moved = false; - for (const f of funcs) { - if (!left.has(f.id)) continue; - const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box)); - if (waits) continue; - done.push(f); - left.delete(f.id); - moved = true; - } - } - return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) }; -} - -/* ── 칸 ─────────────────────────────────────────────────────────────────── */ - -function putValue(칸: Record, cell: string, value: Scalar): void { - const style = 칸[cell]?.서식; - if (value === null) { - if (style) 칸[cell] = { 서식: style }; - else delete 칸[cell]; - } else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) }; -} - -/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */ -function put(칸: Record, cell: string, value: CfValue): void { - const at = parseA1(cell); - if (!at) return; - if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value); - value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x))); -} - -const fromCalc = (v: CalcValue | undefined): Scalar => { - if (!v) return null; - if ("수" in v) return Number(v.수); - if ("글" in v) return v.글; - if ("참" in v) return v.참; - return v.오류; -}; - -/* ── 계산 ───────────────────────────────────────────────────────────────── */ - -/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */ -function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record { - const given = values[box.id] ?? {}; - const pills = portsOut(box); - const out: Record = {}; - const inp = box.input; - if (!inp) return out; - // portsOut 차례 = 요소 · 표 · 변수 - const sources: CfValue[] = [ - ...(inp.mode === "group" - ? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null) - : []), - ...(inp.mode === "group" && inp.table ? [null] : []), - ...inp.variables.map((v) => v.example), - ]; - pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null))); - return out; -} - -/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */ -function runFunc( - box: CfBox, - incoming: Record, - given: Record, - errors: string[], -): Scalar { - const func = box.func!; - const sheet: Sheet = structuredClone(func.sheet); - for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null); - for (const v of func.variables) - if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); - const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "CF", 서식: [{}], 시트: [sheet] }); - for (const [cell, v] of Object.entries(values[sheet.id] ?? {})) - if ("오류" in v) { - const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류; - errors.push(tl("CellError", { box: box.name, cell, err })); - } - const at = func.output.cell.toUpperCase(); - const c = sheet.칸[at]; - if (!c) return null; - return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); -} - -/** 화면 계산 */ -export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult { - const errors: string[] = []; - const ports: CfEvalResult["ports"] = {}; - for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values); - const into = (box: string): Record => - Object.fromEntries( - lg.links - .filter((l) => l.to.box === box) - .map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]), - ); - const { funcs, loop } = order(lg); - for (const f of loop) errors.push(tl("Loop", { box: f.name })); - for (const f of funcs) - ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) }; - const outputs: CfEvalResult["outputs"] = {}; - const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - for (const b of lg.boxes.filter((x) => x.kind === "output")) { - const v = into(b.id)[OUTPUT_PORT] ?? null; - const scalar: Scalar = Array.isArray(v) ? null : v; - outputs[b.id] = scalar; - const kind = b.output?.cost ?? "값"; - if (kind === "값") cost.값들[b.name] = scalar; - else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; - else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); - } - cost.계 = cost.노무비 + cost.재료비 + cost.경비; - return { ports, outputs, cost, errors }; -} - -/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ -export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { - const out: string[] = []; - for (const b of lg.boxes) { - for (const p of portsIn(b)) - if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) - out.push(tl("EmptyIn", { box: b.name, pill: p.name })); - if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) - out.push(tl("NoCell", { box: b.name })); - for (const i of b.func?.inputs ?? []) - if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port })); - } - if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); - const ev = evalLogic(lg, values); - for (const b of lg.boxes.filter((x) => x.kind === "input")) - for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {})) - if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill)) - out.push(tl("NoValue", { box: b.name, pill })); - return [...out, ...ev.errors]; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css deleted file mode 100644 index 3203eff96..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css +++ /dev/null @@ -1,180 +0,0 @@ -.m01-cfpanel { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - font-size: var(--text-body); - color: var(--color-text-body); -} - -.m01-cfpanel-head, -.m01-cfpanel-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m01-cfpanel-head { - justify-content: space-between; -} - -.m01-cfpanel-title { - font-size: var(--text-subheading); -} - -.m01-cfpanel-unitbox { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - margin-left: auto; -} - -.m01-cfpanel-unit { - width: 6em; - text-align: center; -} - -.m01-cfpanel-cell { - padding: 2px var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - background: var(--color-surface-raised); -} - -.m01-cfpanel-label { - font-weight: var(--font-weight-bold); -} - -.m01-cfpanel-note { - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} - -.m01-cfpanel .ui-btn, -.m01-cfpanel .ui-input, -.m01-cfpanel .ui-select { - min-height: 44px; - font-size: var(--text-body); -} - -.m01-cfpanel-form { - align-items: flex-end; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); -} - -.m01-cfpanel-fieldbox { - display: inline-flex; - flex-direction: column; - gap: 2px; -} - -.m01-cfpanel-field { - width: 9em; -} - -.m01-cfpanel-field.is-num { - width: 6em; -} - -.m01-cfpanel-why, -.m01-cfpanel-msg { - margin: 0; - padding: var(--spacing-8) var(--spacing-12); - border-left: 4px solid var(--color-warning); - background: var(--color-surface-raised); - font-weight: var(--font-weight-medium); -} - -.m01-cfpanel-chip { - display: inline-flex; - align-items: center; - gap: var(--spacing-4); - min-height: 44px; - padding: 0 var(--spacing-12); - border: 2px solid; - border-radius: var(--radius-pills); - background: var(--color-surface); - color: inherit; - font: inherit; - font-weight: var(--font-weight-medium); -} - -button.m01-cfpanel-chip { - cursor: pointer; -} - -button.m01-cfpanel-chip:focus-visible { - outline: 2px solid var(--color-focus-ring); -} - -.m01-cfpanel-chip.is-in { - border-color: #3b82f6; -} - -.m01-cfpanel-chip.is-in:not(.is-placed) { - border-style: dashed; -} - -.m01-cfpanel-chip.is-out { - border-color: #f59e0b; -} - -.m01-cfpanel-chip.is-var { - border-color: #22a06b; -} - -/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ -.m01-cfpanel-sheet { - position: relative; - height: max(640px, calc(100vh - 260px)); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - overflow: hidden; -} - -/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */ -.m01-cfpanel .aislo-cell[data-cf-kind="in"] { - box-shadow: inset 0 0 0 2px #3b82f6; -} - -.m01-cfpanel .aislo-cell[data-cf-kind="var"] { - box-shadow: inset 0 0 0 2px #22a06b; -} - -.m01-cfpanel .aislo-cell[data-cf-kind="out"] { - box-shadow: inset 0 0 0 2px #f59e0b; -} - -.m01-cfpanel .aislo-cell[data-cf-kind]::after { - content: attr(data-cf-name); - position: absolute; - top: 0; - right: 0; - padding: 0 4px; - border-radius: 0 0 0 6px; - background: var(--color-surface); - color: var(--color-text); - font-size: 10px; - line-height: 14px; - pointer-events: none; -} - -.m01-cfpanel .aislo-cell[data-cf-lock]::before { - content: attr(data-cf-lock); - position: absolute; - bottom: 0; - left: 0; - padding: 0 4px; - background: var(--color-surface); - font-size: 10px; - line-height: 14px; - pointer-events: none; -} - -.m01-cfpanel .aislo-cell[data-cf-err] { - outline: 2px dashed var(--color-danger); - outline-offset: -2px; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts deleted file mode 100644 index 84dad666f..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts +++ /dev/null @@ -1,700 +0,0 @@ -/* ============================================================================= - * M02_CF_FuncPanel.ts - * 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) · - * 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류 - * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_CF_Model 타입 그대로 - * 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음) - * 글 [한, 영] 짝은 아래 TEXT - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el, showToast } from "@ui/ui_template_elements"; -import type { - CalcValues, - SpreadsheetHandle, - Workbook, -} from "../../A00_Common/spreadsheet/spreadsheet"; -import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_CF_Model"; -import "./M02_CF_FuncPanel.css"; - -const TEXT = { - Unit: ["단위", "Unit"], - Cell: ["고른 칸", "Selected cell"], - Got: ["받은 알약", "Received pills"], - GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], - NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], - Placed: ["놓임", "placed"], - Free: ["아직 안 놓은 알약", "not placed yet"], - Busy: ["이미 쓰는 칸", "That cell is already in use"], - Locked: [ - "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", - "This cell is filled by the wire — cannot edit", - ], - Vars: ["변수", "Variables"], - MakeVar: ["변수 만들기", "Make variable"], - NoVars: ["아직 변수가 없음", "No variables yet"], - VarName: ["이름", "Name"], - VarType: ["갈래", "Type"], - Number: ["숫자", "Number"], - Choice: ["선택", "Choice"], - Text: ["글", "Text"], - VarUnit: ["단위", "Unit"], - VarMin: ["최소", "Min"], - VarMax: ["최대", "Max"], - VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], - VarExample: ["예시값", "Example value"], - VarCell: ["붙을 칸", "Attach to"], - Make: ["만들기", "Make"], - Cancel: ["취소", "Cancel"], - Remove: ["지움", "Remove"], - NeedName: ["이름을 적음", "Enter a name"], - SameName: ["같은 이름이 이미 있음", "That name already exists"], - NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], - BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], - OutBtn: ["출력 칸으로", "Make output cell"], - OutNow: ["출력", "Output"], - OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], - OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], - Frames: ["틀", "Templates"], - FrameMul: ["곱하기", "Multiply"], - FrameMore: ["할증", "Markup"], - FrameDiv: ["나누기", "Divide"], - FrameIf: ["만약", "If"], - FrameMin: ["최소", "Min"], - FrameMax: ["최대", "Max"], - FrameUp: ["올림", "Round up"], - FrameLook: ["표에서 찾기", "Look up in table"], - FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], - ReadOnly: ["읽기만", "Read only"], - Loading: ["시트를 불러오는 중", "Loading sheet"], - Problem: ["⚠ ", "⚠ "], - Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], - Err_DIV0: ["0으로 나눔", "divides by zero"], - Err_NAME: ["알 수 없는 이름", "unknown name"], - Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], - Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], - Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], - Err_NA: ["찾는 값이 없음", "value not found"], - Err_OTHER: ["수식이 틀림", "formula is wrong"], -} as const; - -const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ -const FRAMES: [keyof typeof TEXT, string][] = [ - ["FrameMul", "=B1*B2"], - ["FrameMore", "=B1*(1+B2/100)"], - ["FrameDiv", "=B1/B2"], - ["FrameIf", '=IF(B3="급",10,0)'], - ["FrameMin", "=MIN(B1,B2)"], - ["FrameMax", "=MAX(B1,B2)"], - ["FrameUp", "=ROUNDUP(B4,3)"], - ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], -]; - -const ERR_KEY: Record = { - "#DIV/0!": "Err_DIV0", - "#NAME?": "Err_NAME", - "#REF!": "Err_REF", - "#VALUE!": "Err_VALUE", - "#CYCLE!": "Err_CYCLE", - "#N/A": "Err_NA", -}; - -type Handlers = { change(lg: CfLogic): void }; - -interface VarForm { - name: string; - type: CfVariable["type"]; - unit: string; - min: string; - max: string; - choices: string; - example: string; - why: string; -} - -interface State { - boxId: string; - lg: CfLogic; - on: Handlers; - readOnly: boolean; - handle: SpreadsheetHandle | null; - /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ - snap: CalcValues; - /** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */ - json: string; - cell: string; - form: VarForm | null; - parts: { - head: HTMLElement; - got: HTMLElement; - vars: HTMLElement; - bar: HTMLElement; - msg: HTMLElement; - sheet: HTMLElement; - }; - observer: MutationObserver | null; - redraw(): void; -} - -const states = new WeakMap(); -let sheetModule: Promise | null = null; -/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ -const loadSheet = (): Promise => - (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); - -const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId); -const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func; -const bookOf = (func: CfFunc): Workbook => ({ - 종류: "통합문서", - 판: 1, - 열: "cf", - 서식: [{}], - 시트: [structuredClone(func.sheet)], - 활성: func.sheet.id, -}); -const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet); - -/* ------------------------------ 바꾸기 ------------------------------ */ - -function patchBox(st: State, make: (b: CfBox) => CfBox): void { - const next: CfLogic = { - ...st.lg, - boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)), - }; - st.lg = next; - st.on.change(next); -} - -const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void => - patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b)); - -const usedCells = (f: CfFunc): Map => { - const m = new Map(); - for (const i of f.inputs) m.set(i.cell, i.port); - for (const v of f.variables) if (v.cell) m.set(v.cell, v.name); - if (f.output.cell) m.set(f.output.cell, f.output.name); - return m; -}; - -function placePort(st: State, port: string, cell: string): void { - const f = funcOf(st); - if (!f || st.readOnly) return; - const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell); - if (other) return void showToast(tf("Busy"), "error"); - patchFunc(st, (x) => ({ - ...x, - inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }], - })); -} - -function makeVariable(st: State): void { - const form = st.form; - const f = funcOf(st); - if (!form || !f) return; - const name = form.name.trim(); - const fail = (key: keyof typeof TEXT): void => { - form.why = tf(key); - drawVars(st); - }; - if (!name) return fail("NeedName"); - const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)]; - const mine = f.variables.find((v) => v.name === name); - if (taken.includes(name) && !mine) return fail("SameName"); - const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) }); - if (busy.has(st.cell)) return fail("Busy"); - let example: number | string | null = form.example.trim() === "" ? null : form.example.trim(); - const v: CfVariable = { - name, - type: form.type, - unit: form.unit.trim() || "-", - example: null, - cell: st.cell, - }; - if (form.type === "number") { - if (example !== null) { - const n = Number(String(example).replace(/,/g, "")); - if (!Number.isFinite(n)) return fail("NotNumber"); - example = n; - } - const lo = form.min.trim() === "" ? null : Number(form.min); - const hi = form.max.trim() === "" ? null : Number(form.max); - if (lo !== null && hi !== null) { - if (!(lo < hi)) return fail("BadRange"); - v.range = [lo, hi]; - } - } else if (form.type === "choice") { - v.choices = form.choices - .split(",") - .map((x) => x.trim()) - .filter(Boolean); - } - v.example = example; - const cell = st.cell; - st.form = null; - patchFunc(st, (x) => { - const sheet = structuredClone(x.sheet); - if (example !== null) sheet.칸[cell] = { 값: example }; - return { - ...x, - sheet, - inputs: x.inputs.filter((i) => i.cell !== cell), - variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v], - }; - }); -} - -/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */ -function incoming(st: State): { port: string; unit: string }[] { - const out = new Map(); - for (const l of st.lg.links) { - if (l.to.box !== st.boxId || out.has(l.to.name)) continue; - const src = st.lg.boxes.find((b) => b.id === l.from.box); - const unit = - src?.func && src.func.output.name === l.from.name - ? src.func.output.unit - : (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ?? - src?.input?.variables.find((v) => v.name === l.from.name)?.unit ?? - src?.unit ?? - "-"); - out.set(l.to.name, unit || "-"); - } - // 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리) - for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-"); - return [...out].map(([port, unit]) => ({ port, unit })); -} - -function problems(st: State): string[] { - const f = funcOf(st); - if (!f) return []; - const out: string[] = []; - for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) { - if (typeof v === "object" && "오류" in v) { - out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`); - } - } - for (const p of incoming(st)) { - if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`); - } - if (!f.output.cell) out.push(tf("OutNone")); - return out; -} - -/* ------------------------------ 칸 위 표시 ------------------------------ */ - -function applyMarks(st: State): void { - const f = funcOf(st); - if (!f) return; - const marks = new Map(); - for (const i of f.inputs.filter((x) => x.cell)) - marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); - for (const v of f.variables) - if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` }); - if (f.output.cell) - marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` }); - const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell)); - const list = problems(st); - const bad = new Set(list.map((p) => p.split(" ")[0])); - for (const c of st.parts.sheet.querySelectorAll(".aislo-cell")) { - const addr = c.dataset.addr ?? ""; - for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a]; - const m = marks.get(addr); - if (m) { - c.dataset.cfKind = m.kind; - c.dataset.cfName = m.label; - } - if (locked.has(addr)) c.dataset.cfLock = "🔒"; - if (bad.has(addr)) c.dataset.cfErr = "1"; - } - st.parts.msg.textContent = list.length - ? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}` - : ""; - st.parts.msg.hidden = !list.length; -} - -/* ------------------------------ 위 줄들 ------------------------------ */ - -function drawHead(st: State): void { - const box = boxOf(st); - const unit = el("input", { - className: "ui-input m01-cfpanel-unit", - attrs: { type: "text", "aria-label": tf("Unit") }, - }); - unit.value = box?.unit ?? "-"; - if (st.readOnly) unit.readOnly = true; - unit.addEventListener("change", () => { - const v = unit.value.trim() || "-"; - unit.value = v; - patchBox(st, (b) => ({ - ...b, - unit: v, - func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func, - })); - }); - st.parts.head.replaceChildren( - el("strong", { className: "m01-cfpanel-title", text: box?.name ?? "" }), - el("span", { className: "m01-cfpanel-cell", text: `${tf("Cell")} ${st.cell}` }), - el("label", { - className: "m01-cfpanel-unitbox", - children: [el("span", { className: "m01-cfpanel-note", text: tf("Unit") }), unit], - }), - ); -} - -function drawGot(st: State): void { - const f = funcOf(st); - const items = incoming(st); - const chips = items.map(({ port, unit }) => { - const at = f?.inputs.find((i) => i.port === port)?.cell; - const b = el("button", { - className: `m01-cfpanel-chip is-in${at ? " is-placed" : ""}`, - text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`, - attrs: { type: "button", draggable: st.readOnly ? "false" : "true" }, - }); - b.addEventListener("click", () => placePort(st, port, st.cell)); - b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port)); - return b; - }); - st.parts.got.replaceChildren( - el("span", { className: "m01-cfpanel-label", text: tf("Got") }), - ...(chips.length ? chips : [el("span", { className: "m01-cfpanel-note", text: tf("NoGot") })]), - ...(chips.length && !st.readOnly - ? [el("span", { className: "m01-cfpanel-note", text: tf("GotHint") })] - : []), - ); -} - -function formField( - st: State, - label: keyof typeof TEXT, - key: "name" | "unit" | "min" | "max" | "choices" | "example", - cls = "", -): HTMLElement { - const form = st.form!; - const input = el("input", { - className: `ui-input m01-cfpanel-field ${cls}`, - attrs: { type: "text", "aria-label": tf(label) }, - }); - input.value = form[key]; - input.addEventListener("input", () => (form[key] = input.value)); - input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st)); - return el("label", { - className: "m01-cfpanel-fieldbox", - children: [el("span", { className: "m01-cfpanel-note", text: tf(label) }), input], - }); -} - -function drawVars(st: State): void { - const f = funcOf(st); - const row: HTMLElement[] = [el("span", { className: "m01-cfpanel-label", text: tf("Vars") })]; - for (const v of f?.variables ?? []) { - const chip = el("span", { - className: "m01-cfpanel-chip is-var", - children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`], - }); - if (!st.readOnly) { - chip.append( - createButton({ - label: "×", - variant: "ghost", - size: "sm", - onClick: () => - patchFunc(st, (x) => ({ - ...x, - variables: x.variables.filter((o) => o.name !== v.name), - })), - }), - ); - chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`); - } - row.push(chip); - } - if (!f?.variables.length) - row.push(el("span", { className: "m01-cfpanel-note", text: tf("NoVars") })); - const parts: HTMLElement[] = [el("div", { className: "m01-cfpanel-row", children: row })]; - if (!st.readOnly) { - const form = st.form; - if (!form) { - parts.push( - el("div", { - className: "m01-cfpanel-row", - children: [ - createButton({ - label: tf("MakeVar"), - variant: "ghost", - size: "sm", - onClick: () => { - st.form = { - name: "", - type: "number", - unit: "", - min: "", - max: "", - choices: "", - example: "", - why: "", - }; - drawVars(st); - }, - }), - ], - }), - ); - } else { - const type = el("select", { - className: "ui-select m01-cfpanel-field", - attrs: { "aria-label": tf("VarType") }, - }); - for (const [v, k] of [ - ["number", "Number"], - ["choice", "Choice"], - ["text", "Text"], - ] as const) { - type.append(el("option", { attrs: { value: v }, text: tf(k) })); - } - type.value = form.type; - type.addEventListener("change", () => { - form.type = type.value as CfVariable["type"]; - drawVars(st); - }); - parts.push( - el("div", { - className: "m01-cfpanel-row m01-cfpanel-form", - children: [ - formField(st, "VarName", "name"), - el("label", { - className: "m01-cfpanel-fieldbox", - children: [el("span", { className: "m01-cfpanel-note", text: tf("VarType") }), type], - }), - formField(st, "VarUnit", "unit"), - ...(form.type === "number" - ? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")] - : []), - ...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []), - formField(st, "VarExample", "example"), - el("span", { className: "m01-cfpanel-cell", text: `${tf("VarCell")} ${st.cell}` }), - createButton({ - label: tf("Make"), - variant: "filled", - size: "sm", - onClick: () => makeVariable(st), - }), - createButton({ - label: tf("Cancel"), - variant: "ghost", - size: "sm", - onClick: () => ((st.form = null), drawVars(st)), - }), - ...(form.why - ? [el("span", { className: "m01-cfpanel-why", text: `⚠ ${form.why}` })] - : []), - ], - }), - ); - } - } - st.parts.vars.replaceChildren(...parts); -} - -function drawBar(st: State): void { - const f = funcOf(st); - if (st.readOnly) { - st.parts.bar.replaceChildren( - el("span", { className: "m01-cfpanel-note", text: tf("ReadOnly") }), - ); - return; - } - const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone"); - st.parts.bar.replaceChildren( - el("div", { - className: "m01-cfpanel-row", - children: [ - createButton({ - label: tf("OutBtn"), - variant: "ghost", - size: "sm", - onClick: () => setOutput(st, st.cell), - }), - el("span", { className: "m01-cfpanel-chip is-out", text: outNow }), - el("span", { className: "m01-cfpanel-note", text: tf("OutOnly") }), - ], - }), - el("div", { - className: "m01-cfpanel-row", - children: [ - el("span", { className: "m01-cfpanel-label", text: tf("Frames") }), - ...FRAMES.map(([key, formula]) => - createButton({ - label: tf(key), - variant: "ghost", - size: "sm", - onClick: () => putFormula(st, formula), - }), - ), - el("span", { className: "m01-cfpanel-note", text: tf("FrameHint") }), - ], - }), - ); -} - -/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */ -function setOutput(st: State, cell: string): void { - const f = funcOf(st); - if (!f) return; - if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) { - return void showToast(tf("Busy"), "error"); - } - const row = cell.replace(/^[A-Z]+/, ""); - const label = f.sheet.칸[`A${row}`]; - const name = String(label?.값 ?? f.output.name ?? "").trim() || cell; - patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } })); -} - -/* ------------------------------ 편집기 ------------------------------ */ - -/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ -function putFormula(st: State, formula: string): void { - const root = st.parts.sheet; - const bar = root.querySelector(".ss-formula"); - const addr = root.querySelector(".ss-addr"); - if (!bar || !addr || st.readOnly) return; - const at = st.cell; - bar.focus(); - bar.value = formula; - bar.dispatchEvent(new Event("input", { bubbles: true })); - bar.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), - ); - addr.value = at; - addr.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), - ); -} - -/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */ -function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] { - for (const cell of Object.values(sheet.칸)) delete cell.서식; - return sheet; -} - -/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */ -function onBook(st: State, book: Workbook): void { - const f = funcOf(st); - if (!f) return; - const sheet = plain(structuredClone(book.시트[0])); - const hit = f.inputs.find( - (i) => i.cell && JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), - ); - if (hit) { - showToast(tf("Locked"), "error"); - st.json = sheetJson(f); - return st.handle?.setDoc(bookOf(f)); - } - st.json = JSON.stringify(sheet); - patchFunc(st, (x) => ({ ...x, sheet })); - setTimeout(() => refreshSnap(st), 0); -} - -/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ -function refreshSnap(st: State): void { - if (!st.handle) return; - st.snap = st.handle.recalc(); - st.redraw(); -} - -async function mountSheet(st: State): Promise { - const { createSpreadsheet } = await loadSheet(); - const f = funcOf(st); - if (!st.parts.sheet.isConnected || st.handle || !f) return; - st.parts.sheet.replaceChildren(); - st.json = sheetJson(f); - st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), { - preview: true, - tabs: false, - readOnly: st.readOnly, - onChange: (b) => onBook(st, b), - onSelect: (s) => { - st.cell = s.칸; - drawHead(st); - if (st.form) drawVars(st); - }, - }); - st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); - st.observer.observe(st.parts.sheet, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["data-addr"], - }); - st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault()); - st.parts.sheet.addEventListener("drop", (e) => { - e.preventDefault(); - const port = e.dataTransfer?.getData("text/plain"); - const cell = (e.target as Element | null)?.closest(".aislo-cell")?.dataset.addr; - if (port && cell) placePort(st, port, cell); - }); - refreshSnap(st); -} - -/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ -export function renderFuncPanel( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: Handlers, - opts: { readOnly?: boolean } = {}, -): void { - const old = states.get(host); - const readOnly = !!opts.readOnly; - if (old && old.boxId === boxId && old.readOnly === readOnly) { - old.lg = lg; - old.on = on; - const f = funcOf(old); - if (old.handle && f && sheetJson(f) !== old.json) { - old.json = sheetJson(f); - old.handle.setDoc(bookOf(f)); - setTimeout(() => refreshSnap(old), 0); - return; - } - return old.redraw(); - } - if (old) { - old.observer?.disconnect(); - old.handle?.destroy(); - } - const parts: State["parts"] = { - head: el("div", { className: "m01-cfpanel-head" }), - got: el("div", { className: "m01-cfpanel-row" }), - vars: el("div", { className: "m01-cfpanel-vars" }), - bar: el("div", { className: "m01-cfpanel-bar" }), - msg: el("p", { className: "m01-cfpanel-msg", attrs: { role: "status" } }), - sheet: el("div", { className: "m01-cfpanel-sheet", text: tf("Loading") }), - }; - const st: State = { - boxId, - lg, - on, - readOnly, - handle: null, - snap: {}, - json: "", - cell: "A1", - form: null, - parts, - observer: null, - redraw: () => { - drawHead(st); - drawGot(st); - drawVars(st); - drawBar(st); - applyMarks(st); - }, - }; - states.set(host, st); - host.classList.add("m01-cfpanel"); - host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); - parts.msg.hidden = true; - st.redraw(); - void mountSheet(st); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.css deleted file mode 100644 index 902b91046..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.css +++ /dev/null @@ -1,145 +0,0 @@ -.m02-cfip { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; - color: var(--color-text); - font-size: var(--text-body-lg, 1.05rem); -} -.m02-cfip h4, -.m02-cfip h5 { - margin: 0; - color: var(--color-text-secondary); -} -.m02-cfip h4 { - font-size: 1.15em; - color: var(--color-text); -} -.m02-cfip__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-body-sm); -} -.m02-cfip__unit { - margin: 0; - font-weight: 600; -} -.m02-cfip__body, -.m02-cfip__chosen, -.m02-cfip__find, -.m02-cfip__stack, -.m02-cfip__list { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} -.m02-cfip__list { - max-height: 28vh; - overflow: auto; -} -.m02-cfip__chips { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} -.m02-cfip__chip, -.m02-cfip__row { - padding: var(--spacing-8) var(--spacing-12); - border: 2px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - cursor: pointer; -} -.m02-cfip__chip { - border-radius: var(--radius-pills); - padding: var(--spacing-4) var(--spacing-12); -} -.m02-cfip__chip.is-on { - border-color: var(--color-accent); - outline: 2px solid var(--color-accent); -} -.m02-cfip__chip:hover, -.m02-cfip__chip:focus-visible, -.m02-cfip__row:hover, -.m02-cfip__row:focus-visible { - border-color: var(--color-accent); -} -.m02-cfip__row { - display: flex; - flex: 1 1 auto; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; - text-align: left; -} -.m02-cfip__rowbox { - display: flex; - gap: var(--spacing-8); - align-items: center; -} -.m02-cfip__bar { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - align-items: center; -} -.m02-cfip__bar > :last-child { - flex: 1 1 180px; -} -.m02-cfip__item { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); -} -.m02-cfip__name { - flex: 1 1 auto; - min-width: 0; -} -.m02-cfip__value { - color: var(--color-accent); -} -.m02-cfip__tag { - margin-left: var(--spacing-8); - padding: 0 var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - font-size: var(--text-caption); -} -.m02-cfip__new, -.m02-cfip__form { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-8); - border: 1px dashed var(--color-border); - border-radius: var(--radius-cards); -} -.m02-cfip__new > summary { - cursor: pointer; - font-weight: 600; -} -.m02-cfip__preview table { - border-collapse: collapse; - font-size: var(--text-body-sm); -} -.m02-cfip__preview th, -.m02-cfip__preview td { - padding: 2px 8px; - border: 1px solid var(--color-border); - white-space: nowrap; - text-align: center; -} -.m02-cfip__preview tr.is-hit td { - background: rgba(240, 190, 40, 0.35); - font-weight: 700; -} -.m02-cfip__preview { - max-height: 24vh; - overflow: auto; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts deleted file mode 100644 index bcaed88ba..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts +++ /dev/null @@ -1,459 +0,0 @@ -/* ============================================================================= - * M02_CF_InputPanel.ts - * 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) · - * 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위 - * M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/cf/user-elements` = 시험 폴더 `_data`) - * 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위) - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { - fetchFiles, - fetchRows, - fetchSubs, - type SubInfo, -} from "../../M01_MasterData/M01_MasterData_Api_Fetch"; -import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_CF_Api"; -import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_CF_Model"; -import { - GROUPS, - M01_OF, - MACHINE_SEP, - USER_PREFIX, - addElement, - elementOf, - machineFields, - queryOf, - reconcileUnit, - removeElement, - setKind, - valueOf, - type M01Row, -} from "./M02_CF_InputPanel_Build"; -import { renderTablePick } from "./M02_CF_InputPanel_Table"; -import { renderVariables } from "./M02_CF_InputPanel_Vars"; -import "./M02_CF_InputPanel.css"; - -/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */ -interface Answer { - id: string; - ok: boolean; - value?: number | string | null; -} -interface UserEl { - id: string; - group: string; - name: string; - spec: string; - unit: string; - value: number | null; -} -const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise; -const apiUsers = cfUserElements as unknown as () => Promise; -const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise; - -const TEXT = { - Kind: ["무엇을 넣을까요?", "What goes in?"], - Variable: ["변수", "Variable"], - 인력: ["인력", "Labor"], - 재료: ["재료", "Material"], - 유가전력: ["유가·전력", "Fuel · power"], - 기계: ["기계", "Machine"], - 소요량: ["소요량", "Quantity table"], - 계수: ["계수", "Factor table"], - 환율: ["환율", "Exchange rate"], - 요율: ["요율", "Rate"], - Chosen: [ - "넣은 요소 — 알약마다 이름 · 값 · 단위", - "Chosen elements — name · value · unit per pill", - ], - NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"], - Remove: ["빼기", "Remove"], - Put: ["넣기", "Add"], - Search: ["이름으로 찾기", "Search by name"], - AllSub: ["전체", "All"], - SubLabel: ["구분", "Group"], - DetailLabel: ["상세구분", "Detail"], - Loading: ["찾는 중…", "Searching…"], - NoRow: ["찾는 요소 없음", "Nothing found"], - Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], - Mine: ["내가 만든 것", "Mine"], - NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"], - NewName: ["이름", "Name"], - NewSpec: ["규격", "Spec"], - NewUnit: ["단위", "Unit"], - NewValue: ["값(숫자)", "Value (number)"], - Make: ["만들어 넣기", "Create and add"], - NeedName: ["이름을 적어 주세요", "Enter a name"], - NeedValue: ["값은 숫자로", "Value must be a number"], - BoxUnit: ["상자 단위", "Box unit"], - Field: ["값 칸", "Value"], - NoValue: ["—", "—"], - Failed: ["불러오지 못함", "Could not load"], - Pills: ["알약 {n} 개", "{n} pills"], - NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); - -/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */ -const plain = (e: unknown): string => { - try { - return cfPlain(e); - } catch { - return e instanceof Error ? e.message : t("Failed"); - } -}; - -const fileCache = new Map(); -/** 그룹의 M01 파일 이름 */ -async function fileOf(group: CfGroup): Promise { - const hit = fileCache.get(group); - if (hit) return hit; - const { group: g, part } = M01_OF[group]; - const files = await fetchFiles(g); - const f = files.find((x) => !part || x.file.includes(part)) ?? files[0]; - fileCache.set(group, f.file); - return f.file; -} - -/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */ -export function renderInputPanel( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, -): void { - host.className = "m02-cfip"; - const box = lg.boxes.find((b) => b.id === boxId); - if (!box || box.kind !== "input") { - host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") })); - return; - } - // 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감 - const fixed = reconcileUnit(lg, boxId); - if (fixed !== lg) return on.change(fixed); - const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] }; - const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력"); - const body = el("div", { className: "m02-cfip__body" }); - - // ① 갈래 — 그룹 여덟 + 변수 - const kinds = el("div", { - className: "m02-cfip__chips", - attrs: { role: "tablist", "aria-label": t("Kind") }, - children: [...GROUPS, "변수" as const].map((k) => { - const b = el("button", { - className: `m02-cfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`, - attrs: { type: "button", role: "tab" }, - text: k === "변수" ? t("Variable") : t(k), - }); - b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k))); - return b; - }), - }); - const unit = el("p", { - className: "m02-cfip__unit", - text: `${t("BoxUnit")}: ${box.unit || "-"}`, - }); - host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body); - - if (kind === "변수") renderVariables(body, lg, boxId, on); - else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on); - else renderGroup(body, lg, box, kind, on); -} - -/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */ -function renderGroup( - host: HTMLElement, - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): void { - const chosen = el("div", { className: "m02-cfip__chosen" }); - const find = el("div", { className: "m02-cfip__find" }); - host.replaceChildren(chosen, find, newForm(lg, box, group, on)); - drawChosen(chosen, lg, box, group, on); - void drawFind(find, lg, box, group, on); -} - -/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ -function drawChosen( - host: HTMLElement, - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): void { - const els = box.input?.elements ?? []; - const values = new Map(); - host.replaceChildren( - el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }), - ...(els.length - ? els.map((e) => { - const val = el("strong", { className: "m02-cfip__value", text: t("NoValue") }); - values.set(e.ref, val); - return el("div", { - className: "m02-cfip__item", - children: [ - el("span", { className: "m02-cfip__name", text: e.name }), - val, - el("span", { className: "m02-cfip__muted", text: e.unit || "-" }), - createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => on.change(removeElement(lg, box.id, e.ref)), - }), - ], - }); - }) - : [el("p", { className: "m02-cfip__muted", text: t("NoneChosen") })]), - ); - if (!els.length) return; - const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); - void apiValues(items, lg.userElements).then( - (answers) => - answers.forEach((a) => { - const node = values.get(a.id); - if (node && a.ok && a.value !== null && a.value !== undefined) - node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value); - }), - // Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로) - () => - els.forEach( - (e) => - void m01Value(group, e).then((v) => { - const node = values.get(e.ref); - if (node && v !== null) node.textContent = fmt(v); - }), - ), - ); -} - -/** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */ -async function m01Value(group: CfGroup, e: CfElementRef): Promise { - if (e.ref.startsWith(USER_PREFIX)) return null; - try { - const [key, field] = e.ref.split(MACHINE_SEP); - const page = await fetchRows( - await fileOf(group), - 1, - 30, - e.name.split(" · ")[0].split(" ")[0], - false, - "", - "", - ); - const row = (page.rows as M01Row[]).find((r) => r["키"] === key); - return row ? valueOf(group, row, field || "가격") : null; - } catch { - return null; - } -} - -/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */ -async function drawFind( - host: HTMLElement, - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): Promise { - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-cfip__list" }); - const count = el("span", { className: "m02-cfip__muted" }); - const bar: HTMLElement[] = []; - let file = ""; - let sub = ""; - let detail = ""; - let subs: SubInfo[] = []; - const all = { value: "", text: t("AllSub") }; - const subBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: t("SubLabel"), - onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()), - }); - const detailBox = createSelectField({ - options: [all], - value: "", - compact: true, - ariaLabel: t("DetailLabel"), - onChange: (v) => ((detail = v), void load()), - }); - const fillDetail = (): void => { - const d = subs.find((s) => s.name === sub)?.details ?? []; - detailBox.root.hidden = !d.length; - detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], ""); - }; - fillDetail(); - let seq = 0; - const load = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") })); - try { - const word = q.input.value.trim(); - const [page, users] = await Promise.all([ - fetchRows(file, 1, 40, word, false, sub, detail), - apiUsers().catch((): UserEl[] => []), - ]); - if (mine !== seq) return; - const known = new Map(); - for (const u of [...lg.userElements, ...users]) - if (u.group === group) known.set(u.id, u as UserEl); - const mineRows: M01Row[] = [...known.values()] - .filter((u) => !word || `${u.name} ${u.spec}`.includes(word)) - .map((u) => ({ - 키: u.id, - 이름: u.name, - 규격: u.spec, - 단위: u.unit, - 값: u.value, - __mine: true, - })); - const rows = [...mineRows, ...(page.rows as M01Row[])]; - count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length }); - list.replaceChildren( - ...(rows.length - ? rows.map((r) => rowOf(r)) - : [el("p", { className: "m02-cfip__muted", text: t("NoRow") })]), - ); - } catch (e) { - if (mine === seq) - list.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) })); - } - }; - const rowOf = (r: M01Row): HTMLElement => { - const fields = group === "기계" ? machineFields(r) : []; - const field = createSelectField({ - options: fields.map((f) => ({ value: f, text: f })), - value: fields.includes("가격") ? "가격" : (fields[0] ?? ""), - compact: true, - ariaLabel: t("Field"), - }); - const pick = (): CfLogic => - addElement(lg, box.id, elementOf(group, r, field.select.value || "가격")); - const v = valueOf(group, r, field.select.value || "가격"); - const unitText = group === "기계" ? "" : String(r["단위"] ?? ""); - const b = el("button", { - className: "m02-cfip__row", - attrs: { type: "button" }, - children: [ - el("span", { - className: "m02-cfip__name", - children: [ - el("strong", { text: String(r["이름"] ?? "") }), - el("span", { className: "m02-cfip__muted", text: ` ${r["규격"] ?? ""}` }), - ...(r["__mine"] ? [el("span", { className: "m02-cfip__tag", text: t("Mine") })] : []), - ], - }), - el("span", { - className: "m02-cfip__muted", - text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(), - }), - ], - }); - b.addEventListener("click", () => on.change(pick())); - return el("div", { - className: "m02-cfip__rowbox", - children: [b, ...(fields.length ? [field.root] : [])], - }); - }; - try { - file = await fileOf(group); - subs = await fetchSubs({ file }).catch(() => []); - subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], ""); - subBox.root.hidden = !subs.length; - bar.push(subBox.root, detailBox.root); - } catch (e) { - host.replaceChildren(el("p", { className: "m02-cfip__muted", text: plain(e) })); - return; - } - let timer = 0; - q.input.addEventListener( - "input", - () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), - ); - host.replaceChildren( - el("div", { className: "m02-cfip__bar", children: [...bar, q.root] }), - count, - list, - ); - void load(); - void USER_PREFIX; -} - -/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */ -function newForm( - lg: CfLogic, - box: CfBox, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): HTMLElement { - const name = createInputField({ type: "text", label: t("NewName"), compact: true }); - const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true }); - const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true }); - const value = createInputField({ type: "text", label: t("NewValue"), compact: true }); - const go = createButton({ - label: t("Make"), - onClick: () => { - const n = - value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, "")); - if (!name.input.value.trim()) return name.setError(t("NeedName")); - if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue")); - name.setError(undefined); - value.setError(undefined); - apiAdd({ - group, - name: name.input.value.trim(), - spec: spec.input.value.trim(), - unit: unit.input.value.trim(), - value: n, - }).then( - (made) => { - const el1 = elementOf(group, { - 키: made.id, - 이름: made.name, - 규격: made.spec, - 단위: made.unit, - 값: made.value, - }); - const withUser: CfLogic = { - ...lg, - userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }], - }; - on.change(addElement(withUser, box.id, el1)); - }, - (e) => showToast(plain(e), "error"), - ); - }, - }); - const d = el("details", { - className: "m02-cfip__new", - children: [ - el("summary", { text: t("NewHead") }), - name.root, - spec.root, - unit.root, - value.root, - go, - ], - }); - return d; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts deleted file mode 100644 index 1e392059b..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts +++ /dev/null @@ -1,294 +0,0 @@ -/* ============================================================================= - * M02_CF_InputPanel_Build.ts - * 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic) - * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_CF_InputPanel.ts` - * ========================================================================== */ - -import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_CF_Model"; - -/** 입력 상자가 고르는 그룹 — 화면 차례 */ -export const GROUPS: CfGroup[] = [ - "인력", - "재료", - "유가전력", - "기계", - "소요량", - "계수", - "환율", - "요율", -]; - -/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */ -export const M01_OF: Record = { - 인력: { group: "인력", part: "" }, - 재료: { group: "재료", part: "자재품목" }, - 유가전력: { group: "재료", part: "유가전력" }, - 기계: { group: "기계", part: "" }, - 소요량: { group: "소요량", part: "" }, - 계수: { group: "계수", part: "" }, - 환율: { group: "환율", part: "" }, - 요율: { group: "요율", part: "" }, -}; - -/** M01 줄 한 개 — 파일 그대로(한글 칸) */ -export type M01Row = Record; - -/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */ -export const USER_PREFIX = "UE-"; - -/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */ -export const MACHINE_SEP = "#"; -export const machineFields = (row: M01Row): string[] => - row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : []; - -const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); -const dictUnit = (row: M01Row, field: string): string => - row["단위"] && typeof row["단위"] === "object" - ? text((row["단위"] as Record)[field]) - : ""; - -/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */ -export function lowest(row: M01Row): number | null { - const found = ["물가자료", "유통물가", "물가정보", "거래가격"] - .map((k) => row[k]) - .filter((v): v is number => typeof v === "number"); - return found.length ? Math.min(...found) : null; -} - -/** 줄 옆에 보일 값 — 모르면 null */ -export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null { - if (group === "재료") return lowest(row); - if (group === "기계") - return typeof (row["값"] as Record | undefined)?.[field] === "number" - ? (row["값"] as Record)[field] - : null; - if (group === "요율") { - const v = row["값"] as Record | undefined; - return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null; - } - return typeof row["값"] === "number" ? row["값"] : null; -} - -/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */ -export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef { - const key = text(row["키"]); - const spec = text(row["규격"]); - if (key.startsWith(USER_PREFIX)) - return { - ref: key, - name: [text(row["이름"]), spec].filter(Boolean).join(" "), - spec, - unit: text(row["단위"]) || "-", - }; - // 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름 - const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec; - const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게) - if (group === "기계") - return { - ref: `${key}${MACHINE_SEP}${field}`, - name: `${named} · ${field}`, - spec, - unit: dictUnit(row, field) || "-", - }; - if (group === "요율") - return { - ref: key, - name: text(row["이름"]), - spec: "", - unit: valueOf(group, row) === null ? "-" : "%", - }; - return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" }; -} - -/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */ -export function pillNames(box: CfBox): string[] { - const i = box.input; - if (!i) return []; - if (i.table) return ["표"]; - return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name); -} - -/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */ -export function queryOf(group: CfGroup, ref: string): Record { - if (group === "기계" && !ref.startsWith(USER_PREFIX)) { - const [key, field] = ref.split(MACHINE_SEP); - return { group, ref: key, field: field || "가격" }; - } - return { group, ref }; -} - -/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */ -export function unitOfInput(input: CfInput): string { - if (input.table) return "-"; - const units = ( - input.mode === "variable" - ? input.variables.map((v) => v.unit) - : input.elements.map((e) => e.unit) - ).map((u) => u || "-"); - return units.length && units.every((u) => u === units[0]) ? units[0] : "-"; -} - -/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ -export function checkVariable(v: CfVariable, others: CfVariable[]): string | null { - const name = v.name.trim(); - if (!name) return "이름을 적어 주세요"; - if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음"; - if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상"; - if (v.type === "number") { - if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함"; - if ( - typeof v.example === "number" && - v.range && - (v.example < v.range[0] || v.example > v.range[1]) - ) - return "예시값이 범위 밖"; - if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자"; - } - if ( - v.type === "choice" && - v.example !== null && - !(v.choices ?? []).map(String).includes(String(v.example)) - ) - return "예시값이 선택 목록에 없음"; - return null; -} - -/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */ - -type Pills = (box: CfBox) => string[]; -const defaultPills: Pills = pillNames; - -const touch = ( - lg: CfLogic, - id: string, - fn: (b: CfBox) => CfBox, - pills: Pills = defaultPills, -): CfLogic => { - const old = lg.boxes.find((b) => b.id === id); - if (!old) return lg; - const next = fn(old); - const boxes = lg.boxes.map((b) => - b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b, - ); - // 알약이 없어진 선은 지움 - const keep = new Set(pills(next)); - const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name)); - return { ...lg, boxes, links }; -}; - -const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] }; - -/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */ -export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => { - const cur = inputOf(b); - if (kind === "변수") - return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } }; - const same = cur.mode === "group" && cur.group === kind; - return { - ...b, - input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] }, - }; - }, - pills, - ); -} - -/** 요소 넣기 — 같은 요소는 한 번만 */ -export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => { - const cur = inputOf(b); - return cur.elements.some((e) => e.ref === el.ref || e.name === el.name) - ? b - : { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } }; - }, - pills, - ); -} - -export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => ({ - ...b, - input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) }, - }), - pills, - ); -} - -/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */ -export function setTable( - lg: CfLogic, - id: string, - table: { key: string; row?: number } | null, - name?: string, - pills?: Pills, -): CfLogic { - const next = touch( - lg, - id, - (b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }), - pills, - ); - return name - ? { - ...next, - boxes: next.boxes.map((b) => - b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b, - ), - } - : next; -} - -/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */ -export function putVariable( - lg: CfLogic, - id: string, - v: CfVariable, - oldName?: string, - pills?: Pills, -): CfLogic { - return touch( - lg, - id, - (b) => { - const cur = inputOf(b); - const vars = - oldName === undefined - ? [...cur.variables, v] - : cur.variables.map((x) => (x.name === oldName ? v : x)); - return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } }; - }, - pills, - ); -} - -export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { - return touch( - lg, - id, - (b) => ({ - ...b, - input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) }, - }), - pills, - ); -} - -/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */ -export function reconcileUnit(lg: CfLogic, id: string): CfLogic { - const box = lg.boxes.find((b) => b.id === id); - if (!box?.input) return lg; - const want = unitOfInput(box.input); - return box.unit === want - ? lg - : { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) }; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Table.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Table.ts deleted file mode 100644 index 7f50c0590..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Table.ts +++ /dev/null @@ -1,323 +0,0 @@ -/* ============================================================================= - * M02_CF_InputPanel_Table.ts - * 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나) - * `M02_CF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래 - * ========================================================================== */ - -import { API_BASE_URL } from "@config/config_frontend"; -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import type { CfGroup, CfLogic } from "./M02_CF_Model"; -import { setTable } from "./M02_CF_InputPanel_Build"; - -const TEXT = { - Book: ["적용 품셈", "Standard"], - Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"], - Loading: ["찾는 중…", "Searching…"], - NoSection: ["찾는 절 없음", "No section"], - SecLine: ["표 {t} 개", "{t} tables"], - Tables: ["이 절의 표 — 하나를 고름", "Pick a table"], - NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"], - TableLine: [ - "{b} · 조건 {c} · 결과 {v} · {n} 줄", - "{b} · {c} conditions · {v} results · {n} rows", - ], - Chosen: ["고른 표", "Chosen table"], - Whole: [ - "표 통째 — 함수 상자가 조건에 맞는 줄을 찾음", - "Whole table — a function box looks it up", - ], - OneRow: ["줄 하나 고정", "Fix one row"], - RowNo: ["몇 번째 줄", "Row number"], - Preview: ["미리 보기", "Preview"], - Notes: ["주(註)", "Notes"], - Remove: ["표 빼기", "Remove table"], - Change: ["다른 표 고르기", "Pick another table"], - Failed: ["불러오지 못함", "Could not load"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -async function get(path: string, params: Record): Promise { - const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, { - credentials: "include", - }); - const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T; - if (!res.ok) throw new Error(String(data.detail ?? res.statusText)); - return data; -} - -interface SectionItem { - section: string; - title: string; - chapter: string; - tables: number; -} -interface TableHead { - file: string; - 키: string; - 이름: string; - 기준: string; - 조건: Record; - 값칸: Record; - count: number; -} -interface RawTable { - 키: string; - 원문번호: string; - 이름: string; - 기준: string; - 조건: Record; - 값칸: Record; - 줄: Record[]; - 주?: string[]; -} - -const BOOKS = ["산림품셈", "건설품셈"]; -const files = new Map(); -const tables = new Map(); - -/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */ -export async function loadTable(group: CfGroup, key: string): Promise { - const hit = tables.get(key); - if (hit) return hit; - let file = files.get(key); - if (!file) { - const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find( - (x) => x.키 === key, - ); - if (!found) throw new Error(t("Failed")); - file = found.file; - } - const raw = (await get<{ table: RawTable }>("/table", { file, key })).table; - tables.set(key, raw); - return raw; -} - -const cell = (v: unknown): string => - Array.isArray(v) - ? v.map((x) => x ?? "").join("~") - : v === null || v === undefined - ? "" - : String(v); - -/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */ -function previewOf(tb: RawTable, row?: number): HTMLElement { - const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)]; - const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1)); - return el("div", { - className: "m02-cfip__preview", - children: [ - el("table", { - children: [ - el("tr", { children: cols.map((c) => el("th", { text: c })) }), - ...body.map((r, i) => - el("tr", { - className: i === row ? "is-hit" : "", - children: cols.map((c) => el("td", { text: cell(r[c]) })), - }), - ), - ], - }), - ...(tb.줄.length > body.length - ? [el("p", { className: "m02-cfip__muted", text: `… ${tb.줄.length} 줄` })] - : []), - ...(tb.주?.length - ? [ - el("details", { - children: [ - el("summary", { text: t("Notes") }), - ...tb.주.map((n) => el("p", { text: n })), - ], - }), - ] - : []), - ], - }); -} - -/** 소요량 · 계수 표 고르기 화면 */ -export function renderTablePick( - host: HTMLElement, - lg: CfLogic, - boxId: string, - group: CfGroup, - on: { change(lg: CfLogic): void }, -): void { - const box = lg.boxes.find((b) => b.id === boxId); - const cur = box?.input?.table; - if (cur) return void showChosen(host, lg, boxId, group, cur, on); - let book = BOOKS[0]; - const bookBox = createSelectField({ - label: t("Book"), - options: BOOKS.map((b) => ({ value: b, text: b })), - value: book, - compact: true, - onChange: (v) => ((book = v), load()), - }); - const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-cfip__list" }); - const tableList = el("div", { className: "m02-cfip__list" }); - let seq = 0; - const load = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("Loading") })); - try { - const r = await get<{ sections: SectionItem[] }>("/sections", { - book, - q: q.input.value.trim(), - limit: "60", - }); - if (mine !== seq) return; - list.replaceChildren( - ...(r.sections.length - ? r.sections.map((s) => { - const b = el("button", { - className: "m02-cfip__row", - attrs: { type: "button" }, - children: [ - el("strong", { text: `${s.section} ${s.title}` }), - el("span", { - className: "m02-cfip__muted", - text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`, - }), - ], - }); - b.addEventListener("click", () => void pickSection(s)); - return b; - }) - : [el("p", { className: "m02-cfip__muted", text: t("NoSection") })]), - ); - } catch (e) { - if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error"); - } - }; - const pickSection = async (s: SectionItem): Promise => { - try { - const heads = ( - await get<{ tables: TableHead[] }>("/tables", { - group, - section: s.section, - sub: book, - size: "100", - }) - ).tables; - tableList.replaceChildren( - el("h5", { text: t("Tables") }), - ...(heads.length - ? heads.map((h) => { - const b = el("button", { - className: "m02-cfip__row", - attrs: { type: "button" }, - children: [ - el("strong", { text: h.이름 }), - el("span", { - className: "m02-cfip__muted", - text: t("TableLine", { - b: h.기준 || "—", - c: Object.keys(h.조건).length, - v: Object.keys(h.값칸).length, - n: h.count, - }), - }), - ], - }); - b.addEventListener("click", () => { - files.set(h.키, h.file); - void loadTable(group, h.키).then( - (tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)), - (e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"), - ); - }); - return b; - }) - : [el("p", { className: "m02-cfip__muted", text: t("NoTable") })]), - ); - } catch (e) { - showToast(e instanceof Error ? e.message : t("Failed"), "error"); - } - }; - let timer = 0; - q.input.addEventListener( - "input", - () => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))), - ); - host.replaceChildren(bookBox.root, q.root, list, tableList); - void load(); -} - -/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */ -function showChosen( - host: HTMLElement, - lg: CfLogic, - boxId: string, - group: CfGroup, - cur: { key: string; row?: number }, - on: { change(lg: CfLogic): void }, -): void { - const body = el("div", { className: "m02-cfip__stack" }); - host.replaceChildren(body); - void loadTable(group, cur.key).then( - (tb) => { - const whole = cur.row === undefined; - const modeWhole = createButton({ - label: t("Whole"), - variant: whole ? "filled" : "ghost", - onClick: () => on.change(setTable(lg, boxId, { key: cur.key })), - }); - const modeOne = createButton({ - label: t("OneRow"), - variant: whole ? "ghost" : "filled", - onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })), - }); - const rowBox = createSelectField({ - label: t("RowNo"), - options: tb.줄.map((r, i) => ({ - value: String(i), - text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)] - .slice(0, 4) - .map((c) => cell(r[c])) - .join(" / ")}`, - })), - value: String(cur.row ?? 0), - compact: true, - onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })), - }); - body.replaceChildren( - el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }), - el("div", { className: "m02-cfip__chips", children: [modeWhole, modeOne] }), - ...(whole ? [] : [rowBox.root]), - el("h5", { text: t("Preview") }), - previewOf(tb, cur.row), - el("div", { - className: "m02-cfip__chips", - children: [ - createButton({ - label: t("Change"), - variant: "ghost", - onClick: () => on.change(setTable(lg, boxId, null)), - }), - ], - }), - ); - }, - (e) => - body.replaceChildren( - el("p", { - className: "m02-cfip__muted", - text: e instanceof Error ? e.message : t("Failed"), - }), - ), - ); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Vars.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Vars.ts deleted file mode 100644 index 463f91f52..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Vars.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* ============================================================================= - * M02_CF_InputPanel_Vars.ts - * 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값 - * 변수마다 출력 알약 하나 · `M02_CF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { CfLogic, CfVariable } from "./M02_CF_Model"; -import { checkVariable, putVariable, removeVariable } from "./M02_CF_InputPanel_Build"; - -const TEXT = { - List: ["변수 — 알약마다 하나", "Variables — one pill each"], - None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"], - Name: ["이름", "Name"], - Type: ["갈래", "Type"], - Number: ["숫자", "Number"], - Choice: ["선택", "Choice"], - Text: ["글", "Text"], - Unit: ["단위", "Unit"], - Min: ["범위 아래", "Range from"], - Max: ["범위 위", "Range to"], - Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"], - Example: ["예시값", "Example"], - Add: ["변수 넣기", "Add variable"], - Save: ["고치기 끝", "Save change"], - Edit: ["고치기", "Edit"], - Remove: ["삭제", "Remove"], - Cancel: ["취소", "Cancel"], - Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"], - Range: ["범위 {a}~{b}", "range {a}~{b}"], -} as const; - -type TKey = keyof typeof TEXT; -const t = (key: TKey, v: Record = {}): string => - Object.entries(v).reduce( - (s, [k, x]) => s.replace(`{${k}}`, String(x)), - TEXT[key][currentLanguageIndex] as string, - ); - -const numOf = (s: string): number | null => - s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null; - -/** 변수 한 줄 글 */ -export function variableLine(v: CfVariable): string { - const base = t("Line", { - n: v.name, - u: v.unit || "-", - e: v.example === null ? "—" : String(v.example), - }); - const range = - v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : ""; - const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : ""; - return `${base}${range}${choices}`; -} - -export function renderVariables( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, -): void { - const box = lg.boxes.find((b) => b.id === boxId); - const vars = box?.input?.variables ?? []; - let editing: CfVariable | null = null; - - const form = (): HTMLElement => { - const cur = editing; - const name = createInputField({ - type: "text", - label: t("Name"), - value: cur?.name ?? "", - placeholder: "예: 면적", - compact: true, - }); - const type = createSelectField({ - label: t("Type"), - options: [ - { value: "number", text: t("Number") }, - { value: "choice", text: t("Choice") }, - { value: "text", text: t("Text") }, - ], - value: cur?.type ?? "number", - compact: true, - onChange: () => paint(), - }); - const unit = createInputField({ - type: "text", - label: t("Unit"), - value: cur?.unit ?? "", - placeholder: "예: ㎡", - compact: true, - }); - const lo = createInputField({ - type: "text", - label: t("Min"), - value: cur?.range ? String(cur.range[0]) : "", - compact: true, - }); - const hi = createInputField({ - type: "text", - label: t("Max"), - value: cur?.range ? String(cur.range[1]) : "", - compact: true, - }); - const choices = createInputField({ - type: "text", - label: t("Choices"), - value: (cur?.choices ?? []).join(", "), - compact: true, - }); - const example = createInputField({ - type: "text", - label: t("Example"), - value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example), - compact: true, - }); - const extra = el("div", { className: "m02-cfip__stack" }); - const paint = (): void => { - const ty = type.select.value; - extra.replaceChildren( - ...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []), - ); - }; - paint(); - const submit = (): void => { - const ty = type.select.value as CfVariable["type"]; - const ex = example.input.value.trim(); - const v: CfVariable = { - name: name.input.value.trim(), - type: ty, - unit: unit.input.value.trim(), - example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex, - ...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null - ? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] } - : {}), - ...(ty === "choice" - ? { - choices: choices.input.value - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - } - : {}), - }; - const why = checkVariable( - v, - vars.filter((x) => x.name !== cur?.name), - ); - name.setError(why ?? undefined); - if (why) return; - on.change(putVariable(lg, boxId, v, cur?.name)); - }; - const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit }); - const cancel = cur - ? createButton({ - label: t("Cancel"), - variant: "ghost", - onClick: () => ((editing = null), draw()), - }) - : null; - return el("div", { - className: "m02-cfip__form", - children: [ - name.root, - type.root, - unit.root, - extra, - example.root, - el("div", { className: "m02-cfip__chips", children: cancel ? [ok, cancel] : [ok] }), - ], - }); - }; - - const draw = (): void => { - const list = vars.length - ? vars.map((v) => - el("div", { - className: "m02-cfip__item", - children: [ - el("span", { text: variableLine(v) }), - createButton({ - label: t("Edit"), - variant: "ghost", - size: "sm", - onClick: () => ((editing = v), draw()), - }), - createButton({ - label: t("Remove"), - variant: "ghost", - size: "sm", - onClick: () => on.change(removeVariable(lg, boxId, v.name)), - }), - ], - }), - ) - : [el("p", { className: "m02-cfip__muted", text: t("None") })]; - host.replaceChildren(el("h5", { text: t("List") }), ...list, form()); - }; - draw(); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts deleted file mode 100644 index d19f3f431..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts +++ /dev/null @@ -1,329 +0,0 @@ -/* ============================================================================= - * M02_CF_Model.ts - * 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나) - * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로 - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; - -export type CfKind = "input" | "function" | "output"; -export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율"; -export type CfCost = "노무비" | "재료비" | "경비" | "값"; - -/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */ -export interface CfElementRef { - ref: string; - name: string; - spec: string; - unit: string; -} - -/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */ -export interface CfVariable { - name: string; - type: "number" | "choice" | "text"; - unit: string; - choices?: (string | number)[]; - range?: [number, number]; - example: number | string | null; - /** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */ - cell?: string; -} - -export interface CfInput { - mode: "group" | "variable"; - group?: CfGroup; - elements: CfElementRef[]; - /** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */ - table?: { key: string; row?: number }; - variables: CfVariable[]; -} - -export interface CfFunc { - /** 우리 스프레드시트 시트 하나 */ - sheet: Sheet; - /** 받는 알약 → 칸 */ - inputs: { port: string; cell: string }[]; - variables: CfVariable[]; - /** 내보내는 알약 하나 */ - output: { cell: string; name: string; unit: string }; -} - -export interface CfOutput { - cost: CfCost; - unit: string; -} - -export interface CfBox { - id: string; - kind: CfKind; - name: string; - color: string; - /** 오른쪽 위 단위 — 기본 「-」 */ - unit: string; - x: number; - y: number; - input?: CfInput; - func?: CfFunc; - output?: CfOutput; -} - -/** 알약 한쪽 끝 */ -export interface CfPort { - box: string; - name: string; -} - -export interface CfLink { - id: string; - from: CfPort; - to: CfPort; -} - -/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */ -export interface CfUserElement { - id: string; - group: CfGroup; - name: string; - spec: string; - unit: string; - value: number | null; -} - -export interface CfLogic { - key: string | null; - name: string; - resultUnit: string; - boxes: CfBox[]; - links: CfLink[]; - userElements: CfUserElement[]; -} - -/** 알약 하나의 모습 */ -export interface CfPill { - name: string; - unit: string; -} - -const TEXT = { - Input: ["입력", "Input"], - Func: ["함수", "Function"], - Output: ["출력", "Output"], - Result: ["결과", "Result"], - Value: ["값", "Value"], - NoPill: ["알약이 없음", "No such pill"], - FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"], - ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"], - Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], - Taken: ["이미 선이 들어온 자리", "This spot already has a line"], - Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], -} as const; - -const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -/** 종류별 기본 색 */ -export const BOX_COLOR: Record = { - input: "#2f80ed", - function: "#7b4fd6", - output: "#27ae60", -}; - -/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */ -export const OUTPUT_PORT = "값"; -export const TABLE_PORT = "표"; - -const clone = (v: T): T => structuredClone(v); - -/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */ -function nextId(prefix: string, ids: string[]): string { - const nums = ids - .filter((x) => x.startsWith(prefix)) - .map((x) => Number(x.slice(prefix.length))) - .filter((n) => Number.isInteger(n)); - return `${prefix}${Math.max(0, ...nums) + 1}`; -} - -/** 같은 이름이면 「 2」 「 3」 */ -function uniqueName(used: string[], base: string): string { - if (!used.includes(base)) return base; - for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`; -} - -const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id); - -/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */ -export function updateBox( - lg: CfLogic, - id: string, - patch: Partial>, -): CfLogic { - return { - ...lg, - boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)), - }; -} - -/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ - -export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic { - return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] }; -} - -/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */ -export function addBox( - lg: CfLogic, - kind: CfKind, - x: number, - y: number, -): { lg: CfLogic; box: CfBox } { - const id = nextId( - "b", - lg.boxes.map((b) => b.id), - ); - const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output"); - const name = uniqueName( - lg.boxes.map((b) => b.name), - base, - ); - const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y }; - if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] }; - if (kind === "function") { - // 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음 - const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : [])); - let n = 1; - while (taken.includes(`${tl("Result")} ${n}`)) n++; - const out = `${tl("Result")} ${n}`; - box.func = { - sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } }, - inputs: [], - variables: [], - output: { cell: "B1", name: out, unit: "-" }, - }; - } - if (kind === "output") box.output = { cost: "노무비", unit: "-" }; - return { lg: { ...lg, boxes: [...lg.boxes, box] }, box }; -} - -/** 상자 지우기 — 이어진 선도 지움 */ -export function removeBox(lg: CfLogic, id: string): CfLogic { - // 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리 - let next = lg; - for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id); - return { ...next, boxes: next.boxes.filter((b) => b.id !== id) }; -} - -export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { - return updateBox(lg, id, { x, y }); -} - -export function setColor(lg: CfLogic, id: string, color: string): CfLogic { - return updateBox(lg, id, { color }); -} - -/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */ -export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { - const box = boxOf(lg, id); - if (!box) return lg; - const u = unit.trim() || "-"; - const patch: Partial = { unit: u }; - if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } }; - if (box.output) patch.output = { ...box.output, unit: u }; - return updateBox(lg, id, patch); -} - -/* ── 알약 ───────────────────────────────────────────────────────────────── */ - -/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name - * 함수 = func.output.name 하나 · 출력 = 없음 */ -export function portsOut(box: CfBox): CfPill[] { - if (box.kind === "function" && box.func) - return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }]; - if (box.kind !== "input" || !box.input) return []; - const inp = box.input; - return [ - ...(inp.mode === "group" - ? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" })) - : []), - ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), - ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), - ]; -} - -/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */ -export function portsIn(box: CfBox): CfPill[] { - if (box.kind === "function" && box.func) - return box.func.inputs.map((i) => ({ name: i.port, unit: "-" })); - if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }]; - return []; -} - -/* ── 선 ─────────────────────────────────────────────────────────────────── */ - -/** from 상자에서 선을 따라 to 상자에 닿나 */ -export function reaches(lg: CfLogic, from: string, to: string): boolean { - const seen = new Set(); - const walk = (id: string): boolean => { - if (id === to) return true; - if (seen.has(id)) return false; - seen.add(id); - return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box)); - }; - return walk(from); -} - -/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 - * 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 · - * 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */ -export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { - const a = boxOf(lg, from.box); - const b = boxOf(lg, to.box); - if (!a || !b) return { lg, why: tl("NoPill") }; - if (a.kind === "output") return { lg, why: tl("FromOutput") }; - if (b.kind === "input") return { lg, why: tl("ToInput") }; - if (a.id === b.id) return { lg, why: tl("Self") }; - if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") }; - const has = portsIn(b).some((p) => p.name === to.name); - if (!has && b.kind !== "function") return { lg, why: tl("NoPill") }; - if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; - if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) - return { lg, why: tl("Taken") }; - let next = lg; - let port = to.name; - if (!has) { - const used = portsIn(b).map((p) => p.name); - const base = to.name.trim() || from.name; - port = used.includes(base) ? uniqueSuffix(used, base) : base; - const func = b.func!; - next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } }); - } - const id = nextId( - "l", - next.links.map((l) => l.id), - ); - return { - lg: { - ...next, - links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }], - }, - why: null, - }; -} - -/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */ -function uniqueSuffix(used: string[], base: string): string { - for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`; -} - -/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */ -export function unlink(lg: CfLogic, id: string): CfLogic { - const gone = lg.links.find((l) => l.id === id); - const next = { ...lg, links: lg.links.filter((l) => l.id !== id) }; - const box = gone && boxOf(next, gone.to.box); - if (!gone || !box?.func) return next; - const port = box.func.inputs.find((i) => i.port === gone.to.name); - const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name); - if (!port || port.cell.trim() || still) return next; - const inputs = box.func.inputs.filter((i) => i !== port); - return updateBox(next, box.id, { func: { ...box.func, inputs } }); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css deleted file mode 100644 index 0ce7fa6d2..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css +++ /dev/null @@ -1,85 +0,0 @@ -.m01-cfout__title { - color: var(--color-text-body); - font-size: var(--text-body); -} - -.m01-cfout__body { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: var(--spacing-16); - margin: var(--spacing-8) 0; -} - -.m01-cfout__got { - min-width: 220px; - color: var(--color-text-body); -} - -.m01-cfout__got dl { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-4) var(--spacing-8); - margin: var(--spacing-4) 0 0; - font-size: var(--text-body-sm); -} - -.m01-cfout__got dt { - color: var(--color-text-secondary); -} - -.m01-cfout__got dd { - margin: 0; -} - -.m01-cfout__num { - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.m01-cfout__fields { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-8) var(--spacing-16); -} - -.m01-cfout__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01-cfout__input { - box-sizing: border-box; - width: 160px; - max-width: 100%; - height: var(--control-height-sm); - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); -} - -.m01-cfout__warn { - margin: var(--spacing-4) 0 0; - color: var(--color-danger); - font-size: var(--text-body-sm); -} - -.m01-cfout__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01-cfout__msg { - margin: var(--spacing-4) 0 0; - color: var(--color-danger); - font-size: var(--text-caption); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts deleted file mode 100644 index ffb358c12..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts +++ /dev/null @@ -1,195 +0,0 @@ -/* ============================================================================= - * M02_CF_OutputPanel.ts - * 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸 - * 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름 - * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createSelectField, el } from "@ui/ui_template_elements"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { evalLogic, type CfEvalResult, type CfValue } from "./M02_CF_Eval"; -import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_CF_Model"; -import "./M02_CF_OutputPanel.css"; - -const TEXT = { - Title: ["출력", "Output"], - Name: ["이름", "Name"], - Cost: ["비용 종류", "Cost kind"], - Labor: ["노무비", "Labor cost"], - Material: ["재료비", "Material cost"], - Expense: ["경비", "Expense"], - Plain: ["값(비용 아님)", "Value (not a cost)"], - Unit: ["단위", "Unit"], - UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"], - Got: ["받은 값", "Received value"], - From: ["어디서", "From"], - Value: ["값", "Value"], - NoLink: [ - "아직 이어진 선이 없어요 — 입력이나 함수 상자에서 이 상자로 선을 이어 주세요", - "Nothing is linked yet — draw a line from an input or function box", - ], - Table: ["표", "Table"], - Calc: ["계산 중이 아니에요", "Not calculated"], - Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], - PlainHint: [ - "원가 합계에 안 넣고 값으로만 보여요", - "Shown as a value, not added to the cost total", - ], -} as const; - -type TextKey = keyof typeof TEXT; -const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; - -const COSTS: [CfCost, TextKey][] = [ - ["노무비", "Labor"], - ["재료비", "Material"], - ["경비", "Expense"], - ["값", "Plain"], -]; - -/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */ -export function showValue(v: CfValue | undefined, money = false): string { - if (v === undefined || v === null) return "—"; - if (Array.isArray(v)) return to("Table"); - if (typeof v === "number") - return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); - return String(v); -} - -const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({ - ...lg, - boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)), -}); - -/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */ -export function renderOutputPanel( - host: HTMLElement, - lg: CfLogic, - boxId: string, - on: { change(lg: CfLogic): void }, - opts: { result?: CfEvalResult } = {}, -): void { - const box = lg.boxes.find((b) => b.id === boxId); - if (!box || box.kind !== "output") { - host.replaceChildren(); - return; - } - const msg = el("p", { className: "m01-cfout__msg" }); - const emit = (make: () => CfLogic): void => { - msg.textContent = ""; - try { - on.change(make()); - } catch { - msg.textContent = to("Cant"); - } - }; - const cost = box.output?.cost ?? "노무비"; - - /* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */ - const link = lg.links.find((l) => l.to.box === box.id); - const from = link && lg.boxes.find((b) => b.id === link.from.box); - let result = opts.result; - if (!result && link) { - try { - result = evalLogic(lg); - } catch { - result = undefined; - } - } - let unit = ""; - if (from && link) { - try { - unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit; - } catch { - unit = from.unit; - } - } - const got = el("div", { className: "m01-cfout__got" }); - if (link && from) { - const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id]; - got.replaceChildren( - el("strong", { text: to("Got") }), - el("dl", { - children: [ - el("dt", { text: to("From") }), - el("dd", { text: `${from.name} · ${link.from.name}` }), - el("dt", { text: to("Value") }), - el("dd", { - className: "m01-cfout__num", - text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`, - }), - ], - }), - ); - } else { - got.replaceChildren( - el("strong", { text: to("Got") }), - el("p", { className: "m01-cfout__warn", text: to("NoLink") }), - ); - } - - /* 이름 · 비용 종류 · 단위 */ - const nameBox = el("input", { - className: "m01-cfout__input", - attrs: { type: "text", "aria-label": to("Name") }, - }); - nameBox.value = box.name; - nameBox.addEventListener("change", () => { - const next = nameBox.value.trim(); - if (!next || next === box.name) { - nameBox.value = box.name; - return; - } - emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next }))); - }); - const costField = createSelectField({ - label: to("Cost"), - options: COSTS.map(([value, key]) => ({ value, text: to(key) })), - value: cost, - compact: true, - onChange: (v) => - emit(() => - withBox(lg, box.id, (b) => ({ - ...b, - output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost }, - })), - ), - }); - const unitBox = el("input", { - className: "m01-cfout__input", - attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") }, - }); - unitBox.value = box.output?.unit ?? box.unit; - unitBox.addEventListener("change", () => { - const next = unitBox.value.trim() || "-"; - emit(() => - withBox(lg, box.id, (b) => ({ - ...b, - unit: next, - output: { cost: b.output?.cost ?? "노무비", unit: next }, - })), - ); - }); - const field = (label: string, control: HTMLElement): HTMLElement => - el("label", { - className: "m01-cfout__field", - children: [el("span", { text: label }), control], - }); - - host.replaceChildren( - el("strong", { className: "m01-cfout__title", text: `${to("Title")} · ${box.name}` }), - el("div", { - className: "m01-cfout__body", - children: [ - got, - el("div", { - className: "m01-cfout__fields", - children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)], - }), - ], - }), - ...(cost === "값" ? [el("p", { className: "m01-cfout__muted", text: to("PlainHint") })] : []), - msg, - ); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Router.py b/M02_MasterTemplete/CostFlow/M02_CF_Router.py deleted file mode 100644 index a611eca45..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Router.py +++ /dev/null @@ -1,664 +0,0 @@ -"""단가산출 다이어그램 시험 길(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")} - - -MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」 -HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝) -_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 - - -def _machine_family() -> list[dict]: - """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" - files = store.loaded()[0] - return [ - row - for name, data in files.items() - if data.get("그룹") == "로직" and "건설기계" in name - for row in data.get("줄") or [] - if str(row.get("결과단위") or "").startswith("원") - and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or []) - ] - - -def _machine_rank(row: dict) -> tuple: - """짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키.""" - names = [i.get("이름") for i in row.get("입력") or []] - codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계") - attach = any(n not in _MACHINE_PLAIN for n in names) - loss = "지역" not in names - return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키"))) - - -def _machine_hourly(key: str, inputs: dict) -> dict: - """기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기.""" - row, why = _brief(key, "기계") - if row is None: - return {"ok": False, "reason": why} - code = str(row.get("원문번호") or "") - fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]] - if not fit: - return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} - logic = min(fit, key=_machine_rank) - given = {} - for spec in logic.get("입력") or []: - name = spec.get("이름") - if name != "기계": - given[name] = inputs.get(name, (spec.get("고르기") or [None])[0]) - args = "".join(f", {n}='{v}'" for n, v in given.items()) - ref = f"로직({logic['키']}, 기계='{code}'{args})" - line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} - got = _money(store.calc("", {}, _row(line, ()), None), "계") - return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got - - -def _codes(row: dict) -> list: - return next( - (i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), [] - ) - - -def one_value(item: dict, local: list[dict] | None = None) -> dict: - """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`. - - 기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈. - """ - group, ref = item.get("group"), item.get("ref") - table, field = item.get("table") or {}, item.get("field") - if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref: - ref, _, field = ref.partition(MACHINE_SEP) - 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), field) - elif group == "기계" and field == HOURLY: - got = _machine_hourly(str(ref or ""), item.get("inputs") or {}) - elif group in ("기계", "유가전력", "환율", "요율"): - got = _plain_value(group, str(ref or ""), 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) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts b/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts deleted file mode 100644 index 3673fe54e..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* ============================================================================= - * M02_CF_Sample.ts - * 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬) - * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비 - * ② 공구손료(비율) — 노무비 × 율 → 출력 재료비 - * ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값 - * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). - * 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」). - * 모양 = `M02_CF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port · - * output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { CfLogic } from "./M02_CF_Model"; - -const FILES = import.meta.glob("./_sample/*.json", { eager: true, import: "default" }); - -/** 견본마다 한 줄 설명 — [한, 영] */ -const NOTE: Record = { - masonry: [ - "변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비", - "Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost", - ], - rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material cost"], - value: [ - "q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)", - "q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)", - ], -}; - -/** 보여 줄 차례 */ -const ORDER = ["masonry", "rate", "value"]; - -export interface CfSampleItem { - /** 파일 이름(확장자 뺌) */ - id: string; - name: string; - resultUnit: string; - /** 쉬운 말 한 줄 */ - note: string; -} - -const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, ""); -const byId = (): Map => - new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg])); - -/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */ -export function sampleList(): CfSampleItem[] { - const all = byId(); - const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length); - return [...all.entries()] - .sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name)) - .map(([id, lg]) => ({ - id, - name: lg.name, - resultUnit: lg.resultUnit, - note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "", - })); -} - -/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */ -export function sampleLoad(id: string): CfLogic | null { - const lg = byId().get(id); - return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null; -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.css b/M02_MasterTemplete/CostFlow/M02_CF_Summary.css deleted file mode 100644 index f8982bf37..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Summary.css +++ /dev/null @@ -1,132 +0,0 @@ -.m01-cfsum__big { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-8); - margin: 0 0 var(--spacing-8); - color: var(--color-text-secondary); -} - -.m01-cfsum__big strong { - color: var(--color-text-body); - font-size: 28px; - font-variant-numeric: tabular-nums; -} - -.m01-cfsum__costs { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-16); - margin: 0 0 var(--spacing-12); - font-size: var(--text-body-sm); -} - -.m01-cfsum__costs dt { - display: inline; - color: var(--color-text-secondary); -} - -.m01-cfsum__costs dd { - display: inline; - margin: 0 0 0 var(--spacing-4); - color: var(--color-text-body); - font-weight: 600; - font-variant-numeric: tabular-nums; -} - -.m01-cfsum__block { - margin: 0 0 var(--spacing-12); - color: var(--color-text-body); -} - -.m01-cfsum__scroll { - max-width: 100%; - overflow-x: auto; -} - -.m01-cfsum__table { - width: 100%; - min-width: 360px; - margin-top: var(--spacing-4); - border-collapse: collapse; - font-size: var(--text-body-sm); -} - -.m01-cfsum__table th, -.m01-cfsum__table td { - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - text-align: left; - vertical-align: middle; -} - -.m01-cfsum__table th { - background: var(--color-surface); - color: var(--color-text-secondary); - font-weight: 600; - white-space: nowrap; -} - -.m01-cfsum__table td.m01-cfsum__num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.m01-cfsum__strip { - margin: 0 0 var(--spacing-12); - color: var(--color-text-body); -} - -.m01-cfsum__fields { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-16); - margin: var(--spacing-8) 0; -} - -.m01-cfsum__field { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.m01-cfsum__input { - box-sizing: border-box; - width: 140px; - height: var(--control-height-sm); - padding: 3px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - font-size: var(--text-body-sm); -} - -.m01-cfsum__muted { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.m01-cfsum__msg { - margin: var(--spacing-4) 0 0; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m01-cfsum__problems { - margin: 0 0 var(--spacing-12); - padding: var(--spacing-8) var(--spacing-12); - border-left: 4px solid var(--color-danger); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-body-sm); -} - -.m01-cfsum__problems ul { - margin: var(--spacing-4) 0 0; - padding-left: var(--spacing-16); -} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts deleted file mode 100644 index 94164b86b..000000000 --- a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* ============================================================================= - * M02_CF_Summary.ts - * 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄 - * · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈) - * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. - * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createSelectField, el } from "@ui/ui_template_elements"; -import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { checkLogic, type CfEvalResult, type CfValues } from "./M02_CF_Eval"; -import type { CfBox, CfLogic, CfVariable } from "./M02_CF_Model"; -import { showValue } from "./M02_CF_OutputPanel"; -import "./M02_CF_Summary.css"; - -const TEXT = { - Per: ["{per} 당", "per {per}"], - Total: ["원가 합계", "Cost total"], - Won: ["원", "KRW"], - Labor: ["노무비", "Labor cost"], - Material: ["재료비", "Material cost"], - Expense: ["경비", "Expense"], - Lines: ["출력 상자별", "By output box"], - HName: ["이름", "Name"], - HCost: ["비용 종류", "Cost kind"], - HValue: ["값", "Value"], - HUnit: ["단위", "Unit"], - Plain: ["값(비용 아님)", "Values (not costs)"], - Sample: ["예시값", "Sample values"], - SampleHint: [ - "여기서 바꾸면 변수 상자의 예시값도 바뀌어요", - "Changing here changes the variable box too", - ], - Problems: ["고칠 것", "To fix"], - NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"], - NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], - Pick: ["고르세요", "Choose"], - Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], - Plain3: ["값", "Value"], -} as const; - -type TextKey = keyof typeof TEXT; -const ts = (key: TextKey, fill: Record = {}): string => - TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace( - /\{(\w+)\}/g, - (m, k: string) => fill[k] ?? m, - ); - -const COSTS = ["노무비", "재료비", "경비"] as const; -const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; - -const costText = (c: string): string => (c === "값" ? ts("Plain3") : c); - -/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */ -function emptyPlaces(lg: CfLogic): string[] { - const outs = lg.boxes.filter((b) => b.kind === "output"); - if (!outs.length) return [ts("NoOutput")]; - return outs - .filter((o) => !lg.links.some((l) => l.to.box === o.id)) - .map((o) => ts("NoLink", { box: o.name })); -} - -/** 변수 상자의 예시값 칸 하나 */ -function sampleField( - v: CfVariable, - put: (value: number | string) => void, - msg: HTMLElement, -): HTMLElement { - const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); - let control: HTMLElement; - if (v.type === "choice" && v.choices?.length) { - control = createSelectField({ - options: [ - { value: "", text: ts("Pick") }, - ...v.choices.map((c) => ({ value: String(c), text: String(c) })), - ], - value: v.example === null ? "" : String(v.example), - compact: true, - ariaLabel: v.name, - onChange: (s) => { - const hit = v.choices?.find((c) => String(c) === s); - if (hit !== undefined) put(hit); - }, - }).root; - } else { - const box = el("input", { - className: "m01-cfsum__input", - attrs: { - type: "text", - inputmode: v.type === "number" ? "decimal" : "text", - "aria-label": v.name, - }, - }); - if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) }); - box.value = v.example === null ? "" : String(v.example); - box.addEventListener("change", () => { - const raw = box.value.trim(); - if (raw === "") return; - if (v.type !== "number") return put(raw); - const n = Number(raw); - if (Number.isNaN(n)) msg.textContent = ts("Bad"); - else put(n); - }); - control = box; - } - return el("label", { className: "m01-cfsum__field", children: [label, control] }); -} - -/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */ -export function renderSummary( - host: HTMLElement, - lg: CfLogic, - result: CfEvalResult, - on?: { change(lg: CfLogic): void }, - opts?: { values?: CfValues }, -): void { - const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); - const perUnit = lg.resultUnit.split("/").slice(1).join("/"); - const perText = !perUnit - ? ts("Total") - : ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); - const { cost } = result; - const big = el("p", { - className: "m01-cfsum__big", - children: [ - el("span", { text: money ? perText : ts("Total") }), - el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }), - ], - }); - const costs = el("dl", { - className: "m01-cfsum__costs", - children: COSTS.flatMap((c) => [ - el("dt", { text: ts(COST_KEY[c]) }), - el("dd", { text: formatMoney(cost[c]) }), - ]), - }); - - /* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */ - const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); - const costed = outs.filter((b) => b.output?.cost !== "값"); - const plain = outs.filter((b) => b.output?.cost === "값"); - const line = (b: CfBox, isCost: boolean): HTMLElement => - el("tr", { - children: [ - el("td", { text: b.name }), - ...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []), - el("td", { - className: "m01-cfsum__num", - text: showValue(result.outputs[b.id], isCost), - }), - el("td", { text: b.output?.unit ?? b.unit }), - ], - }); - const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement => - el("div", { - className: "m01-cfsum__block", - children: [ - el("strong", { text: title }), - el("div", { - className: "m01-cfsum__scroll", - children: [ - el("table", { - className: "m01-cfsum__table", - children: [ - el("thead", { - children: [ - el("tr", { - children: [ - el("th", { text: ts("HName") }), - ...(isCost ? [el("th", { text: ts("HCost") })] : []), - el("th", { text: ts("HValue") }), - el("th", { text: ts("HUnit") }), - ], - }), - ], - }), - el("tbody", { children: list.map((b) => line(b, isCost)) }), - ], - }), - ], - }), - ], - }); - - /* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */ - let checked: string[] = []; - try { - checked = checkLogic(lg, opts?.values ?? {}); - } catch { - checked = []; - } - const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])]; - - /* 예시값 — 변수 상자의 변수마다 */ - const msg = el("p", { className: "m01-cfsum__msg" }); - const fields = lg.boxes - .filter((b) => b.kind === "input" && b.input?.mode === "variable") - .flatMap((b) => - (b.input?.variables ?? []).map((v, i) => - sampleField( - v, - (value) => { - msg.textContent = ""; - if (!on) return; - try { - on.change({ - ...lg, - boxes: lg.boxes.map((x) => - x.id === b.id && x.input - ? { - ...x, - input: { - ...x.input, - variables: x.input.variables.map((y, j) => - j === i ? { ...y, example: value } : y, - ), - }, - } - : x, - ), - }); - } catch { - msg.textContent = ts("Cant"); - } - }, - msg, - ), - ), - ); - - const plainValues = Object.entries(cost.값들); - host.replaceChildren( - big, - costs, - ...(costed.length ? [table(costed, true, ts("Lines"))] : []), - ...(plain.length - ? [table(plain, false, ts("Plain"))] - : plainValues.length - ? [ - el("div", { - className: "m01-cfsum__block", - children: [ - el("strong", { text: ts("Plain") }), - ...plainValues.map(([k, v]) => - el("p", { className: "m01-cfsum__muted", text: `${k} · ${showValue(v)}` }), - ), - ], - }), - ] - : []), - ...(fields.length - ? [ - el("div", { - className: "m01-cfsum__strip", - children: [ - el("strong", { text: ts("Sample") }), - el("div", { className: "m01-cfsum__fields", children: fields }), - el("p", { className: "m01-cfsum__muted", text: ts("SampleHint") }), - msg, - ], - }), - ] - : []), - ...(problems.length - ? [ - el("div", { - className: "m01-cfsum__problems", - children: [ - el("strong", { text: ts("Problems") }), - el("ul", { children: problems.map((t) => el("li", { text: t })) }), - ], - }), - ] - : []), - ); -} diff --git a/main.py b/main.py index 33befc4bb..bd3266c1c 100644 --- a/main.py +++ b/main.py @@ -638,11 +638,6 @@ app.include_router(b07_frame_router, dependencies=protected_with_company) # 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다. system_admin_only = [Depends(verify_session), Depends(require_system_admin)] app.include_router(m01_master_data_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( __import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router, diff --git a/resources/tester/fixtures_df/costflow/masonry.json b/resources/tester/fixtures_df/costflow/masonry.json deleted file mode 100644 index 7ad4f5adb..000000000 --- a/resources/tester/fixtures_df/costflow/masonry.json +++ /dev/null @@ -1,785 +0,0 @@ -{ - "key": "SAMPLE-masonry", - "name": "견본 · 돌쌓기 메쌓기(인력)", - "resultUnit": "원/㎡", - "boxes": [ - { - "id": "in-var", - "kind": "input", - "name": "쌓기 조건", - "color": "#2f80ed", - "unit": "-", - "x": 40, - "y": 40, - "input": { - "mode": "variable", - "elements": [], - "variables": [ - { - "name": "뒷길이", - "type": "choice", - "unit": "㎝", - "example": 30, - "choices": [25, 30, 35, 45, 55, 60, 75] - }, - { - "name": "돌", - "type": "choice", - "unit": "-", - "example": "깬돌", - "choices": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] - }, - { - "name": "쌓기", - "type": "choice", - "unit": "-", - "example": "골쌓기", - "choices": ["골쌓기", "켜쌓기"] - }, - { - "name": "높이", - "type": "number", - "unit": "m", - "example": 4.5 - } - ] - } - }, - { - "id": "in-poom", - "kind": "input", - "name": "메쌓기 품표", - "color": "#2f80ed", - "unit": "-", - "x": 40, - "y": 300, - "input": { - "mode": "group", - "group": "소요량", - "elements": [], - "table": { - "key": "QF000421" - }, - "variables": [] - } - }, - { - "id": "in-rate", - "kind": "input", - "name": "높이 증가율표", - "color": "#2f80ed", - "unit": "-", - "x": 40, - "y": 460, - "input": { - "mode": "group", - "group": "소요량", - "elements": [], - "table": { - "key": "QF000422" - }, - "variables": [] - } - }, - { - "id": "in-labor", - "kind": "input", - "name": "인력", - "color": "#2f80ed", - "unit": "원/일", - "x": 40, - "y": 620, - "input": { - "mode": "group", - "group": "인력", - "variables": [], - "elements": [ - { - "ref": "LB000033", - "name": "석공", - "spec": "", - "unit": "원/일" - }, - { - "ref": "LB000002", - "name": "보통인부", - "spec": "", - "unit": "원/일" - } - ] - } - }, - { - "id": "fn-rate", - "kind": "function", - "name": "증가율 찾기", - "color": "#7b4fd6", - "unit": "%", - "x": 340, - "y": 420, - "func": { - "sheet": { - "id": "cf-rate", - "이름": "증가율 찾기", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "높이" - }, - "C2": { - "값": "받는 값 · m" - }, - "A3": { - "값": "표에서 찾은 증가율" - }, - "B3": { - "식": "SUMIFS($G$2:$G$100,$E$2:$E$100,\"<\"&B2,$F$2:$F$100,\">=\"&B2)" - }, - "C3": { - "값": "높이가 「아래 < 높이 ≤ 위」 인 줄" - }, - "A4": { - "값": "증가율" - }, - "B4": { - "식": "IF(B2<=3,0,B3)" - }, - "C4": { - "값": "3m 까지는 할증 없음" - } - } - }, - "inputs": [ - { - "port": "높이", - "cell": "B2" - }, - { - "port": "증가율표", - "cell": "E1" - } - ], - "variables": [], - "output": { - "cell": "B4", - "name": "증가율", - "unit": "%" - } - } - }, - { - "id": "fn-poom0", - "kind": "function", - "name": "석공 품", - "color": "#7b4fd6", - "unit": "인/㎡", - "x": 640, - "y": 80, - "func": { - "sheet": { - "id": "cf-poom0", - "이름": "석공 품", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "뒷길이" - }, - "C2": { - "값": "받는 값 · ㎝" - }, - "A3": { - "값": "돌" - }, - "C3": { - "값": "받는 값" - }, - "A4": { - "값": "쌓기" - }, - "C4": { - "값": "받는 값" - }, - "A5": { - "값": "증가율" - }, - "C5": { - "값": "받는 값 · %" - }, - "A6": { - "값": "표에서 찾은 품" - }, - "B6": { - "식": "SUMIFS($H$2:$H$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" - }, - "C6": { - "값": "세 조건이 맞는 줄의 품" - }, - "A7": { - "값": "석공 품" - }, - "B7": { - "식": "B6*(1+B5/100)" - }, - "C7": { - "값": "높이 할증을 얹음" - } - } - }, - "inputs": [ - { - "port": "뒷길이", - "cell": "B2" - }, - { - "port": "돌", - "cell": "B3" - }, - { - "port": "쌓기", - "cell": "B4" - }, - { - "port": "증가율", - "cell": "B5" - }, - { - "port": "품표", - "cell": "E1" - } - ], - "variables": [], - "output": { - "cell": "B7", - "name": "석공 품", - "unit": "인/㎡" - } - } - }, - { - "id": "fn-poom1", - "kind": "function", - "name": "보통인부 품", - "color": "#7b4fd6", - "unit": "인/㎡", - "x": 640, - "y": 300, - "func": { - "sheet": { - "id": "cf-poom1", - "이름": "보통인부 품", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "뒷길이" - }, - "C2": { - "값": "받는 값 · ㎝" - }, - "A3": { - "값": "돌" - }, - "C3": { - "값": "받는 값" - }, - "A4": { - "값": "쌓기" - }, - "C4": { - "값": "받는 값" - }, - "A5": { - "값": "증가율" - }, - "C5": { - "값": "받는 값 · %" - }, - "A6": { - "값": "표에서 찾은 품" - }, - "B6": { - "식": "SUMIFS($I$2:$I$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" - }, - "C6": { - "값": "세 조건이 맞는 줄의 품" - }, - "A7": { - "값": "보통인부 품" - }, - "B7": { - "식": "B6*(1+B5/100)" - }, - "C7": { - "값": "높이 할증을 얹음" - } - } - }, - "inputs": [ - { - "port": "뒷길이", - "cell": "B2" - }, - { - "port": "돌", - "cell": "B3" - }, - { - "port": "쌓기", - "cell": "B4" - }, - { - "port": "증가율", - "cell": "B5" - }, - { - "port": "품표", - "cell": "E1" - } - ], - "variables": [], - "output": { - "cell": "B7", - "name": "보통인부 품", - "unit": "인/㎡" - } - } - }, - { - "id": "fn-pay0", - "kind": "function", - "name": "석공 노무비", - "color": "#7b4fd6", - "unit": "원/㎡", - "x": 940, - "y": 80, - "func": { - "sheet": { - "id": "cf-pay0", - "이름": "석공 노무비", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "석공 품" - }, - "C2": { - "값": "받는 값 · 인/㎡" - }, - "A3": { - "값": "석공 노임" - }, - "C3": { - "값": "받는 값 · 원/인" - }, - "A4": { - "값": "석공 노무비" - }, - "B4": { - "식": "B2*B3" - }, - "C4": { - "값": "품 × 노임" - } - } - }, - "inputs": [ - { - "port": "석공 품", - "cell": "B2" - }, - { - "port": "석공 노임", - "cell": "B3" - } - ], - "variables": [], - "output": { - "cell": "B4", - "name": "석공 노무비", - "unit": "원/㎡" - } - } - }, - { - "id": "fn-pay1", - "kind": "function", - "name": "보통인부 노무비", - "color": "#7b4fd6", - "unit": "원/㎡", - "x": 940, - "y": 300, - "func": { - "sheet": { - "id": "cf-pay1", - "이름": "보통인부 노무비", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "보통인부 품" - }, - "C2": { - "값": "받는 값 · 인/㎡" - }, - "A3": { - "값": "보통인부 노임" - }, - "C3": { - "값": "받는 값 · 원/인" - }, - "A4": { - "값": "보통인부 노무비" - }, - "B4": { - "식": "B2*B3" - }, - "C4": { - "값": "품 × 노임" - } - } - }, - "inputs": [ - { - "port": "보통인부 품", - "cell": "B2" - }, - { - "port": "보통인부 노임", - "cell": "B3" - } - ], - "variables": [], - "output": { - "cell": "B4", - "name": "보통인부 노무비", - "unit": "원/㎡" - } - } - }, - { - "id": "fn-sum", - "kind": "function", - "name": "노무비 합", - "color": "#7b4fd6", - "unit": "원/㎡", - "x": 1240, - "y": 190, - "func": { - "sheet": { - "id": "cf-sum", - "이름": "노무비 합", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "석공 노무비" - }, - "C2": { - "값": "받는 값" - }, - "A3": { - "값": "보통인부 노무비" - }, - "C3": { - "값": "받는 값" - }, - "A4": { - "값": "노무비" - }, - "B4": { - "식": "B2+B3" - }, - "C4": { - "값": "둘을 더함" - } - } - }, - "inputs": [ - { - "port": "석공 노무비", - "cell": "B2" - }, - { - "port": "보통인부 노무비", - "cell": "B3" - } - ], - "variables": [], - "output": { - "cell": "B4", - "name": "노무비", - "unit": "원/㎡" - } - } - }, - { - "id": "out-pay", - "kind": "output", - "name": "노무비", - "color": "#27ae60", - "unit": "원/㎡", - "x": 1520, - "y": 190, - "output": { - "cost": "노무비", - "unit": "원/㎡" - } - } - ], - "links": [ - { - "id": "l1", - "from": { - "box": "in-var", - "name": "높이" - }, - "to": { - "box": "fn-rate", - "name": "높이" - } - }, - { - "id": "l2", - "from": { - "box": "in-rate", - "name": "표" - }, - "to": { - "box": "fn-rate", - "name": "증가율표" - } - }, - { - "id": "l3", - "from": { - "box": "in-var", - "name": "뒷길이" - }, - "to": { - "box": "fn-poom0", - "name": "뒷길이" - } - }, - { - "id": "l4", - "from": { - "box": "in-var", - "name": "돌" - }, - "to": { - "box": "fn-poom0", - "name": "돌" - } - }, - { - "id": "l5", - "from": { - "box": "in-var", - "name": "쌓기" - }, - "to": { - "box": "fn-poom0", - "name": "쌓기" - } - }, - { - "id": "l6", - "from": { - "box": "fn-rate", - "name": "증가율" - }, - "to": { - "box": "fn-poom0", - "name": "증가율" - } - }, - { - "id": "l7", - "from": { - "box": "in-poom", - "name": "표" - }, - "to": { - "box": "fn-poom0", - "name": "품표" - } - }, - { - "id": "l8", - "from": { - "box": "in-var", - "name": "뒷길이" - }, - "to": { - "box": "fn-poom1", - "name": "뒷길이" - } - }, - { - "id": "l9", - "from": { - "box": "in-var", - "name": "돌" - }, - "to": { - "box": "fn-poom1", - "name": "돌" - } - }, - { - "id": "l10", - "from": { - "box": "in-var", - "name": "쌓기" - }, - "to": { - "box": "fn-poom1", - "name": "쌓기" - } - }, - { - "id": "l11", - "from": { - "box": "fn-rate", - "name": "증가율" - }, - "to": { - "box": "fn-poom1", - "name": "증가율" - } - }, - { - "id": "l12", - "from": { - "box": "in-poom", - "name": "표" - }, - "to": { - "box": "fn-poom1", - "name": "품표" - } - }, - { - "id": "l13", - "from": { - "box": "fn-poom0", - "name": "석공 품" - }, - "to": { - "box": "fn-pay0", - "name": "석공 품" - } - }, - { - "id": "l14", - "from": { - "box": "in-labor", - "name": "석공" - }, - "to": { - "box": "fn-pay0", - "name": "석공 노임" - } - }, - { - "id": "l15", - "from": { - "box": "fn-poom1", - "name": "보통인부 품" - }, - "to": { - "box": "fn-pay1", - "name": "보통인부 품" - } - }, - { - "id": "l16", - "from": { - "box": "in-labor", - "name": "보통인부" - }, - "to": { - "box": "fn-pay1", - "name": "보통인부 노임" - } - }, - { - "id": "l17", - "from": { - "box": "fn-pay0", - "name": "석공 노무비" - }, - "to": { - "box": "fn-sum", - "name": "석공 노무비" - } - }, - { - "id": "l18", - "from": { - "box": "fn-pay1", - "name": "보통인부 노무비" - }, - "to": { - "box": "fn-sum", - "name": "보통인부 노무비" - } - }, - { - "id": "l19", - "from": { - "box": "fn-sum", - "name": "노무비" - }, - "to": { - "box": "out-pay", - "name": "값" - } - } - ], - "userElements": [] -} diff --git a/resources/tester/fixtures_df/costflow/rate.json b/resources/tester/fixtures_df/costflow/rate.json deleted file mode 100644 index cf1ff8428..000000000 --- a/resources/tester/fixtures_df/costflow/rate.json +++ /dev/null @@ -1,146 +0,0 @@ -{ - "key": "SAMPLE-rate", - "name": "견본 · 공구손료(비율)", - "resultUnit": "원", - "boxes": [ - { - "id": "in-var", - "kind": "input", - "name": "바탕 값", - "color": "#2f80ed", - "unit": "-", - "x": 40, - "y": 80, - "input": { - "mode": "variable", - "elements": [], - "variables": [ - { - "name": "노무비", - "type": "number", - "unit": "원", - "example": 100000 - }, - { - "name": "공구손료율", - "type": "number", - "unit": "%", - "example": 3 - } - ] - } - }, - { - "id": "fn-rate", - "kind": "function", - "name": "공구손료", - "color": "#7b4fd6", - "unit": "원", - "x": 340, - "y": 80, - "func": { - "sheet": { - "id": "cf-tool", - "이름": "공구손료", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "노무비" - }, - "C2": { - "값": "받는 값 · 원" - }, - "A3": { - "값": "공구손료율" - }, - "C3": { - "값": "받는 값 · %" - }, - "A4": { - "값": "공구손료" - }, - "B4": { - "식": "B2*B3/100" - }, - "C4": { - "값": "노무비 × 율 ÷ 100" - } - } - }, - "inputs": [ - { - "port": "노무비", - "cell": "B2" - }, - { - "port": "공구손료율", - "cell": "B3" - } - ], - "variables": [], - "output": { - "cell": "B4", - "name": "공구손료", - "unit": "원" - } - } - }, - { - "id": "out-mat", - "kind": "output", - "name": "재료비", - "color": "#27ae60", - "unit": "원", - "x": 640, - "y": 80, - "output": { - "cost": "재료비", - "unit": "원" - } - } - ], - "links": [ - { - "id": "l1", - "from": { - "box": "in-var", - "name": "노무비" - }, - "to": { - "box": "fn-rate", - "name": "노무비" - } - }, - { - "id": "l2", - "from": { - "box": "in-var", - "name": "공구손료율" - }, - "to": { - "box": "fn-rate", - "name": "공구손료율" - } - }, - { - "id": "l3", - "from": { - "box": "fn-rate", - "name": "공구손료" - }, - "to": { - "box": "out-mat", - "name": "값" - } - } - ], - "userElements": [] -} diff --git a/resources/tester/fixtures_df/costflow/value.json b/resources/tester/fixtures_df/costflow/value.json deleted file mode 100644 index c292ba947..000000000 --- a/resources/tester/fixtures_df/costflow/value.json +++ /dev/null @@ -1,200 +0,0 @@ -{ - "key": "SAMPLE-value", - "name": "견본 · 덤프트럭 시간당 작업량(값)", - "resultUnit": "㎥/시간", - "boxes": [ - { - "id": "in-var", - "kind": "input", - "name": "덤프트럭 조건", - "color": "#2f80ed", - "unit": "-", - "x": 40, - "y": 80, - "input": { - "mode": "variable", - "elements": [], - "variables": [ - { - "name": "q", - "type": "number", - "unit": "㎥", - "example": 6 - }, - { - "name": "f", - "type": "number", - "unit": "-", - "example": 1.2 - }, - { - "name": "E", - "type": "number", - "unit": "-", - "example": 0.9 - }, - { - "name": "Cm", - "type": "number", - "unit": "분", - "example": 25 - } - ] - } - }, - { - "id": "fn-q", - "kind": "function", - "name": "시간당 작업량", - "color": "#7b4fd6", - "unit": "㎥/시간", - "x": 340, - "y": 80, - "func": { - "sheet": { - "id": "cf-q", - "이름": "시간당 작업량", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "q" - }, - "C2": { - "값": "적재량 · ㎥" - }, - "A3": { - "값": "f" - }, - "C3": { - "값": "토량환산계수" - }, - "A4": { - "값": "E" - }, - "C4": { - "값": "작업효율" - }, - "A5": { - "값": "Cm" - }, - "C5": { - "값": "1회 사이클 시간 · 분" - }, - "A6": { - "값": "시간당 작업량" - }, - "B6": { - "식": "ROUND(60*B2*B3*B4/B5,3)" - }, - "C6": { - "값": "60 × q × f × E ÷ Cm · 소수 셋째 자리" - } - } - }, - "inputs": [ - { - "port": "q", - "cell": "B2" - }, - { - "port": "f", - "cell": "B3" - }, - { - "port": "E", - "cell": "B4" - }, - { - "port": "Cm", - "cell": "B5" - } - ], - "variables": [], - "output": { - "cell": "B6", - "name": "시간당 작업량", - "unit": "㎥/시간" - } - } - }, - { - "id": "out-q", - "kind": "output", - "name": "시간당 작업량", - "color": "#27ae60", - "unit": "㎥/시간", - "x": 640, - "y": 80, - "output": { - "cost": "값", - "unit": "㎥/시간" - } - } - ], - "links": [ - { - "id": "l1", - "from": { - "box": "in-var", - "name": "q" - }, - "to": { - "box": "fn-q", - "name": "q" - } - }, - { - "id": "l2", - "from": { - "box": "in-var", - "name": "f" - }, - "to": { - "box": "fn-q", - "name": "f" - } - }, - { - "id": "l3", - "from": { - "box": "in-var", - "name": "E" - }, - "to": { - "box": "fn-q", - "name": "E" - } - }, - { - "id": "l4", - "from": { - "box": "in-var", - "name": "Cm" - }, - "to": { - "box": "fn-q", - "name": "Cm" - } - }, - { - "id": "l5", - "from": { - "box": "fn-q", - "name": "시간당 작업량" - }, - "to": { - "box": "out-q", - "name": "값" - } - } - ], - "userElements": [] -} diff --git a/resources/tester/test_m02_cf_inputpanel_build.py b/resources/tester/test_m02_cf_inputpanel_build.py deleted file mode 100644 index 79aa5cb05..000000000 --- a/resources/tester/test_m02_cf_inputpanel_build.py +++ /dev/null @@ -1,254 +0,0 @@ -"""단가산출 다이어그램 입력 상자 판 — 고르는 규칙(`M02_CF_InputPanel_Build.ts`). - -화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 줄 · 상자는 이 시험 안에 둠. -""" - -import json -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "M02_MasterTemplete" / "CostFlow" / "M02_CF_InputPanel_Build.ts" - -RUNNER = r""" -const fs = require("fs"); -const path = require("path"); -const [root, file] = process.argv.slice(1); -const ts = require(path.join(root, "config", "node_modules", "typescript")); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, () => ({})); -const m = box.exports; -const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -const out = jobs.map((j) => { - if (j.op === "fn") return m[j.name](...j.args); - let lg = j.lg; - for (const [fn, ...args] of j.steps || []) lg = m[fn](lg, ...args); - return lg; -}); -process.stdout.write(JSON.stringify(out)); -""" - - -def run(jobs: list[dict]) -> list: - if shutil.which("node") is None: - pytest.skip("node 가 없음") - done = subprocess.run( - ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], - input=json.dumps(jobs, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(done.stdout) - - -def logic() -> dict: - plain = {"color": "#fff", "unit": "-", "x": 0, "y": 0} - return { - "key": None, - "name": "견본", - "resultUnit": "원/㎡", - "userElements": [], - "boxes": [ - { - "id": "b1", - "kind": "input", - "name": "입력", - **plain, - "input": {"mode": "variable", "elements": [], "variables": []}, - }, - {"id": "b2", "kind": "function", "name": "함수", **plain}, - ], - "links": [], - } - - -def call(name: str, *args): - return run([{"op": "fn", "name": name, "args": list(args)}])[0] - - -def edit(steps: list, lg: dict | None = None) -> dict: - return run([{"op": "lg", "lg": lg or logic(), "steps": steps}])[0] - - -LABOR = {"키": "LB000033", "이름": "석공", "규격": "", "단위": "원/일", "값": 268908} -LABOR2 = {"키": "LB000002", "이름": "보통인부", "규격": "", "단위": "원/일", "값": 172068} -MAT = { - "키": "MT1", - "이름": "각형흄관", - "규격": "D=300mm", - "단위": "M", - "물가자료": 30000, - "물가정보": 29600, -} -MACHINE = { - "키": "EQ000001", - "이름": "불도저", - "규격": "7ton", - "단위": {"가격": "천원", "손료계수": "10^-7/시간"}, - "값": {"가격": 76547, "손료계수": 1811}, -} -RATE_ONE = {"키": "RT1", "이름": "산재보험료", "값": {"rate_percent": 3.56}} -RATE_BRACKET = {"키": "RT2", "이름": "고용보험료", "값": {"brackets": []}} - - -def test_요소는_이름과_규격으로_알약_이름이_됨(): - labor = call("elementOf", "인력", LABOR) - assert labor == {"ref": "LB000033", "name": "석공", "spec": "", "unit": "원/일"} - got = call("elementOf", "재료", MAT) - assert got["name"] == "각형흄관 D=300mm" and got["unit"] == "M" - - -def test_기계는_값_칸을_골라_요소가_됨(): - assert call("machineFields", MACHINE) == ["가격", "손료계수"] - got = call("elementOf", "기계", MACHINE, "손료계수") - assert got["ref"] == "EQ000001#손료계수" and got["unit"] == "10^-7/시간" - assert got["name"].endswith("· 손료계수") - assert call("queryOf", "기계", "EQ000001#손료계수") == { - "group": "기계", - "ref": "EQ000001", - "field": "손료계수", - } - - -def test_값_읽기_재료는_낮은_값_요율은_비율(): - assert call("valueOf", "재료", MAT) == 29600 - assert call("valueOf", "요율", RATE_ONE) == 3.56 - assert call("valueOf", "요율", RATE_BRACKET) is None - assert call("elementOf", "요율", RATE_ONE)["unit"] == "%" - assert call("elementOf", "요율", RATE_BRACKET)["unit"] == "-" - assert call("valueOf", "기계", MACHINE, "가격") == 76547 - - -def test_사용자_요소는_그_그룹의_ref_로_물음(): - assert call("queryOf", "인력", "UE-0001") == {"group": "인력", "ref": "UE-0001"} - assert call("queryOf", "재료", "MT1") == {"group": "재료", "ref": "MT1"} - row = {"키": "UE-0001", "이름": "특수공", "규격": "", "단위": "원/일", "값": 300000} - assert call("elementOf", "인력", row)["unit"] == "원/일" - assert call("elementOf", "요율", {**row, "단위": "%"})["unit"] == "%" - - -def test_상자_단위는_같으면_그_단위_여럿이면_하이픈(): - one = edit([["setKind", "b1", "인력"], ["addElement", "b1", call("elementOf", "인력", LABOR)]]) - assert one["boxes"][0]["unit"] == "원/일" - two = edit([["addElement", "b1", call("elementOf", "인력", LABOR2)]], one) - assert two["boxes"][0]["unit"] == "원/일" and len(two["boxes"][0]["input"]["elements"]) == 2 - oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} - assert edit([["addElement", "b1", oil]], two)["boxes"][0]["unit"] == "-" - - -def test_같은_요소_이름은_한_번만(): - el = call("elementOf", "인력", LABOR) - lg = edit( - [ - ["setKind", "b1", "인력"], - ["addElement", "b1", el], - ["addElement", "b1", el], - ["addElement", "b1", {**el, "ref": "다른"}], - ] - ) - assert len(lg["boxes"][0]["input"]["elements"]) == 1 - - -def test_요소를_빼면_그_알약에서_나간_선도_지움(): - el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) - base = edit([["setKind", "b1", "인력"], ["addElement", "b1", el1], ["addElement", "b1", el2]]) - base["links"] = [ - {"id": "l1", "from": {"box": "b1", "name": "석공"}, "to": {"box": "b2", "name": "a"}}, - {"id": "l2", "from": {"box": "b1", "name": "보통인부"}, "to": {"box": "b2", "name": "b"}}, - ] - out = edit([["removeElement", "b1", "LB000033"]], base) - assert [x["id"] for x in out["links"]] == ["l2"] - assert [e["name"] for e in out["boxes"][0]["input"]["elements"]] == ["보통인부"] - - -def test_갈래를_바꾸면_고른_것을_비움_표는_요소와_함께_못_둠(): - el = call("elementOf", "인력", LABOR) - lg = edit([["setKind", "b1", "인력"], ["addElement", "b1", el], ["setKind", "b1", "재료"]]) - assert lg["boxes"][0]["input"]["group"] == "재료" - assert lg["boxes"][0]["input"]["elements"] == [] - t = edit([["setKind", "b1", "소요량"], ["setTable", "b1", {"key": "QF000421"}, "메쌓기(인력)"]]) - assert t["boxes"][0]["input"]["table"] == {"key": "QF000421"} - assert t["boxes"][0]["name"] == "메쌓기(인력)" and t["boxes"][0]["unit"] == "-" - row = edit([["setTable", "b1", {"key": "QF000421", "row": 3}]], t) - assert row["boxes"][0]["input"]["table"] == {"key": "QF000421", "row": 3} - assert call("pillNames", row["boxes"][0]) == ["표"] - - -def test_변수_넣기_고치기_삭제_알약_이름(): - v = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} - lg = edit([["setKind", "b1", "변수"], ["putVariable", "b1", v]]) - box = lg["boxes"][0] - assert box["input"]["mode"] == "variable" and box["unit"] == "㎡" - assert call("pillNames", box) == ["면적"] - renamed = edit([["putVariable", "b1", {**v, "name": "연장", "unit": "m"}, "면적"]], lg) - assert call("pillNames", renamed["boxes"][0]) == ["연장"] and renamed["boxes"][0]["unit"] == "m" - gone = edit([["removeVariable", "b1", "연장"]], renamed) - assert gone["boxes"][0]["input"]["variables"] == [] and gone["boxes"][0]["unit"] == "-" - - -def test_변수_검사는_쉬운_말(): - ok = {"name": "면적", "type": "number", "unit": "㎡", "example": 100, "range": [1, 1000]} - assert call("checkVariable", ok, []) is None - assert call("checkVariable", {**ok, "name": " "}, []) == "이름을 적어 주세요" - assert call("checkVariable", ok, [ok]) == "같은 이름의 변수가 이미 있음" - assert call("checkVariable", {**ok, "range": [9, 1]}, []) == "범위는 아래가 위보다 작아야 함" - assert call("checkVariable", {**ok, "example": 5000}, []) == "예시값이 범위 밖" - many = {**ok, "type": "choice", "choices": ["가"]} - assert call("checkVariable", many, []) == "선택 목록은 둘 이상" - bad = {**ok, "type": "choice", "choices": ["가", "나"], "example": "다"} - assert call("checkVariable", bad, []) == "예시값이 선택 목록에 없음" - - -def test_환율_유가전력_알약_이름에_날짜가_안_들어감(): - fx = {"키": "FX1", "이름": "USD", "규격": "2026-08-14", "단위": "원/USD", "값": 1414.9} - assert call("elementOf", "환율", fx)["name"] == "USD" - oil = { - "키": "MO1", - "이름": "휘발유", - "규격": "2026-08-14", - "상세구분": "서울", - "단위": "원/L", - "값": 1900, - } - assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울" - - -def test_단위가_안_맞게_저장된_상자는_열_때_맞춤(): - el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) - lg = logic() - lg["boxes"][0]["input"] = { - "mode": "group", - "group": "인력", - "elements": [el1, el2], - "variables": [], - } - assert lg["boxes"][0]["unit"] == "-" - fixed = run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0] - assert fixed["boxes"][0]["unit"] == "원/일" - again = run([{"op": "fn", "name": "reconcileUnit", "args": [fixed, "b1"]}])[0] - assert again == fixed - oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} - lg["boxes"][0]["input"]["elements"].append(oil) - lg["boxes"][0]["unit"] = "원/일" - assert ( - run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] - == "-" - ) - one = { - "mode": "variable", - "elements": [], - "variables": [{"name": "면적", "type": "number", "unit": "㎡", "example": 1}], - } - lg["boxes"][0]["input"] = one - assert ( - run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] - == "㎡" - ) diff --git a/resources/tester/test_m02_cf_model.py b/resources/tester/test_m02_cf_model.py deleted file mode 100644 index 818541127..000000000 --- a/resources/tester/test_m02_cf_model.py +++ /dev/null @@ -1,348 +0,0 @@ -"""M02 단가산출 다이어그램 Model · Eval 시험(35-7) — Node 로 `M02_CF_Model.ts` · `M02_CF_Eval.ts` 와 진짜 스프레드시트 엔진을 돌림. - -· 견본 둘은 이 시험 안에서 만듦 — 마스터 · `_data` 에 기대지 않음 · 저장 안 함 -· 입력(인력 석공 노임) + 변수(면적) → 함수(품 × 면적 × 노임) → 출력 노무비 · 직렬 · 병렬 · 순환 막음 · 선 규칙 · 출력 하나 · 원가 합 · 검사 -· TypeScript 를 그 자리에서 CommonJS 로 옮겨 부름(`helper_spreadsheet_engine.cjs` 와 같은 길) -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -CF = ROOT / "M02_MasterTemplete" / "CostFlow" - -pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") - -LOADER = r""" -const fs = require("fs"); -const path = require("path"); -const ROOT = process.argv[1]; -const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); -const cache = new Map(); -function loadTs(file) { - const full = path.resolve(file); - if (cache.has(full)) return cache.get(full).exports; - const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, - }).outputText; - const mod = { exports: {} }; - cache.set(full, mod); - const req = (r) => { - if (r === "@ui/ui_template_locale") return { currentLanguageIndex: 0 }; - if (r.endsWith(".css")) return {}; - if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); - if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), r.endsWith(".ts") ? r : `${r}.ts`)); - return require(r); - }; - new Function("exports", "module", "require", js)(mod.exports, mod, req); - return mod.exports; -} -const M = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Model.ts")); -const E = loadTs(path.join(ROOT, "M02_MasterTemplete", "CostFlow", "M02_CF_Eval.ts")); -const out = (x) => console.log(JSON.stringify(x)); -const add = (lg, kind, x = 0, y = 0) => M.addBox(lg, kind, x, y); -const join = (lg, a, ap, b, bp) => { - const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }); - if (r.why) throw new Error(r.why); - return r.lg; -}; -/** 함수 상자 시트 · 받는 칸 · 변수 · 출력 칸 한 번에 */ -const func = (lg, box, cells, inputs, output, variables = []) => - M.updateBox(lg, box.id, { func: { ...box.func, sheet: { ...box.func.sheet, 칸: cells }, inputs, variables, - output: { ...box.func.output, ...output } } }); -""" - - -def _run(body: str) -> object: - result = subprocess.run( - ["node", "-e", LOADER + body, str(ROOT)], - capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr - return json.loads(result.stdout.strip().splitlines()[-1]) - - -# 견본 하나 — 입력(인력 석공) + 입력(변수 면적) → 함수(품 × 면적 × 노임) → 출력 노무비 -ONE = r""" -let lg = M.newLogic("견본 하나", "원/㎡"); -let r = add(lg, "input"); lg = r.lg; let man = r.box; -lg = M.updateBox(lg, man.id, { input: { mode: "group", group: "인력", - elements: [{ ref: "LB000033", name: "석공", spec: "", unit: "인" }], variables: [] } }); -r = add(lg, "input"); lg = r.lg; let area = r.box; -lg = M.updateBox(lg, area.id, { input: { mode: "variable", elements: [], - variables: [{ name: "면적", type: "number", unit: "㎡", example: 10 }] } }); -r = add(lg, "function"); lg = r.lg; let f = r.box; -lg = func(lg, f, { A2: { 값: "노임" }, A3: { 값: "면적" }, A4: { 값: "품" }, A5: { 값: "노무비" }, B5: { 식: "B4*B3*B2" } }, - [{ port: "노임", cell: "B2" }, { port: "면적", cell: "B3" }], { cell: "B5", name: "노무비", unit: "원" }, - [{ name: "품", type: "number", unit: "인/㎡", example: 0.2, cell: "B4" }]); -r = add(lg, "output"); lg = r.lg; let o = r.box; -lg = join(lg, man, "석공", f, "노임"); -lg = join(lg, area, "면적", f, "면적"); -lg = join(lg, f, "노무비", o, "값"); -const V = { [man.id]: { 석공: 268908 } }; -""" - -# 견본 둘 — 직렬 · 병렬: f1 = 면적 × 2 · f2 = 노임 × 0.1 · f3 = f1 + f2(병렬 모음) · f4 = f3 × 1.1(직렬) -TWO = r""" -let lg = M.newLogic("견본 둘"); -let r = add(lg, "input"); lg = r.lg; const inp = r.box; -lg = M.updateBox(lg, inp.id, { input: { mode: "group", group: "인력", - elements: [{ ref: "LB000002", name: "보통인부", spec: "", unit: "인" }], - variables: [{ name: "면적", type: "number", unit: "㎡", example: 5 }] } }); -const fn = (cells, inputs) => { r = add(lg, "function"); lg = r.lg; const b = r.box; - lg = func(lg, b, cells, inputs, { cell: "B9", name: "결과" }); return b; }; -const f1 = fn({ B9: { 식: "B1*2" } }, [{ port: "가", cell: "B1" }]); -const f2 = fn({ B9: { 식: "B1*0.1" } }, [{ port: "가", cell: "B1" }]); -const f3 = fn({ B9: { 식: "B1+B2" } }, [{ port: "가", cell: "B1" }, { port: "나", cell: "B2" }]); -const f4 = fn({ B9: { 식: "ROUND(B1*1.1,0)" } }, [{ port: "가", cell: "B1" }]); -lg = join(lg, inp, "면적", f1, "가"); -lg = join(lg, inp, "보통인부", f2, "가"); -lg = join(lg, f1, "결과", f3, "가"); -lg = join(lg, f2, "결과", f3, "나"); -lg = join(lg, f3, "결과", f4, "가"); -r = add(lg, "output"); lg = r.lg; const oExp = r.box; lg = M.updateBox(lg, oExp.id, { output: { cost: "경비", unit: "원" } }); -r = add(lg, "output"); lg = r.lg; const oVal = r.box; lg = M.updateBox(lg, oVal.id, { output: { cost: "값", unit: "㎡" } }); -r = add(lg, "output"); lg = r.lg; const oMat = r.box; lg = M.updateBox(lg, oMat.id, { output: { cost: "재료비", unit: "원" } }); -lg = join(lg, f4, "결과", oExp, "값"); -lg = join(lg, f1, "결과", oVal, "값"); -lg = join(lg, inp, "면적", oMat, "값"); -const V = { [inp.id]: { 보통인부: 172068 } }; -""" - - -def test_견본하나_입력_변수_함수_출력_노무비() -> None: - got = _run( - ONE - + "out([E.evalLogic(lg, V), E.checkLogic(lg, V), lg.boxes.map(b => [b.id, b.kind, b.name, b.color, b.unit])]);" - ) - ev, check, boxes = got - assert ev["ports"]["b3"] == {"노무비": 537816} # 0.2 × 10 × 268,908 - assert ev["outputs"] == {"b4": 537816} - assert ev["cost"] == {"노무비": 537816, "재료비": 0, "경비": 0, "값들": {}, "계": 537816} - assert ev["errors"] == [] and check == [] - assert boxes == [ - ["b1", "input", "입력", "#2f80ed", "-"], - ["b2", "input", "입력 2", "#2f80ed", "-"], - ["b3", "function", "함수", "#7b4fd6", "-"], - ["b4", "output", "출력", "#27ae60", "-"], - ] - - -def test_변수값_바꾸면_값이_바뀜() -> None: - got = _run( - ONE - + "out(E.evalLogic(lg, { ...V, [area.id]: { 면적: 20 }, [f.id]: { 품: 0.1 } }).cost.계);" - ) - assert got == 0.1 * 20 * 268908 - - -def test_견본둘_직렬_병렬_원가_합() -> None: - got = _run( - TWO - + "const ev = E.evalLogic(lg, V);\nout([ev.ports, ev.cost, ev.errors, E.checkLogic(lg, V)]);" - ) - ports, cost, errors, check = got - f1, f2, f3, f4 = (ports[k]["결과"] for k in ("b2", "b3", "b4", "b5")) - assert (f1, f2, f3, f4) == (10, 17206.8, 17216.8, 18938) - assert cost == {"노무비": 0, "재료비": 5, "경비": 18938, "값들": {"출력 2": 10}, "계": 18943} - assert errors == [] and check == [] - - -def test_선_규칙() -> None: - got = _run( - TWO - + "const t = (a, ap, b, bp) => M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }).why;\n" - "out([t(f4, '결과', f3, '나'), t(f3, '결과', f1, '가'), t(oExp, '값', f1, '가'), t(f1, '결과', inp, '면적')," - " t(f3, '결과', f3, '가'), t(f2, '결과', f3, '가'), t(f1, '없음', f4, '가'), t(inp, '면적', oExp, '값')," - " M.link(lg, { box: f1.id, name: '결과' }, { box: f3.id, name: '가' }).lg === lg]);" - ) - assert got == [ - "이으면 돌고 도는 선이 됨", - "이으면 돌고 도는 선이 됨", - "출력 상자에서는 선이 나가지 않음", - "입력 상자로는 선이 들어가지 않음", - "같은 상자끼리는 못 이음", - "이미 선이 들어온 자리", - "알약이 없음", - "이미 선이 들어온 자리", - True, - ] - - -def test_알약_출력은_하나() -> None: - got = _run( - TWO - + "lg = M.updateBox(lg, inp.id, { input: { ...lg.boxes[0].input, table: { key: 'QF000421' } } });\n" - "out([M.portsOut(lg.boxes[0]), M.portsIn(lg.boxes[0]), M.portsOut(lg.boxes[3]), M.portsIn(lg.boxes[3])," - " M.portsOut(lg.boxes.find(b => b.id === oExp.id)), M.portsIn(lg.boxes.find(b => b.id === oExp.id))]);" - ) - in_out, in_in, f_out, f_in, o_out, o_in = got - assert in_out == [ - {"name": "보통인부", "unit": "인"}, - {"name": "표", "unit": "-"}, - {"name": "면적", "unit": "㎡"}, - ] - assert in_in == [] and f_out == [{"name": "결과", "unit": "-"}] - assert f_in == [{"name": "가", "unit": "-"}, {"name": "나", "unit": "-"}] - assert o_out == [] and o_in == [{"name": "값", "unit": "원"}] - - -def test_선_끊기_상자_지우기_검사() -> None: - got = _run( - TWO + "const cut = lg.links.find(l => l.to.box === f3.id && l.to.name === '나').id;\n" - "const a = M.unlink(lg, cut);\n" - "const b = M.removeBox(lg, f3.id);\n" - "const c = M.removeBox(M.removeBox(M.removeBox(lg, oExp.id), oVal.id), oMat.id);\n" - "out([E.evalLogic(a, V).ports[f3.id], E.checkLogic(a, V), b.links.length, lg.links.length," - " E.checkLogic(b, V), E.checkLogic(c, V), E.checkLogic(lg, {})]);" - ) - f3_after, check_a, n_b, n_all, check_b, check_c, check_novalue = got - assert f3_after == {"결과": 10} - assert check_a == ["「함수 3」 의 받는 알약 「나」 에 선이 없음"] - assert (n_b, n_all) == (5, 8) - assert check_b == [ - "「함수 4」 의 받는 알약 「가」 에 선이 없음", - ] - assert check_c == ["출력 상자가 없음"] - assert check_novalue == ["「입력」 의 「보통인부」 값이 아직 없음"] - - -def test_오류칸_순환칸_숫자아닌_출력() -> None: - got = _run( - ONE - + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," - " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B4/0' } } } } });\n" - "const a = E.evalLogic(lg, V);\n" - "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, sheet: { ...lg.boxes[2].func.sheet," - " 칸: { ...lg.boxes[2].func.sheet.칸, B5: { 식: 'B6' }, B6: { 식: 'B5' } } } } });\n" - "const b = E.evalLogic(lg, V);\n" - "out([a.errors, a.cost.계, b.errors]);" - ) - a_err, total, b_err = got - assert a_err == ["「함수」 B5 칸 계산이 막힘 — #DIV/0!", "「출력」 에 숫자가 아닌 값이 들어옴"] - assert total == 0 - assert "「함수」 B5 칸 계산이 막힘 — #CYCLE! 돌고 도는 참조" in b_err - - -def test_자리_색_단위() -> None: - got = _run( - ONE - + "let g = M.moveBox(lg, f.id, 120, 40); g = M.setColor(g, f.id, '#ff0000'); g = M.setUnit(g, f.id, '원/㎡');\n" - "g = M.setUnit(g, o.id, ' ');\n" - "const fb = g.boxes.find(b => b.id === f.id), ob = g.boxes.find(b => b.id === o.id);\n" - "out([fb.x, fb.y, fb.color, fb.unit, fb.func.output.unit, ob.unit, ob.output.unit, lg.boxes[2].x]);" - ) - assert got == [120, 40, "#ff0000", "원/㎡", "원/㎡", "-", "-", 0] - - -def test_사용자_요소_값() -> None: - got = _run( - ONE - + "lg = { ...lg, userElements: [{ id: 'U1', group: '인력', name: '석공', spec: '', unit: '인', value: 300000 }] };\n" - "lg = M.updateBox(lg, man.id, { input: { ...lg.boxes[0].input, elements: [{ ref: 'U1', name: '석공', spec: '', unit: '인' }] } });\n" - "out(E.evalLogic(lg, {}).cost.노무비);" - ) - assert got == 0.2 * 10 * 300000 - - -def test_화면_글에_금지_낱말_없음() -> None: - for name in ("M02_CF_Model.ts", "M02_CF_Eval.ts"): - src = (CF / name).read_text(encoding="utf-8") - block = src[src.index("const TEXT = {") : src.index("} as const;")] - korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] - assert korean - for text in korean: - for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): - assert bad not in text, (name, bad, text) - - -def test_알약_이름_규칙() -> None: - got = _run( - "let lg = M.newLogic(); let r = add(lg, 'input'); lg = r.lg;\n" - "lg = M.updateBox(lg, r.box.id, { input: { mode: 'group', group: '재료'," - " elements: [{ ref: 'MT1', name: '시멘트', spec: '40kg', unit: '포' }], table: { key: 'QF000421', row: 2 }," - " variables: [{ name: '길이', type: 'number', unit: 'm', example: 3 }] } });\n" - "out(M.portsOut(lg.boxes[0]).map(p => p.name));" - ) - assert got == ["시멘트", "표", "길이"] - - -def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None: - got = _run( - ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" - "let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n" - "let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n" - "let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n" - "let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n" - "const gb = (x) => x.boxes.find(y => y.id === g.id);\n" - "const inputs = gb(d.lg).func.inputs;\n" - "const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n" - "const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n" - "const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n" - "const removed = M.removeBox(d.lg, man.id);\n" - "out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to)," - " check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port)," - " M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);" - ) - a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got - assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None) - assert inputs == [ - {"port": "석공", "cell": ""}, - {"port": "면적", "cell": ""}, - {"port": "노무비", "cell": ""}, - ] - assert ports == ["석공", "면적", "노무비"] - assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}] - assert check == [ - "「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음", - "「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음", - "「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음", - ] - assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음 - assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움 - assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음 - - -def test_같은_이름_받는_알약은_번호() -> None: - got = _run( - ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" - "let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" - "x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n" - "r = add(x, 'input'); x = r.lg; const m2 = r.box;\n" - "x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n" - "x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" - "x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n" - "out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));" - ) - assert got == ["석공", "노무비", "석공 (2)", "할증"] - - -def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호() -> None: - got = _run( - "let lg = M.newLogic(); let r = add(lg, 'function'); lg = r.lg; const f1 = r.box;\n" - "r = add(lg, 'function'); lg = r.lg; const f2 = r.box;\n" - "r = add(lg, 'function'); lg = r.lg; const f3 = r.box;\n" - "lg = M.removeBox(lg, f2.id); r = add(lg, 'function'); lg = r.lg; const f4 = r.box;\n" - "lg = M.link(lg, { box: f1.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" - "lg = M.link(lg, { box: f4.id, name: '결과 2' }, { box: f3.id, name: '' }).lg;\n" - "r = add(lg, 'function'); lg = r.lg; const f5 = r.box;\n" - "lg = M.updateBox(lg, f5.id, { func: { ...f5.func, output: { ...f5.func.output, name: '결과 1' } } });\n" - "lg = M.link(lg, { box: f5.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" - "out([[f1, f3, f4, f5].map(b => [b.func.output.name, b.func.sheet.칸.A1.값])," - " lg.boxes.find(b => b.id === f3.id).func.inputs.map(i => i.port)]);" - ) - names, ports = got - assert names == [ - ["결과 1", "결과 1"], - ["결과 3", "결과 3"], - ["결과 2", "결과 2"], - ["결과 4", "결과 4"], - ] - assert ports == ["결과 1", "결과 2", "결과 1 (2)"] diff --git a/resources/tester/test_m02_cf_router.py b/resources/tester/test_m02_cf_router.py deleted file mode 100644 index 6b8e4bf83..000000000 --- a/resources/tester/test_m02_cf_router.py +++ /dev/null @@ -1,353 +0,0 @@ -"""단가산출 다이어그램 시험 길(`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"]] == ["견본 돌쌓기"] - - -@pytest.mark.skipif(NO_NODE, reason="node 가 없음") -def test_기계_키_칸_꼴과_시간당_경비(client: TestClient) -> None: - """입력 판(sub3) 기계 ref 「EQ키#칸」 · 물음 {ref, field} · 「시간당 경비」 = 기계경비 로직.""" - logic = min( - (r for r in cf._machine_family() if "지역" in [i["이름"] for i in r["입력"]]), - key=cf._machine_rank, - ) - code = str(cf._codes(logic)[0]) - eq = _first("기계.json", lambda r: str(r.get("원문번호")) == code) - family = [r for r in cf._machine_family() if code in [str(c) for c in cf._codes(r)]] - chosen = min(family, key=cf._machine_rank) - given = { - i["이름"]: (code if i["이름"] == "기계" else (i.get("고르기") or [None])[0]) - for i in chosen["입력"] - } - want = _post(client, "/api/m01/calc", {"key": chosen["키"], "inputs": given})["sums"]["계"] - - items = [ - {"id": "p", "group": "기계", "ref": f"{eq['키']}#가격"}, - {"id": "p2", "group": "기계", "ref": eq["키"], "field": "가격"}, - {"id": "h", "group": "기계", "ref": f"{eq['키']}#시간당 경비"}, - {"id": "h2", "group": "기계", "ref": eq["키"], "field": "시간당 경비"}, - {"id": "x", "group": "기계", "ref": f"{eq['키']}#없는칸"}, - ] - got = {v["id"]: v for v in _post(client, "/api/m02/cf/values", {"items": items})["values"]} - assert got["p"]["value"] == got["p2"]["value"] == eq["값"]["가격"] - assert got["h"]["value"] == got["h2"]["value"] == want and got["h"]["unit"] == "원/시간" - assert got["x"]["ok"] is False and "없는칸" not in got["x"].get("value", "") - - # 저장 — 입력 상자 요소 ref 그대로(「EQ키#칸」) - box = _box("m1", "input", "기계", input={"mode": "group", "group": "기계", "variables": [], - "elements": [{"ref": f"{eq['키']}#시간당 경비", "name": "기계 · 시간당 경비", - "spec": "", "unit": "원/시간"}]}) # fmt: skip - f1 = _box("f1", "function", "곱", func=_func( - {"id": "s1", "이름": "곱", "칸": {"B1": {"식": "A1*2"}}}, - [{"port": "단가", "cell": "A1"}], - {"cell": "B1", "name": "경비", "unit": "원"})) # fmt: skip - o1 = _box("o1", "output", "경비", output={"cost": "경비", "unit": "원"}) - body = {"key": None, "name": "기계 견본", "resultUnit": "원/시간", "boxes": [box, f1, o1], - "links": [_link("a", "m1", "기계 · 시간당 경비", "f1", "단가"), - _link("b", "f1", "경비", "o1", "값")], "userElements": []} # fmt: skip - saved = _post(client, "/api/m02/cf/save", {"logic": body}) - assert _d(saved["values"]["cost"]["경비"]) == 2 * _d(want) - _post(client, "/api/m02/cf/delete", {"key": saved["key"]}) diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py deleted file mode 100644 index 00d6fb661..000000000 --- a/resources/tester/test_m02_cf_sample.py +++ /dev/null @@ -1,259 +0,0 @@ -"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`. - -· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) -· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 - (Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만 -· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100 -· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 -""" - -from __future__ import annotations - -import json -import re -import shutil -import subprocess -from decimal import Decimal -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow" -SAMPLE_TS = FOLDER / "M02_CF_Sample.ts" -SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df" / "costflow").glob("*.json"))} # fmt: skip - -HOOKS = r""" -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { stripTypeScriptTypes } from "node:module"; -export async function load(url, ctx, next) { - if (!url.endsWith(".ts")) return next(url, ctx); - let src = readFileSync(fileURLToPath(url), "utf8"); - src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples"); - src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { - const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); - return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; - }); - return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; -} -""" - - -def _node(body: str) -> object: - files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()} - script = ( - "import { register } from 'node:module';\n" - f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" - "globalThis.__stub = { currentLanguageIndex: 0 };\n" - f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n" - f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n" - "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body - ) - result = subprocess.run( - ["node", "--no-warnings", "--input-type=module", "-e", script], - capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, - ) # fmt: skip - assert result.returncode == 0, result.stderr[-2000:] - return json.loads(result.stdout) - - -# ── 모양 ───────────────────────────────────────────────────────────────── -def _ports_out(box: dict) -> list[str]: - if box["kind"] == "function": - return [box["func"]["output"]["name"]] - if box["kind"] == "output": - return [] - inp = box["input"] - return ( - [e["name"] for e in inp.get("elements", [])] - + (["표"] if inp.get("table") else []) - + [v["name"] for v in inp.get("variables", [])] - ) - - -def _ports_in(box: dict) -> list[str]: - if box["kind"] == "function": - return [i["port"] for i in box["func"]["inputs"]] - return ["값"] if box["kind"] == "output" else [] - - -def _order(lg: dict) -> list[dict]: - """함수 상자 차례(앞 값을 받는 것이 뒤) — 순환이면 실패""" - boxes = {b["id"]: b for b in lg["boxes"]} - need = {b["id"]: {link["from"]["box"] for link in lg["links"] if link["to"]["box"] == b["id"]} for b in lg["boxes"]} # fmt: skip - done: list[str] = [] - while len(done) < len(boxes): - ready = [i for i in boxes if i not in done and need[i] <= set(done)] - assert ready, "선이 돌고 돎" - done += ready - 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] - assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == [] - boxes = {b["id"]: b for b in lg["boxes"]} - assert len(boxes) == len(lg["boxes"]) - sheets = [b["func"]["sheet"]["id"] for b in lg["boxes"] if b["kind"] == "function"] - assert len(set(sheets)) == len(sheets) - for b in lg["boxes"]: - assert b["kind"] in ("input", "function", "output") - assert ( - b["color"] == BOX_COLOR[b["kind"]] - and b["unit"] - and isinstance(b["x"], int) - and isinstance(b["y"], int) - ) - if b["kind"] == "function": - fn = b["func"] - cells = fn["sheet"]["칸"] - assert "식" in cells[fn["output"]["cell"]] # 출력 하나 = 수식 칸 - assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) - if b["kind"] == "output": - assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") - units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []} - if len(units) == 1: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙) - assert b["unit"] == next(iter(units)), b["id"] - for link in lg["links"]: - a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] - assert link["from"]["name"] in _ports_out(a), link - assert link["to"]["name"] in _ports_in(z), link - # 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오 - for b in lg["boxes"]: - for port in _ports_in(b): - got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port] - assert len(got) == 1, (b["id"], port) - for link in lg["links"]: - assert boxes[link["from"]["box"]]["kind"] != "output" - assert boxes[link["from"]["box"]]["x"] < boxes[link["to"]["box"]]["x"] - _order(lg) - - -def test_견본_목록_열기() -> None: - got = _node( - "out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });" - ) - assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"] - assert all(x["note"] for x in got["list"]) - assert got["one"]["key"] is None and got["one"]["boxes"] == SAMPLES["masonry"]["boxes"] - assert got["none"] is None - - -# ── 값 ─────────────────────────────────────────────────────────────────── -@pytest.fixture(scope="module") -def master(): - from M01_MasterData import M01_MasterData_Store as store - - cm = store.cm - files = cm.load(folder=store.FOLDER) - return cm, files - - -def _table_rows(table: dict) -> list[list]: - """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)""" - 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 ((f"{n}·아래", f"{n}·위") if n in split else (n,))] - rows = [] - for r in lines: - line: list = [] - 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(v) - rows.append(line) - return [head, *rows] - - -def _plain(v): - return float(v) if isinstance(v, Decimal) else v - - -def _run(lg: dict, master) -> dict: - """상자 차례대로 — 입력 값(예시값 · 노임 · 표) → 함수 상자 시트에 넣고 엔진으로 셈 → 출력 상자 값""" - from common_util.common_util_spreadsheet import recalc_workbooks - - cm, files = master - m = cm.mf.Master(files) - tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip - ports: dict[str, dict] = {} - for b in _order(lg): - into = {x["to"]["name"]: ports[x["from"]["box"]][x["from"]["name"]] for x in lg["links"] if x["to"]["box"] == b["id"]} # fmt: skip - if b["kind"] == "input": - inp = b["input"] - got = {v["name"]: v["example"] for v in inp.get("variables", [])} - got |= { - e["name"]: _plain(cm.mf.element(m, e["ref"], {})[0]) - for e in inp.get("elements", []) - } - if inp.get("table"): - got["표"] = _table_rows(tables[inp["table"]["key"]]) - ports[b["id"]] = got - elif b["kind"] == "function": - fn = b["func"] - sheet = json.loads(json.dumps(fn["sheet"])) - for i in fn["inputs"]: - value = into[i["port"]] - col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:]) - block = value if isinstance(value, list) else [[value]] - for r, line in enumerate(block): - for c, x in enumerate(line): - if x is not None: - sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)} - book = {"종류": "통합문서", "판": 1, "열": "cf", "서식": [{}], "시트": [sheet]} - out = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] - assert "수" in out, out - ports[b["id"]] = {fn["output"]["name"]: float(out["수"])} - else: - ports[b["id"]] = {"값": into["값"]} - return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} - - -def test_견본_표_그룹이_마스터와_같음(master) -> None: - """표 입력 상자의 group = 그 표가 든 마스터 파일 그룹(소요량 · 계수)""" - cm, files = master - group = {str(t["키"]): d["그룹"] for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip - for lg in SAMPLES.values(): - for b in lg["boxes"]: - table = b.get("input", {}).get("table") - if table: - assert b["input"]["group"] == group[table["key"]], (b["id"], table["key"]) - - -def test_견본1_원가_calc_와_같음(master) -> None: - from M01_MasterData import M01_MasterData_Store as store # /calc 와 같은 셈 - - given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} - want = store.calc("GF000219", {**given, "초과증가율": 80}) - assert want["ok"], want - got = _run(SAMPLES["masonry"], master)["out-pay"] - print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}") - assert abs(got - float(want["sums"]["계"])) < 1 - - -def test_견본3_값_calc_와_같음_견본2_비율(master) -> None: - from M01_MasterData import M01_MasterData_Store as store # /calc 와 같은 셈 - - given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} - given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」 - want = store.calc("GF000133", given) - assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"]) - assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100