diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 1daa1b757..b0daf8a6a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -7,7 +7,7 @@ * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * ========================================================================== */ -import { t as L } from "@ui/ui_template_locale"; +import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; import { createButton, el, @@ -75,6 +75,50 @@ interface Opened extends Draft { const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); + +/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */ +function trialMenu(items: HTMLElement[]): HTMLElement { + const list = el("div", { attrs: { role: "menu" }, children: items }); + Object.assign(list.style, { + position: "absolute", + top: "calc(100% + 4px)", + left: "0", + zIndex: "20", + display: "none", + flexDirection: "column", + gap: "var(--spacing-4)", + padding: "var(--spacing-8)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-lg)", + background: "var(--color-surface-raised)", + boxShadow: "var(--shadow-lg)", + whiteSpace: "nowrap", + }); + const shut = (): void => { + list.style.display = "none"; + button.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", outside, true); + }; + const outside = (ev: Event): void => { + if (!wrap.contains(ev.target as Node)) shut(); + }; + const button = createButton({ + label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0], + variant: "ghost", + onClick: () => { + if (list.style.display !== "none") return shut(); + list.style.display = "flex"; + button.setAttribute("aria-expanded", "true"); + document.addEventListener("click", outside, true); + }, + }); + button.setAttribute("aria-haspopup", "menu"); + list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶 + const wrap = el("div", { children: [button, list] }); + Object.assign(wrap.style, { position: "relative", display: "inline-flex" }); + wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut()); + return wrap; +} const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -406,14 +450,16 @@ export async function mountM01LogicLab( el("h2", { text: tx("Title") }), pending, createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), - // 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createEasyCalcButton(), - // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createLogicBuilderButton(), - // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createSheetLogicButton(), - // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createCostFlowButton(), + trialMenu([ + // 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createEasyCalcButton(), + // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createLogicBuilderButton(), + // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createSheetLogicButton(), + // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createCostFlowButton(), + ]), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css index fc9c8ee8f..467e29d7a 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css @@ -256,3 +256,21 @@ 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 index d8f55a7b5..5ba3662b6 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts @@ -53,6 +53,10 @@ const TEXT = { From: ["어디서", "From"], To: ["어디로", "To"], Value: ["지금 값", "Now"], + PlusIn: [ + "받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김", + "Drop an output pill here to add an input", + ], LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], Hint: [ "상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대", @@ -89,29 +93,9 @@ export interface DiagramOpts { /* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */ -/** 알약 — Model 이 아직이면 상자 칸에서 바로 뽑음 */ +/** 알약 — Model 의 받는 · 내보내는 알약 */ export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } { - try { - return { ins: portsIn(box), outs: portsOut(box) }; - } catch { - if (box.kind === "input") { - const inp = box.input; - const outs = [ - ...(inp?.elements ?? []).map((e) => ({ name: e.name, unit: e.unit })), - ...(inp?.table ? [{ name: "표", unit: "-" }] : []), - ...(inp?.variables ?? []).map((v) => ({ name: v.name, unit: v.unit })), - ]; - return { ins: [], outs }; - } - if (box.kind === "function") { - const f = box.func; - return { - ins: (f?.inputs ?? []).map((p) => ({ name: p.port, unit: "-" })), - outs: f ? [{ name: f.output.name, unit: f.output.unit }] : [], - }; - } - return { ins: [{ name: "값", unit: box.unit }], outs: [] }; - } + return { ins: portsIn(box), outs: portsOut(box) }; } /** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */ @@ -124,6 +108,9 @@ export function resolveDrop( 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 = @@ -163,7 +150,10 @@ export function arrange( 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 }, @@ -174,12 +164,11 @@ export function fitView( 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.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)); - return { - zoom, - panX: (view.w - (x1 - x0) * zoom) / 2 - x0 * zoom, - panY: (view.h - (y1 - y0) * zoom) / 2 - y0 * zoom, - }; + 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))); @@ -201,10 +190,15 @@ interface HostState { const states = new WeakMap(); const SVG = "http://www.w3.org/2000/svg"; -const showValue = (v: unknown): string => { +/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */ +export const showValue = (v: unknown): string => { if (v === null || v === undefined || v === "") return ""; if (typeof v === "number") - return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4)); + 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); }; @@ -300,6 +294,28 @@ export function renderDiagram( 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", { @@ -323,7 +339,13 @@ export function renderDiagram( el("div", { className: "m02-cfdg__pills", children: [ - el("div", { className: "m02-cfdg__ins", children: ins.map((p) => pillEl(b, p, "in")) }), + 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")), diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css index 98c3faa23..63f66e9fc 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css @@ -115,10 +115,6 @@ padding: var(--spacing-12); } -.m02-cf__diagram .m02-cf__part-body { - min-height: 60vh; -} - .m02-cf__lower { display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); @@ -189,3 +185,36 @@ 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 index 84e7a2f91..d28a72125 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -1,9 +1,10 @@ /* ============================================================================= * M02_CF_Entry.ts - * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀 + * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게. - * 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음. + * 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary + * → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S. * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). * ========================================================================== */ @@ -17,21 +18,49 @@ import { 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 } from "./M02_CF_Api"; -import type { CfLogic } from "./M02_CF_Model"; +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"], + Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], Name: ["로직 이름", "Logic name"], NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], Unit: ["결과 단위", "Result unit"], @@ -41,11 +70,24 @@ const TEXT = { Unsaved: ["저장 안 함", "Not saved"], Diagram: ["다이어그램", "Diagram"], Box: ["고른 상자", "Chosen box"], - Panel_Input: ["입력", "Input"], - Panel_Function: ["함수", "Function"], - Panel_Output: ["출력", "Output"], + PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], Summary: ["원가", "Cost"], - NotYet: ["준비 중", "Coming soon"], + 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?", @@ -55,17 +97,14 @@ const TEXT = { type TextKey = keyof typeof TEXT; -const te = (key: TextKey): string => - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; +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 }); -/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */ -const blankLogic = (name: string, unit: string): Promise => - import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null); -const loadList = (): Promise => - import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []); - /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ async function unitChoices(): Promise { const count = new Map(); @@ -83,6 +122,46 @@ async function unitChoices(): Promise { 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 }); @@ -90,8 +169,9 @@ export function createCostFlowButton(): HTMLButtonElement { function openCostFlow(): void { /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean } | null = null; + 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")); @@ -103,7 +183,7 @@ function openCostFlow(): void { variant: "ghost", onClick: () => void (async () => { - if (await mayLeave()) shut(); + if (await mayLeave()) paper ? void showStart() : shut(); })(), }); const page = el("div", { @@ -128,42 +208,52 @@ function openCostFlow(): void { document.body.style.overflow = prevOverflow; }; page.addEventListener("keydown", (ev) => { - if (ev.key !== "Escape" || ev.defaultPrevented) return; - void (async () => { - if (await mayLeave()) shut(); - })(); + 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 = (item: CfListItem): HTMLElement => { + const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { const row = el("button", { className: "m02-cf__item", - attrs: { type: "button", title: item.saved }, + attrs: { type: "button", title }, children: [ - el("span", { className: "m02-cf__item-name", text: item.name }), - el("span", { className: "m02-cf__item-unit", text: item.resultUnit }), + el("span", { className: "m02-cf__item-name", text: name }), + el("span", { className: "m02-cf__item-unit", text: unit }), ], }); - row.addEventListener("click", () => showToast(te("NotYet"), "info")); + row.addEventListener("click", onClick); return row; }; - const showStart = (): void => { + 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", children: [muted(te("Loading"))] }); - void loadList().then((all) => { - const mine = all.filter((x) => !x.sample); - const samples = all.filter((x) => x.sample); - mineHost.replaceChildren( - ...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]), - ); - sampleHost.replaceChildren( - ...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]), - ); - }); + const sampleHost = el("div", { className: "m02-cf__list" }); body.replaceChildren( el("div", { className: "m02-cf__start", @@ -171,7 +261,9 @@ function openCostFlow(): void { el("section", { className: "m02-cf__start-col", children: [ - bigButton(te("Start_New"), () => void openNew()), + bigButton(te("Start_New"), () => + run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), + ), el("h3", { text: te("Start_Mine") }), mineHost, ], @@ -183,41 +275,254 @@ function openCostFlow(): void { ], }), ); + 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 openNew = async (): Promise => { - units ??= await unitChoices(); - const lg = await blankLogic("", units[0]); - showPaper(lg, units); - }; + 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 showPaper = (lg: CfLogic | null, choices: string[]): void => { - let dirty = false; - paper = { dirty: () => dirty }; const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = true; - const touched = (): void => { - dirty = true; - unsaved.hidden = false; + 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: touched, + 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: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: lg?.resultUnit ?? choices[0], - onChange: touched, + 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(), }); - const notYet = (): void => showToast(te("NotYet"), "info"); - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; strip.replaceChildren( name.root, unit.root, @@ -226,39 +531,33 @@ function openCostFlow(): void { className: "m02-cf__actions", children: [ undo, - createButton({ label: te("Save"), onClick: notYet }), - createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), + createButton({ label: te("Save"), onClick: () => void save() }), + deleteButton, ], }), ); body.replaceChildren( + note, el("div", { className: "m02-cf__layout", children: [ - part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]), + part(te("Diagram"), "m02-cf__diagram", [diagramHost]), el("div", { className: "m02-cf__lower", children: [ - part(te("Box"), "m02-cf__box", [ - el("div", { - className: "m02-cf__box-slots", - children: [ - slot(te("Panel_Input")), - slot(te("Panel_Function")), - slot(te("Panel_Output")), - ], - }), - ]), - part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]), + part(te("Box"), "m02-cf__box", [pickHint, colorRow, inHost, funcHost, outHost]), + part(te("Summary"), "m02-cf__summary", [summaryHost]), ], }), ], }), ); - name.input.focus(); + paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; + redraw(); + if (!lg.name) name.input.focus(); }; - showStart(); + void showStart(); document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 document.body.append(page); page.tabIndex = -1; @@ -273,14 +572,6 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl }); } -/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */ -function slot(title: string): HTMLElement { - return el("div", { - className: "m02-cf__slot", - children: [el("h4", { text: title }), muted(te("NotYet"))], - }); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m02-cf__big"); diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts index 4d0679995..7c3209560 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts @@ -53,6 +53,10 @@ const TEXT = { ], 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; @@ -193,6 +197,8 @@ export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { 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); diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts index 168a14005..bcaed88ba 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts @@ -31,6 +31,7 @@ import { elementOf, machineFields, queryOf, + reconcileUnit, removeElement, setKind, valueOf, @@ -143,6 +144,9 @@ export function renderInputPanel( 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" }); diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts index ce2f0799b..1e392059b 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts @@ -282,3 +282,13 @@ export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pi 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_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts index 949ad705a..d19f3f431 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts @@ -188,24 +188,29 @@ export function addBox( ); 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") + 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: { 값: tl("Result") } } }, + sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } }, inputs: [], variables: [], - output: { cell: "B1", name: tl("Result"), unit: "-" }, + 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 { - return { - ...lg, - boxes: lg.boxes.filter((b) => b.id !== id), - links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), - }; + // 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리 + 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 { @@ -267,7 +272,9 @@ export function reaches(lg: CfLogic, from: string, to: string): boolean { 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); @@ -275,21 +282,48 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: 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) || !portsIn(b).some((p) => p.name === to.name)) - return { lg, why: tl("NoPill") }; + 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 (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) + 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", - lg.links.map((l) => l.id), + next.links.map((l) => l.id), ); return { - lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] }, + 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 { - return { ...lg, links: lg.links.filter((l) => l.id !== id) }; + 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_Router.py b/M02_MasterTemplete/CostFlow/M02_CF_Router.py index cc7aa882f..a611eca45 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Router.py +++ b/M02_MasterTemplete/CostFlow/M02_CF_Router.py @@ -342,10 +342,70 @@ def _user(uid: str, local: list[dict]) -> dict: 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`.""" + """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`. + + 기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈. + """ group, ref = item.get("group"), item.get("ref") - table = item.get("table") or {} + 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 []) @@ -358,9 +418,11 @@ def one_value(item: dict, local: list[dict] | None = None) -> dict: elif group == "기계" and str(ref or "").startswith("로직("): inputs = item.get("inputs") or {} line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} - got = _money(store.calc("", inputs, _row(line, inputs), None), item.get("field")) + 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 ""), item.get("field")) + got = _plain_value(group, str(ref or ""), field) else: got = {"ok": False, "reason": f"모르는 그룹 「{group}」"} except store.StoreError as e: diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts index f0cfb9284..94164b86b 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts @@ -9,7 +9,7 @@ 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 } from "./M02_CF_Eval"; +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"; @@ -109,12 +109,13 @@ function sampleField( return el("label", { className: "m01-cfsum__field", children: [label, control] }); } -/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 */ +/** 원가 합계 칸 — 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("/"); @@ -187,7 +188,7 @@ export function renderSummary( /* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */ let checked: string[] = []; try { - checked = checkLogic(lg); + checked = checkLogic(lg, opts?.values ?? {}); } catch { checked = []; } diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/M02_MasterTemplete/CostFlow/_sample/masonry.json index a57860530..7ad4f5adb 100644 --- a/M02_MasterTemplete/CostFlow/_sample/masonry.json +++ b/M02_MasterTemplete/CostFlow/_sample/masonry.json @@ -73,7 +73,7 @@ "y": 460, "input": { "mode": "group", - "group": "계수", + "group": "소요량", "elements": [], "table": { "key": "QF000422" @@ -86,7 +86,7 @@ "kind": "input", "name": "인력", "color": "#2f80ed", - "unit": "-", + "unit": "원/일", "x": 40, "y": 620, "input": { diff --git a/resources/tester/test_m02_cf_inputpanel_build.py b/resources/tester/test_m02_cf_inputpanel_build.py index 890b0639d..79aa5cb05 100644 --- a/resources/tester/test_m02_cf_inputpanel_build.py +++ b/resources/tester/test_m02_cf_inputpanel_build.py @@ -219,3 +219,36 @@ def test_환율_유가전력_알약_이름에_날짜가_안_들어감(): "값": 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 index 8072c83af..818541127 100644 --- a/resources/tester/test_m02_cf_model.py +++ b/resources/tester/test_m02_cf_model.py @@ -272,3 +272,77 @@ def test_알약_이름_규칙() -> None: "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 index bbeeddc59..6b8e4bf83 100644 --- a/resources/tester/test_m02_cf_router.py +++ b/resources/tester/test_m02_cf_router.py @@ -305,3 +305,49 @@ def test_시스템_관리자가_아니면_403(folder: Path) -> None: 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 index ec59c4ed4..1312cd508 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -124,6 +124,9 @@ def test_견본_모양(name: str) -> None: 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 @@ -224,6 +227,17 @@ def _run(lg: dict, master) -> dict: 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.LogicBuilder import M01_LB_Calc as lb