diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index a74a98bb9..8bef8f3c8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,13 +1,13 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 가운데 단가산출 상세(호표) 고치기 / 오른쪽 시험 계산 + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도 * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * ========================================================================== */ -import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; +import { t as L } from "@ui/ui_template_locale"; import { createButton, el, @@ -37,12 +37,7 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail" import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal"; import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; -// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry"; -// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움 -import { createDataFlowButton } from "../M02_MasterTemplete/DataFlow/M02_DF_Entry"; +import { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -72,49 +67,6 @@ 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 { @@ -137,13 +89,13 @@ export async function mountM01LogicLab( ): Promise { let drafts = loadDrafts(); let opened: Opened | null = null; - let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음 let calcInputs = ""; let recalc: number | undefined; const editor = el("div", { className: "m01-logic__editor" }); + // 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억 + const split = buildSplit(editor); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("div", { className: "m01lab__calc" }); - const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } }); const filterHost = el("div"); const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 @@ -233,12 +185,11 @@ export async function mountM01LogicLab( const drawEditor = (): void => { // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 - const listing = !opened && !creating; + const listing = !opened; list.root.hidden = !listing || !loaded; waiting.hidden = loaded; - editor.hidden = !opened; + split.root.hidden = !opened; back.hidden = listing; - newHost.hidden = !creating; if (!opened) return; if (!opened.row) { editor.replaceChildren( @@ -264,7 +215,6 @@ export async function mountM01LogicLab( const show = (next: Opened | null): void => { opened = next; - creating = false; errors.hidden = true; drawEditor(); drawCalc(); @@ -326,7 +276,7 @@ export async function mountM01LogicLab( restore: true, onPick: (sub, detail) => { list.setPick(sub, detail); - if (opened || creating) show(null); + if (opened) show(null); }, }), ); @@ -410,65 +360,36 @@ export async function mountM01LogicLab( } }; - /** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */ - const startNew = (): void => { - opened = null; - creating = true; - newHost.replaceChildren( - buildNewLogic({ - onSaved: (key) => { - void (async () => { - try { - await reload(); - await openKeyOn(key); - } catch (error) { - showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); - } - })(); - }, - onClose: () => show(null), - }), - ); - drawEditor(); - drawCalc(); - }; - const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); const discardButton = createButton({ label: tx("Bar_Discard"), variant: "ghost", onClick: () => void onDiscard(), }); + const copyButton = createButton({ + label: tl("Copy_Open"), + variant: "ghost", + onClick: () => + opened?.origKey + ? void openCopyScreen({ + key: opened.origKey, + afterSave: async (newKey) => { + await reload(); + await open(logicId("자체", newKey), "자체", newKey); + }, + }) + : showToast(tl("Copy_NeedOpen"), "info"), + }); const bar = el("div", { className: "m01-logic__bar", children: [ back, el("h2", { text: tx("Title") }), pending, - createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), - trialMenu([ - // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createCostFlowButton(), - // 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createDataFlowButton(), - ]), + copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, - createButton({ - label: tl("Copy_Open"), - variant: "ghost", - onClick: () => - opened?.origKey - ? void openCopyScreen({ - key: opened.origKey, - afterSave: async (newKey) => { - await reload(); - await open(logicId("자체", newKey), "자체", newKey); - }, - }) - : showToast(tl("Copy_NeedOpen"), "info"), - }), ], }); host.replaceChildren( @@ -477,7 +398,7 @@ export async function mountM01LogicLab( children: [ el("main", { className: "m01-logic__main", - children: [bar, errors, waiting, list.root, editor, newHost], + children: [bar, errors, waiting, list.root, split.root], }), ], }), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 5842ec45e..2e599bb42 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -72,6 +72,9 @@ export interface DetailContext { onChange: () => void; /** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */ calcHost: HTMLElement; + /** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 · + * 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */ + flowHost?: HTMLElement; } type Group = "labor" | "material" | "cost" | "other"; @@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { : null; host.replaceChildren( infoBox(ctx), - hoBox(ctx), - section(tl("Formula_Title"), [formula]), + ...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]), section(tl("Calc_Title"), [ctx.calcHost]), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css new file mode 100644 index 000000000..64a4c5500 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.css @@ -0,0 +1,40 @@ +/* 로직 상세 두 칸 — 왼쪽 지금 내용 · 끌기 막대 · 오른쪽 흐름도 */ +.m01lab-flow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: start; + gap: 0; +} + +.m01lab-flow[hidden] { + display: none; +} + +.m01lab-flow__left, +.m01lab-flow__right { + min-width: 0; +} + +.m01lab-flow__bar { + align-self: stretch; + width: 8px; + margin: 0 var(--spacing-4); + border-radius: var(--radius-sm); + background: var(--color-border); + cursor: col-resize; + touch-action: none; +} + +.m01lab-flow__bar:hover, +.m01lab-flow__bar:focus-visible { + background: var(--color-primary); +} + +.m01lab-flow__wait { + margin: 0; + padding: var(--spacing-16); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + text-align: center; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts new file mode 100644 index 000000000..15c729006 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Flow.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Flow.ts + * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임) + * 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import "./M01_MasterData_UI_LogicLab_Flow.css"; + +const TEXT = { + Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"], + Waiting: ["흐름도 자리", "Flow diagram"], +} as const; + +export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const STORE = "m01.logic.flowRatio"; +const MIN = 0.25; +const MAX = 0.75; + +const clamp = (r: number): number => Math.min(MAX, Math.max(MIN, r)); + +function readRatio(): number { + try { + const r = Number(localStorage.getItem(STORE)); + return r ? clamp(r) : 0.5; + } catch { + return 0.5; + } +} + +export interface SplitHandle { + root: HTMLElement; + /** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */ + right: HTMLElement; +} + +/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */ +export function buildSplit(left: HTMLElement): SplitHandle { + const right = el("div", { + className: "m01lab-flow__right", + children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })], + }); + const bar = el("div", { + className: "m01lab-flow__bar", + attrs: { + role: "separator", + "aria-orientation": "vertical", + "aria-label": tf("Resize"), + title: tf("Resize"), + tabindex: "0", + }, + }); + const leftCol = el("div", { className: "m01lab-flow__left", children: [left] }); + const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] }); + let ratio = readRatio(); + const apply = (): void => { + root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`; + }; + const keep = (): void => { + try { + localStorage.setItem(STORE, String(ratio)); + } catch { + /* 기억 못 해도 지금 화면은 그대로 */ + } + }; + bar.addEventListener("pointerdown", (ev) => { + if (ev.button !== 0) return; + ev.preventDefault(); + const box = root.getBoundingClientRect(); + const move = (m: PointerEvent): void => { + ratio = clamp((m.clientX - box.left) / box.width); + apply(); + }; + const up = (): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + keep(); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + // 키보드 — 왼쪽 · 오른쪽 화살표로 조금씩 + bar.addEventListener("keydown", (ev) => { + const step = ev.key === "ArrowLeft" ? -0.05 : ev.key === "ArrowRight" ? 0.05 : 0; + if (!step) return; + ev.preventDefault(); + ratio = clamp(ratio + step); + apply(); + keep(); + }); + apply(); + return { root, right }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts new file mode 100644 index 000000000..a9652b42b --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowRows.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FlowRows.ts + * 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어 + * 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함. + * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py + * ========================================================================== */ + +import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; +import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; + +export type FlowGroupKey = "labor" | "material" | "cost" | "plain"; + +/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */ +export const GROUP_OF: Record = { + 노무비: "labor", + 재료비: "material", + 경비: "cost", + 값: "plain", +}; +export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"]; +const COST_OF: Record, "노무비" | "재료비" | "경비"> = { + labor: "노무비", + material: "재료비", + cost: "경비", +}; + +export interface FlowLine { + box: string; + name: string; + unit: string; + value: number | string | boolean | null; + /** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */ + text: string; +} + +export interface FlowGroup { + key: FlowGroupKey; + lines: FlowLine[]; + /** 비용 묶음만 — 합계 끝수를 적용한 소계 */ + sub: number | null; + subText: string; +} + +export interface FlowFunc { + box: string; + name: string; + unit: string; + value: unknown; + text: string; +} + +export interface FlowRows { + groups: FlowGroup[]; + /** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */ + total: number; + totalText: string; + funcs: FlowFunc[]; + errors: string[]; +} + +const plain = (v: unknown): string => + v === null || v === undefined || Array.isArray(v) + ? "—" + : typeof v === "number" + ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) + : String(v); + +/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */ +export function flowRows( + lg: CfLogic, + ev: CfEvalResult, + round: { item: CfRound; total: CfRound }, +): FlowRows { + const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); + const groups = GROUP_ORDER.map((key): FlowGroup => { + const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key); + const lines = mine.map((b): FlowLine => { + const v = ev.outputs[b.id] ?? null; + const r = b.output?.round ?? (key === "plain" ? undefined : round.item); + return { + box: b.id, + name: b.name, + unit: b.output?.unit ?? b.unit, + value: v, + text: typeof v === "number" ? showRounded(v, r) : plain(v), + }; + }); + const raw = key === "plain" ? null : ev.cost[COST_OF[key]]; + const sub = raw === null ? null : roundBy(round.total, raw); + return { + key, + lines, + sub, + subText: sub === null ? "" : showRounded(sub, round.total), + }; + }).filter((g) => g.lines.length); + const total = roundBy(round.total, ev.cost.계); + const funcs = lg.boxes + .filter((b) => b.kind === "function" && b.func) + .map((b): FlowFunc => { + const out = b.func!.output; + const v = ev.ports[b.id]?.[out.name]; + return { + box: b.id, + name: b.name, + unit: out.unit || b.unit, + value: v, + text: + typeof v === "number" + ? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }) + : plain(v), + }; + }); + return { + groups, + total, + totalText: showRounded(total, round.total), + funcs, + errors: ev.errors, + }; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css new file mode 100644 index 000000000..4a4f282e8 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.css @@ -0,0 +1,30 @@ +.m01flow__group { + margin: 0 0 var(--spacing-12); +} + +.m01flow__block { + margin: 0 0 var(--spacing-8); +} + +.m01flow__func { + margin: 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; +} + +.m01flow__branch { + margin: 0 0 0 var(--spacing-16); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01flow__branch--now { + color: var(--color-text-body); + font-weight: 600; +} + +.m01flow__warn { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts new file mode 100644 index 000000000..87b9d9de2 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_FlowView.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_FlowView.ts + * 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다 + * 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠. + * 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름. + * 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름. + * 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { + evalLogic, + type CfEvalOptions, + type CfEvalResult, + type CfValues, +} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; +import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; +import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round"; +import { + flowRows, + type FlowFunc, + type FlowGroup, + type FlowGroupKey, +} from "./M01_MasterData_UI_LogicLab_FlowRows"; +import "./M01_MasterData_UI_LogicLab_FlowView.css"; + +const TEXT = { + Detail: ["단가산출 상세", "Unit-price detail"], + Formula: ["텍스트 수식", "Text formulas"], + Labor: ["인력 · 노무비", "Labor · labor cost"], + Material: ["재료 · 재료비", "Material · material cost"], + Cost: ["경비", "Expense"], + Plain: ["값(비용 아님)", "Values (not costs)"], + HName: ["이름", "Name"], + HUnit: ["단위", "Unit"], + HValue: ["값", "Value"], + Sub: ["소계", "Subtotal"], + Total: ["계", "Total"], + TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"], + None: ["출력 상자가 없음", "No output box"], + NoFunc: ["함수 상자가 없음", "No function box"], +} as const; + +type TextKey = keyof typeof TEXT; +const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; +const GROUP_TITLE: Record = { + labor: "Labor", + material: "Material", + cost: "Cost", + plain: "Plain", +}; + +/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */ +interface FormulaLine { + name: string; + named: string; + valued: string; + branches?: { when: string; then: string; now: boolean }[]; +} +type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[]; +const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>( + "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts", +); +const loadFormula = async (): Promise => { + const load = Object.values(FORMULA)[0]; + return load ? ((await load()).formulaLines ?? null) : null; +}; + +const section = (title: string, body: HTMLElement[]): HTMLElement => + el("section", { + className: "m01-logic__section m01lab__box", + children: [ + el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }), + ...body, + ], + }); + +const groupTable = (g: FlowGroup): HTMLElement => { + const sub = + g.sub === null + ? [] + : [ + el("tr", { + className: "m01lab__sub", + children: [ + el("td", { text: tf("Sub"), attrs: { colspan: "2" } }), + el("td", { className: "m01-logic__money", text: g.subText }), + ], + }), + ]; + return el("div", { + className: "m01flow__group", + children: [ + el("h4", { text: tf(GROUP_TITLE[g.key]) }), + el("div", { + className: "m01-logic__scroll", + children: [ + el("table", { + className: "m01-logic__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) => + el("th", { text: h }), + ), + }), + ], + }), + el("tbody", { + children: [ + ...g.lines.map((l) => + el("tr", { + children: [ + el("td", { text: l.name }), + el("td", { text: l.unit }), + el("td", { className: "m01-logic__money", text: l.text }), + ], + }), + ), + ...sub, + ], + }), + ], + }), + ], + }), + ], + }); +}; + +/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */ +function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement { + const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : ""; + const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` }); + if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] }); + return el("div", { + className: "m01flow__block", + children: [ + head, + ...lines.flatMap((l) => [ + el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }), + el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }), + ...(l.branches ?? []).map((b) => + el("p", { + className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`, + text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`, + }), + ), + ]), + ], + }); +} + +/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */ +export function buildFlowView( + host: HTMLElement, + lg: CfLogic, + values: CfValues, + opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {}, +): void { + let ev: CfEvalResult; + try { + ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics }); + } catch (e) { + host.replaceChildren( + section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]), + ); + return; + } + const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() }); + const detail = section(tf("Detail"), [ + ...(rows.groups.length + ? rows.groups.map(groupTable) + : [el("p", { className: "m01-logic__muted", text: tf("None") })]), + el("p", { + className: "m01lab__total", + attrs: { "data-sum": String(rows.total) }, + text: `${tf("Total")} ${rows.totalText}`, + }), + el("p", { className: "m01-logic__muted", text: tf("TotalNote") }), + ...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })), + ]); + const bodies = rows.funcs.map((f) => funcBlock(f, null)); + const text = section( + tf("Formula"), + bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })], + ); + host.replaceChildren(detail, text); + // sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함 + void loadFormula().then((fn) => { + if (!fn || !bodies.length) return; + const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func); + const merged = { ...ev.ports, ...ev.arrived } as CfValues; + text.replaceChildren( + text.firstElementChild!, + ...rows.funcs.map((f, i) => { + try { + return funcBlock(f, fn(boxes[i], merged)); + } catch { + return bodies[i]; + } + }), + ); + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css b/M01_MasterData/M01_MasterData_UI_LogicLab_New.css deleted file mode 100644 index bd23745e2..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New.css +++ /dev/null @@ -1,105 +0,0 @@ -/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */ -.m01lab-new { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - min-width: 0; -} - -.m01lab-new__step { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - -.m01lab-new__text { - width: 100%; - min-height: 140px; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-bg); - color: var(--color-text); - font-family: ui-monospace, Consolas, monospace; - font-size: var(--text-body); - resize: vertical; -} - -.m01lab-new__row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.m01lab-new__vars { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.m01lab-new__var { - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); -} - -.m01lab-new__var-head { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-8); -} - -.m01lab-new__var-body { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--spacing-8); - margin-top: var(--spacing-8); -} - -.m01lab-new__wide { - flex: 1 1 220px; -} - -.m01lab-new__picked { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - min-width: 0; -} - -.m01lab-new__src { - display: flex; - gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-lg); - background: var(--color-paper); - overflow-x: auto; -} - -.m01lab-new__no { - flex: 0 0 auto; - color: var(--color-text-secondary); -} - -.m01lab-new__src mark { - background: var(--color-danger); - color: var(--color-bg); - border-radius: 2px; -} - -.m01lab-new__bad { - color: var(--color-danger); -} - -.m01lab-new__soft { - color: var(--color-text-secondary); -} - -.m01lab-new__calc { - min-width: 0; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts deleted file mode 100644 index 7cc035b22..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New.ts - * 「단가산출 로직」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장) - * ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름 - * ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX) - * 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`) - * ========================================================================== */ - -import { - createButton, - createInputField, - el, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { - ApiError, - draftLogic, - type DraftAnswer, - type DraftDecided, - type DraftHead, - type DraftProblem, - type DraftVar, -} from "./M01_MasterData_UI_Logic_Api"; -import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; -import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var"; -import "./M01_MasterData_UI_LogicLab_New.css"; - -/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */ -const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요 -넓이 = 길이 * 폭 -인부 = 넓이 * 품 -잡재료비 = 노무비 * 0.05 -공사비 = 인부 * 보통인부 + 잡재료비 -`; - -export interface NewOptions { - /** 저장 끝 — 새 자체 로직의 키 */ - onSaved: (key: string) => void; - onClose: () => void; -} - -export function buildNewLogic(opts: NewOptions): HTMLElement { - const decided: Record = {}; - const rows = new Map(); - const values: Record = {}; - let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림 - let answer: DraftAnswer | null = null; - let calcSig = ""; - let seq = 0; - - const text = el("textarea", { - className: "m01lab-new__text", - attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") }, - }); - text.value = SAMPLE; - const stale = el("p", { - className: "m01-logic__muted", - text: tn("Stale"), - attrs: { hidden: "" }, - }); - const varsHost = el("div", { className: "m01lab-new__vars" }); - const problemHost = el("div", { className: "m01lab-new__problems" }); - const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" }); - const nameBox = createInputField({ - type: "text", - label: tn("Name"), - onInput: () => onEdit(), - }); - nameBox.input.setAttribute("data-key", "name"); - const unitBox = createInputField({ - type: "text", - label: tn("ResultUnit"), - placeholder: "원/㎡", - onInput: () => onEdit(), - }); - unitBox.input.setAttribute("data-key", "unit"); - const ownerBox = createInputField({ type: "text", label: tn("Owner") }); - const state = el("p", { className: "m01-logic__muted" }); - const save = createButton({ label: tn("Save"), onClick: () => void doSave() }); - save.setAttribute("data-act", "save"); - save.disabled = true; - - const head = (): DraftHead | undefined => { - const 이름 = nameBox.input.value.trim(); - const 결과단위 = unitBox.input.value.trim(); - return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined; - }; - - /** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */ - const drawProblems = (problems: DraftProblem[]): void => { - if (!analyzed) return problemHost.replaceChildren(); - const lines = analyzed.split("\n"); - const flagged = new Map(); - for (const p of problems) { - if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]); - } - const shown = [...flagged.keys()] - .sort((a, b) => a - b) - .filter((n) => n <= lines.length) - .map((n) => { - const raw = lines[n - 1]; - const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1; - return el("div", { - className: "m01lab-new__src", - children: [ - el("span", { className: "m01lab-new__no", text: String(n) }), - at >= 0 - ? el("code", { - children: [ - raw.slice(0, at), - el("mark", { text: raw.slice(at, at + 1) }), - raw.slice(at + 1), - ], - }) - : el("code", { text: raw }), - ], - }); - }); - const items = problems.map((p) => - el("li", { - className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad", - attrs: { "data-kind": p.갈래 }, - text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`, - }), - ); - problemHost.replaceChildren( - el("h4", { text: tn("Problems") }), - ...(items.length - ? [...shown, el("ul", { children: items })] - : [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]), - ); - }; - - const drawCalc = (): void => { - const row = answer?.logic ?? null; - save.disabled = !row || (answer?.문제.length ?? 1) > 0; - state.textContent = !answer - ? "" - : answer.문제.some((p) => p.갈래 !== "덜정함") - ? tn("SaveBlocked") - : !head() - ? tn("NeedHead") - : answer.다됨 - ? "" - : tn("Undecided"); - if (!row) { - calcSig = ""; - calcHost.replaceChildren(); - return; - } - const sig = JSON.stringify(row.입력 ?? []); - if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림 - calcSig = sig; - buildCalc(calcHost, { - savedKey: null, - row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄 - dirty: () => true, - values, - onLines: () => undefined, - }); - }; - - const drawVars = (vars: DraftVar[]): void => { - rows.clear(); - const shown = vars.filter((v) => v.갈래 !== "비목합"); - for (const name of Object.keys(decided)) { - if (!shown.some((v) => v.이름 === name)) delete decided[name]; - } - const result = answer?.결과 ?? ""; - const order = [ - ...shown.filter((v) => v.갈래 === "미정"), - ...shown.filter((v) => v.갈래 !== "미정"), - ]; - for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit })); - varsHost.replaceChildren(...[...rows.values()].map((r) => r.root)); - }; - - const evaluate = async (rebuild: boolean): Promise => { - if (!analyzed) return; - const mine = ++seq; - try { - const got = await draftLogic({ text: analyzed, decided, head: head() }); - if (mine !== seq) return; // 최신 응답만 - answer = got; - if (rebuild) drawVars(got.변수); - for (const v of got.변수) rows.get(v.이름)?.update(v); - drawProblems(got.문제); - drawCalc(); - } catch (error) { - showToast(error instanceof Error ? error.message : tn("Failed"), "error"); - } - }; - let timer = 0; - function onEdit(): void { - window.clearTimeout(timer); - timer = window.setTimeout(() => void evaluate(false), 250); - } - - const analyzeButton = createButton({ - label: tn("Analyze"), - onClick: () => { - analyzed = text.value; - stale.hidden = true; - void evaluate(true); - }, - }); - analyzeButton.setAttribute("data-act", "analyze"); - text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed)); - - async function doSave(): Promise { - const h = head(); - if (!h) return void showToast(tn("NeedHead"), "info"); - save.disabled = true; - showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림 - try { - const owner = ownerBox.input.value.trim(); - const done = await draftLogic({ - text: analyzed, - decided, - head: h, - save: true, - ...(owner ? { owner } : {}), - }); - showToast(tn("Saved", { v: done.key ?? "" }), "success"); - if (done.key) opts.onSaved(done.key); - } catch (error) { - const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null; - if (detail?.문제) drawProblems(detail.문제); - else showToast(error instanceof Error ? error.message : tn("Failed"), "error"); - save.disabled = false; - } finally { - hideLoadingOverlay(); - } - } - - const section = (title: string, ...children: HTMLElement[]): HTMLElement => - el("section", { - className: "m01lab__box m01lab-new__step", - children: [el("h3", { text: title }), ...children], - }); - - return el("div", { - className: "m01lab-new", - children: [ - el("div", { - className: "m01-logic__section-head", - children: [ - el("h2", { text: tn("Title") }), - createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }), - ], - }), - section( - tn("Step1"), - el("p", { className: "m01-logic__muted", text: tn("Guide") }), - text, - el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }), - ), - section(tn("Step2"), varsHost, problemHost), - section( - tn("Step3"), - el("div", { - className: "m01lab-new__row", - children: [nameBox.root, unitBox.root, ownerBox.root], - }), - ), - section(tn("Step4"), calcHost, state, save), - ], - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts deleted file mode 100644 index 08761a627..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Find.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New_Find.ts - * 새 로직 만들기의 고르기 창 둘 - * ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기 - * ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음 - * ========================================================================== */ - -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { - fetchFiles, - fetchRows, - fetchSubsAll, - type FileInfo, - type SubInfo, -} from "./M01_MasterData_Api_Fetch"; -import type { DraftFind } from "./M01_MasterData_UI_Logic_Api"; -import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { loadTable } from "./M01_MasterData_UI_Logic_Note"; -import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; - -export interface PickedElement { - ref: string; - /** 이름 + 규격 */ - name: string; - /** 수량에 쓰는 단위(「원/일」 → 「일」) */ - unit: string; - group: string; -} - -const GROUPS = ["인력", "재료", "기계"]; -const SHOWN = 50; - -/** 창 뼈대 — 제목 · 닫기 · 몸통 */ -function shell(title: string, body: HTMLElement[]): { close: () => void } { - const close = (): void => backdrop.remove(); - const dialog = el("div", { - className: "m01-logic__pick", - attrs: { role: "dialog", "aria-label": title }, - children: [ - el("div", { - className: "m01-logic__section-head", - children: [ - el("h3", { text: title }), - createButton({ label: tn("Close"), variant: "ghost", onClick: close }), - ], - }), - ...body, - ], - }); - const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); - backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); - backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); - document.body.append(backdrop); - return { close }; -} - -/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */ -async function fileOf(group: string): Promise { - const files = await fetchFiles(group); - return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0]; -} - -const options = (values: string[]): { value: string; text: string }[] => [ - { value: "", text: tn("All") }, - ...values.map((v) => ({ value: v, text: v })), -]; - -export function openElementPick(done: (picked: PickedElement) => void): void { - let file: FileInfo | undefined; - let subs: SubInfo[] = []; - const groupBox = createSelectField({ - options: GROUPS.map((g) => ({ value: g, text: g })), - value: GROUPS[0], - compact: true, - label: tn("Group"), - }); - const subBox = createSelectField({ - options: options([]), - value: "", - compact: true, - label: tn("Sub"), - }); - const detailBox = createSelectField({ - options: options([]), - value: "", - compact: true, - label: tn("Detail"), - }); - const searchField = createInputField({ type: "search", placeholder: tn("Search") }); - const count = el("span", { className: "m01-logic__muted" }); - const list = el("div", { className: "m01-logic__pick-list" }); - const win = shell(tn("K_master"), [ - el("div", { - className: "m01-logic__pick-bar", - children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count], - }), - list, - ]); - - let seq = 0; - const load = async (): Promise => { - if (!file) return; - const mine = ++seq; - try { - const got = await fetchRows( - file.file, - 1, - SHOWN, - searchField.input.value.trim(), - false, - subBox.select.value, - detailBox.select.value, - ); - if (mine !== seq) return; - count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) }); - list.replaceChildren( - ...got.rows.map((row) => { - const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim(); - const unitText = String(row["단위"] ?? ""); - const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? ""); - const button = el("button", { - className: "m01-logic__pick-row m01-logic__pick-row--button", - attrs: { type: "button", "data-ref": String(row["키"]) }, - children: [ - el("div", { - className: "m01-logic__stack", - children: [ - el("strong", { text: name }), - el("span", { className: "m01-logic__muted", text: String(row["키"]) }), - ], - }), - el("span", { - className: "m01-logic__money", - text: `${formatNumber(row["값"])} ${unitText}`.trim(), - }), - ], - }); - button.addEventListener("click", () => { - win.close(); - done({ ref: String(row["키"]), name, unit, group: groupBox.select.value }); - }); - return button; - }), - ); - } catch (error) { - showToast(error instanceof Error ? error.message : tn("Failed"), "error"); - } - }; - - const changeGroup = async (): Promise => { - file = await fileOf(groupBox.select.value); - subs = file ? (await fetchSubsAll({ file: file.file })).subs : []; - subBox.setOptions(options(subs.map((s) => s.name)), ""); - detailBox.setOptions(options([]), ""); - detailBox.select.disabled = true; - await load(); - }; - groupBox.select.addEventListener("change", () => void changeGroup()); - subBox.select.addEventListener("change", () => { - const details = subs.find((s) => s.name === subBox.select.value)?.details ?? []; - detailBox.setOptions(options(details), ""); - detailBox.select.disabled = details.length === 0; - void load(); - }); - detailBox.select.addEventListener("change", () => void load()); - let timer = 0; - searchField.input.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void load(), 250); - }); - void changeGroup(); -} - -/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */ -export function openTablePick(done: (find: DraftFind, label: string) => void): void { - openPicker("table", "소요량", (item, column) => { - void (async () => { - const table = await loadTable(item.ref); - const conds = Object.keys(table?.조건 ?? {}); - const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`; - const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record }; - if (!conds.length) { - done(find, label); - return; - } - const inputs = conds.map((c) => { - const box = createInputField({ type: "text", value: c }); - box.input.setAttribute("data-cond", c); - return { c, box }; - }); - const win = shell(tn("TableTitle"), [ - el("p", { className: "m01-logic__muted", text: label }), - el("p", { text: tn("Cond") }), - ...inputs.map(({ c, box }) => - el("label", { - className: "m01-logic__field", - children: [el("span", { text: c }), box.root], - }), - ), - createButton({ - label: tn("Ok"), - onClick: () => { - for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c; - win.close(); - done(find, label); - }, - }), - ]); - })(); - }); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts deleted file mode 100644 index 272d234fb..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Text.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New_Text.ts - * 「식으로 새 로직 만들기」 글자 — [한국어, 영어] - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; - -const TEXT = { - Open: ["식으로 새로 만들기", "New from formula"], - Title: ["식으로 새 로직 만들기", "New logic from a formula"], - Close: ["닫기", "Close"], - Step1: ["① 식 적기", "① Write the formula"], - Guide: [ - "한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀", - "One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped", - ], - Analyze: ["분석", "Analyze"], - Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"], - Step2: ["② 변수 정하기", "② Decide each variable"], - Step3: ["③ 이름·단위", "③ Name and unit"], - Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"], - Problems: ["고칠 곳", "To fix"], - Line: ["{n}번 줄", "Line {n}"], - AllGood: ["검사에 걸린 것 없음", "No problems found"], - Result: ["결과", "Result"], - Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"], - Kind: ["무엇인가", "What is it"], - K_master: ["마스터 요소", "Master element"], - K_input: ["설계 입력", "Design input"], - K_fixed: ["고정값", "Fixed value"], - K_table: ["표 찾기", "Table lookup"], - Pick: ["고르기", "Pick"], - Repick: ["다시 고르기", "Pick again"], - NotPicked: ["아직 안 골랐음", "Not picked"], - Unit: ["단위", "Unit"], - Cost: ["비목", "Cost item"], - CostNone: ["넣지 않음", "None"], - CostExtra: ["덧줄 비목", "Extra-line cost item"], - Value: ["값", "Value"], - Note: ["설명", "Note"], - Derived: ["식으로 정해짐", "Set by the formula"], - SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"], - Name: ["로직 이름", "Logic name"], - ResultUnit: ["결과 단위", "Result unit"], - Owner: ["소유", "Owner"], - Save: ["저장", "Save"], - Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"], - SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"], - NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"], - Group: ["종류", "Kind"], - Sub: ["구분", "Group"], - Detail: ["상세구분", "Detail"], - All: ["(전체)", "(All)"], - Search: ["이름 찾기", "Search name"], - Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"], - TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"], - TablePick: ["표 고르기", "Pick a table"], - TableCol: ["값 칸", "Value column"], - Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"], - Ok: ["확인", "OK"], - Cancel: ["취소", "Cancel"], - Failed: ["못 함", "Failed"], -} as const satisfies Record; - -export type NewTextKey = keyof typeof TEXT; - -export function tn(key: NewTextKey, fill: Record = {}): string { - const entry = TEXT[key]; - const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0]; - return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts deleted file mode 100644 index 7cfb70d18..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_New_Var.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_New_Var.ts - * 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름 - * 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로) - * ========================================================================== */ - -import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api"; -import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find"; -import { tn } from "./M01_MasterData_UI_LogicLab_New_Text"; - -export interface VarCtx { - decided: Record; - /** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */ - onEdit: () => void; -} -export interface VarRow { - root: HTMLElement; - /** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */ - update: (v: DraftVar) => void; -} - -const KINDS: [string, () => string][] = [ - ["마스터요소", () => tn("K_master")], - ["설계입력", () => tn("K_input")], - ["고정값", () => tn("K_fixed")], - ["표찾기", () => tn("K_table")], -]; -const COSTS = ["노무비", "재료비", "경비"]; -/** 요소 종류 → 비목 첫 제안 */ -const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비" }; - -const costOptions = (): { value: string; text: string }[] => [ - { value: "", text: tn("CostNone") }, - ...COSTS.map((c) => ({ value: c, text: c })), -]; - -const field = (label: string, control: HTMLElement): HTMLElement => - el("label", { - className: "m01-logic__field", - children: [el("span", { text: label }), control], - }); - -export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow { - const name = v.이름; - const info = el("span", { className: "m01-logic__muted m01lab-new__derived" }); - const body = el("div", { className: "m01lab-new__var-body" }); - const root = el("div", { - className: "m01lab-new__var", - attrs: { "data-var": name }, - children: [ - el("div", { - className: "m01lab-new__var-head", - children: [el("strong", { text: name }), info], - }), - body, - ], - }); - const now = (): DraftDecided => (ctx.decided[name] ??= {}); - const set = (patch: DraftDecided): void => { - ctx.decided[name] = { ...now(), ...patch }; - for (const [k, val] of Object.entries(ctx.decided[name])) { - if (val === "" || val === undefined) delete (ctx.decided[name] as Record)[k]; - } - ctx.onEdit(); - }; - const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => { - const box = createInputField({ - type: "text", - value: now()[key] ?? "", - onInput: (val) => set({ [key]: val.trim() }), - }); - box.input.setAttribute("data-key", key); - const cell = field(label, box.root); - if (wide) cell.classList.add("m01lab-new__wide"); - return cell; - }; - - let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글 - const draw = (): void => { - const kind = now().무엇 ?? ""; - if (v.갈래 === "비목합") { - body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") })); - return; - } - if (v.갈래 === "정의됨") { - if (isResult) return body.replaceChildren(); - const cost = createSelectField({ - options: costOptions(), - value: now().비목 ?? "", - compact: true, - label: tn("CostExtra"), - onChange: (val) => set({ 비목: val }), - }); - body.replaceChildren(cost.root); - return; - } - const kindBox = createSelectField({ - options: [ - { value: "", text: "—" }, - ...KINDS.map(([value, label]) => ({ value, text: label() })), - ], - value: kind, - compact: true, - label: tn("Kind"), - onChange: (val) => { - ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {}; - picked = ""; - draw(); - ctx.onEdit(); - }, - }); - const parts: HTMLElement[] = [kindBox.root]; - if (kind === "마스터요소") { - const cost = createSelectField({ - options: costOptions(), - value: now().비목 ?? "", - compact: true, - label: tn("Cost"), - onChange: (val) => set({ 비목: val }), - }); - const choose = createButton({ - label: now().요소 ? tn("Repick") : tn("Pick"), - variant: "ghost", - onClick: () => - openElementPick((p) => { - picked = `${p.name} · ${p.ref}`; - set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 }); - draw(); - }), - }); - choose.setAttribute("data-act", "pick-element"); - parts.push( - el("div", { - className: "m01lab-new__picked", - children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })], - }), - text(tn("Unit"), "단위"), - cost.root, - ); - } else if (kind === "설계입력") { - parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true)); - } else if (kind === "고정값") { - parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위")); - } else if (kind === "표찾기") { - const choose = createButton({ - label: now().찾기 ? tn("Repick") : tn("TablePick"), - variant: "ghost", - onClick: () => - openTablePick((find, label) => { - picked = label; - set({ 찾기: find }); - draw(); - }), - }); - choose.setAttribute("data-act", "pick-table"); - parts.push( - el("div", { - className: "m01lab-new__picked", - children: [choose, el("span", { text: picked || tn("NotPicked") })], - }), - text(tn("Unit"), "단위"), - ); - } - body.replaceChildren(...parts); - }; - draw(); - - const update = (next: DraftVar): void => { - v = next; - info.textContent = - next.갈래 === "정의됨" - ? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}` - : (next.단위 ?? ""); - }; - update(v); - return { root, update }; -} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 29a081d9a..283d7a063 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -81,7 +81,7 @@ const TEXT = { "지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음", "No row matches the current values — change them, or this table is on an unused branch", ], - Copy_Open: ["본떠 만들기", "Copy as mine"], + Copy_Open: ["로직 복사/생성", "Copy / create logic"], Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"], Copy_Title: ["복사해서 만들기", "Copy to edit"], Copy_Name: ["새 이름", "New name"], diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index 35596a0c9..3e7775ebd 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -206,6 +206,25 @@ export async function cfLogicValue( return send("/logic-value", { key, designs }); } +/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true · + * notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */ +export async function cfLogicFlow( + file: string, + key: string, +): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, { + credentials: "include", + }); + } catch { + throw new CfError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown }; + if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText); + return data as { flow: CfLogic; converted: boolean; notes: string[] }; +} + /** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */ export async function cfDesignInputs(key: string): Promise { const qs = new URLSearchParams({ key }).toString(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 9dd41038a..e74e30fa7 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -23,6 +23,9 @@ import { restParts, linkLabel, midOf, + pillInfo, + svgAdd as add, + svgLabel as label, pillsOf, resolveDrop, showValue, @@ -46,6 +49,7 @@ interface HostState { panX: number; panY: number; fitted: boolean; + fitKey?: number; watch?: ResizeObserver; } @@ -79,6 +83,9 @@ export function renderDiagram( fitted: false, }; if (opts.selected !== undefined) st.selected = opts.selected; + if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey) + [st.fitKey, st.fitted] = [opts.fitKey, false]; + const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨) if (opts.selectedPill !== undefined) st.pill = opts.selectedPill; states.set(host, st); const values = opts.values ?? {}; @@ -243,7 +250,7 @@ export function renderDiagram( className: "m02-dfdg__ins", children: [ ...ins.map((p) => pillEl(b, p, "in")), - ...(b.kind === "function" ? [plusSlot(b)] : []), + ...(b.kind === "function" && !ro ? [plusSlot(b)] : []), ], }), el("div", { @@ -282,13 +289,13 @@ export function renderDiagram( ArrowUp: [0, -step], ArrowDown: [0, step], }[ev.key]; - if (d && st.selected === b.id) { + if (d && st.selected === b.id && !ro) { ev.preventDefault(); on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1])); } }); head.addEventListener("pointerdown", (ev) => { - if (ev.button === 0 && !spaceDown) dragBox(ev, b, node); + if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node); }); return node; }; @@ -350,15 +357,8 @@ export function renderDiagram( ); } else if (st.pill) { const p = st.pill; - const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; - 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.port]) || "—"}`; - const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""; - tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") })); - if (p.dir === "out") + tools.push(el("span", { text: pillInfo(lg, p, values) })); + if (p.dir === "out" && !ro) tools.push( sm( "Connect", @@ -371,9 +371,9 @@ export function renderDiagram( const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) }); tools.push( el("span", { text: rule ? `${line} · ${rule}` : line }), - sm("Disconnect", () => on.unlink(selLink.id), "danger"), + ...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]), ); - } else if (selBox) { + } else if (selBox && !ro) { tools.push(sm("Remove", () => void askRemove(selBox), "danger")); } const legend = @@ -388,18 +388,26 @@ export function renderDiagram( className: "m02-dfdg__bar", children: [ el("strong", { className: "m02-dfdg__title", text: tt("Title") }), - addBtn("input", "AddInput"), - addBtn("function", "AddFunc"), - addBtn("output", "AddOutput"), + ...(ro + ? [] + : [ + addBtn("input", "AddInput"), + addBtn("function", "AddFunc"), + addBtn("output", "AddOutput"), + ]), el("span", { className: "m02-dfdg__gap" }), ...legend, 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); - }), + ...(ro + ? [] + : [ + sm("Arrange", () => + Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)), + ), + ]), ], }); const root = el("div", { @@ -426,7 +434,7 @@ export function renderDiagram( st.pill = null; redraw(); } - if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; + if (ro || ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return; if (selLink) on.unlink(selLink.id); else if (selBox) void askRemove(selBox); }); @@ -477,32 +485,6 @@ export function renderDiagram( const right = (dir === "out") === (side === "near"); return toWorld(right ? r.right : r.left, r.top + r.height / 2); }; - const add = ( - parent: Element, - tag: K, - attrs: Record, - ) => { - const n = document.createElementNS(SVG, tag); - for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); - parent.append(n); - return n; - }; - const label = (parent: Element, x: number, y: number, text: string, cls: string): number => { - const tag = add(parent, "g", { class: cls }); - const box = add(tag, "rect", { rx: "4", ry: "4" }); - const t = add(tag, "text", { - x: x.toFixed(1), - y: y.toFixed(1), - "dominant-baseline": "central", - "text-anchor": "middle", - }); - t.textContent = text; - const w = (t.getComputedTextLength?.() || text.length * 7) + 10; - Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => - box.setAttribute(k, v.toFixed(1)), - ); - return w; - }; const defs = add(svg, "defs", {}); const arrow = add(defs, "marker", { id: "m02-dfdg-arrow", @@ -657,7 +639,7 @@ export function renderDiagram( } function dragLink(ev: PointerEvent, from: CfPort): void { - if (ev.button !== 0 || spaceDown) return; + if (ev.button !== 0 || spaceDown || ro) return; ev.stopPropagation(); ev.preventDefault(); const pill = ev.currentTarget as HTMLElement; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts index cf0616712..649aac97e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram_Parts.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_DF_Diagram_Parts.ts * 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 · - * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`. + * 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -118,6 +118,10 @@ export interface DiagramOpts { trace?: { back?: CfStep[]; forward?: CfStep[] } | null; /** 끊긴 곳 — Eval breaks */ breaks?: CfBreak[]; + /** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */ + readOnly?: boolean; + /** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */ + fitKey?: number; } /* ── 알약 · 자리 ────────────────────────────────────────────────────── */ @@ -330,6 +334,25 @@ export function restParts( return { links, pills, boxes }; } +/** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */ +export function pillInfo( + lg: CfLogic, + p: DfPillSel, + values: Record>, +): string { + const name = (e: CfPort): string => + `${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`; + const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port; + const where = lg.links + .filter((l) => mine(p.dir === "in" ? l.to : l.from)) + .map((l) => name(p.dir === "in" ? l.from : l.to)) + .join(", "); + const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`; + return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""] + .filter(Boolean) + .join(" · "); +} + /* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */ /** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */ @@ -360,3 +383,37 @@ export function follow( window.addEventListener("pointermove", mv); window.addEventListener("pointerup", up); } + +/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** SVG 마디 하나 붙이기 */ +export function svgAdd( + parent: Element, + tag: K, + attrs: Record, +): SVGElementTagNameMap[K] { + const n = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); + parent.append(n); + return n; +} + +/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */ +export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number { + const tag = svgAdd(parent, "g", { class: cls }); + const box = svgAdd(tag, "rect", { rx: "4", ry: "4" }); + const t = svgAdd(tag, "text", { + x: x.toFixed(1), + y: y.toFixed(1), + "dominant-baseline": "central", + "text-anchor": "middle", + }); + t.textContent = text; + const w = (t.getComputedTextLength?.() || text.length * 7) + 10; + Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) => + box.setAttribute(k, v.toFixed(1)), + ); + return w; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css index 05228f379..9058ccdb2 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css @@ -70,7 +70,7 @@ /* 시작 화면 */ .m02-df__start { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); gap: var(--spacing-24); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts index e6a60b0e8..0cd240ba0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -47,12 +47,11 @@ const loadMods = () => import("./M02_DF_FuncPanel"), import("./M02_DF_OutputPanel"), import("./M02_DF_Summary"), - import("./M02_DF_Sample"), import("./M02_DF_InputPanel"), import("./M02_DF_PillPanel"), import("./M02_DF_LinkPanel"), import("./M02_DF_Standards"), - ]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({ + ]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({ model, ev, api, @@ -60,7 +59,6 @@ const loadMods = () => func, output, summary, - sample, input, pill, line, @@ -78,7 +76,6 @@ const TEXT = { 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"], @@ -217,7 +214,6 @@ function openDataFlow(): void { paper = null; strip.replaceChildren(); const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] }); - const sampleHost = el("div", { className: "m02-df__list" }); body.replaceChildren( el("div", { className: "m02-df__start", @@ -232,28 +228,10 @@ function openDataFlow(): void { mineHost, ], }), - el("section", { - className: "m02-df__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( @@ -379,7 +357,7 @@ function openDataFlow(): void { if (sig === logicSig) return; logicSig = sig; const get = async (key: string): Promise => - key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key); + key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null); void loadLogics(lg, get).then((got) => { if (sig !== logicSig) return; logics = got.logics; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index 541fd358c..3125278d4 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -236,19 +236,28 @@ export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string return null; } -/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리) */ +/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리 · 이어지지 않은 다른 로직 받는 알약) + * examples = 받는 알약마다 그 로직 자기 예시값(선이 없을 때 값 차례의 끝) */ export function logicRefOf(key: string, other: CfLogic): CfLogicRef { const outputs = other.boxes .filter((b) => b.kind === "output") .map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" })); const designs: { name: string; unit: string }[] = []; + const examples: Record = {}; + const addDesign = (name: string, unit: string, example?: number | string | null): void => { + designs.push({ name, unit: unit || "-" }); + if (example !== null && example !== undefined && example !== "") examples[name] = example; + }; for (const b of other.boxes) { - if (b.kind !== "input" || !b.input) continue; - if (b.input.mode === "variable") - for (const v of b.input.variables) designs.push({ name: v.name, unit: v.unit || "-" }); - for (const e of b.input.elements) - if (e.mode === "choice" || e.mode === "rule") - designs.push({ name: e.slot || e.name, unit: e.unit || "-" }); + for (const v of b.input?.variables ?? b.func?.variables ?? []) + addDesign(v.name, v.unit, v.example); + if (b.input?.mode === "group") + for (const e of b.input.elements) + if (e.mode === "choice" || e.mode === "rule") addDesign(e.slot || e.name, e.unit); + if (b.input?.mode === "logic") + for (const d of b.input.logic?.designs ?? []) + if (!other.links.some((l) => l.to.box === b.id && l.to.name === d.name)) + addDesign(d.name, d.unit, b.input.logic?.examples?.[d.name]); } const seen = new Set(); return { @@ -256,9 +265,93 @@ export function logicRefOf(key: string, other: CfLogic): CfLogicRef { name: other.name, outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)), designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))), + ...(Object.keys(examples).length ? { examples } : {}), }; } +/* ── 마스터 로직 고르기 — 목록 · 거름 · 쪽 · 순환 막음(36-6) ── */ + +/** 마스터 로직 한 줄 — M01 `fetchLogics` 의 줄(자기 시험 로직 DF-… 도 같은 꼴로 합침) */ +export interface MasterLogicBrief { + file: string; + 구분: string; + 상세구분: string; + 키: string; + 원문번호: string; + 이름: string; + 결과단위: string; + blocked?: boolean; + reasons?: string[]; +} + +/** 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 원문번호 · 키 · 구분 · 상세구분 · 띄어쓰기는 가리지 않음) · 원문(구분) · 장(상세구분) 거름 */ +export function filterLogics( + items: MasterLogicBrief[], + q: { text: string; book?: string; chapter?: string }, +): MasterLogicBrief[] { + const words = q.text.split(/\s+/).map(squash).filter(Boolean); + return items.filter((i) => { + if (q.book && i.구분 !== q.book) return false; + if (q.chapter && i.상세구분 !== q.chapter) return false; + const hay = squash(`${i.이름} ${i.원문번호} ${i.키} ${i.구분} ${i.상세구분}`); + return words.every((w) => hay.includes(w)); + }); +} + +/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */ +export function logicBooks(items: MasterLogicBrief[]): string[] { + return [...new Set(items.map((i) => i.구분).filter(Boolean))]; +} +export function logicChapters(items: MasterLogicBrief[], book: string): string[] { + return [ + ...new Set( + items + .filter((i) => !book || i.구분 === book) + .map((i) => i.상세구분) + .filter(Boolean), + ), + ]; +} + +/** 옛 로직을 흐름도로 바꿔 읽은 것은 출력 상자가 비목별이라 노무비 · 재료비 · 경비 · 계 알약 넷으로 보임 */ +export const COST_OUTPUTS = ["노무비", "재료비", "경비", "계"] as const; + +/** 마스터 로직 고른 것 → 다른 로직 입력 값 — 흐름이면 출력 상자 · 변환이면 비용 넷 */ +export function masterRef( + key: string, + flow: CfLogic, + converted: boolean, + resultUnit: string, +): CfLogicRef { + const ref = logicRefOf(key, flow); + return converted + ? { ...ref, outputs: COST_OUTPUTS.map((name) => ({ name, unit: resultUnit || "-" })) } + : ref; +} + +/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */ +export async function wouldCycle( + selfKey: string | null, + startKey: string, + load: (key: string) => Promise, + depth = 8, +): Promise { + if (!selfKey) return false; + const seen = new Set(); + const walk = async (key: string, left: number): Promise => { + if (key === selfKey) return true; + if (seen.has(key) || left <= 0) return false; + seen.add(key); + const lg = await load(key).catch(() => null); + for (const b of lg?.boxes ?? []) { + const next = b.input?.mode === "logic" ? b.input.logic?.key : undefined; + if (next && (await walk(next, left - 1))) return true; + } + return false; + }; + return walk(startKey, depth); +} + /** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */ export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic { return { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts index a59e4e3d4..3ac1c5507 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -1,42 +1,87 @@ /* ============================================================================= * M02_DF_InputPanel_Logic.ts - * 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면 - * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) + * 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면 + * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길 + * 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름 * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; -import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api"; -import type { CfLogic } from "./M02_DF_Model"; -import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api"; +import type { CfLogic, CfLogicRef } from "./M02_DF_Model"; +import { + filterLogics, + logicBooks, + logicChapters, + logicRefOf, + masterRef, + pageRange, + setKind, + setLogicInput, + wouldCycle, + type MasterLogicBrief, +} from "./M02_DF_InputPanel_Build"; const TEXT = { Help: [ - "저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀", - "Use a saved logic or sample as an input", + "마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀", + "Use any master logic or a logic of mine as an input", ], - Search: ["로직 이름으로 찾기", "Search by name"], + Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], + Book: ["원문", "Source"], + Chapter: ["장", "Chapter"], + All: ["전체", "All"], Loading: ["찾는 중…", "Searching…"], None: ["찾는 로직 없음", "No logic found"], - Sample: ["견본", "Sample"], + Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], + Prev: ["이전", "Prev"], + Next: ["다음", "Next"], Mine: ["내가 만든 것", "Mine"], + Blocked: ["읽을 수 없음", "Unreadable"], Chosen: ["고른 로직", "Chosen logic"], - Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"], + Converted: [ + "옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜", + "Old logic read as a flow — the file is unchanged", + ], + Notes: ["못 옮긴 곳", "Not carried over"], + Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"], Designs: [ - "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)", + "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)", "Pills it takes — its design inputs", ], NoOut: ["출력 상자가 없음", "No output box"], NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"], Change: ["다른 로직 고르기", "Pick another"], Remove: ["빼기", "Remove"], - Self: ["이 로직 자신은 못 씀", "Can't use itself"], + Self: ["이 로직 자신은 못 고름", "Can't pick itself"], + Cycle: [ + "돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름", + "That would loop — a logic that uses this one can't be picked", + ], + NoFlow: [ + "이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음", + "Can't read this logic's flow yet", + ], Failed: ["불러오지 못함", "Could not load"], + Wait: ["값 셈 중…", "Calculating…"], } as const; type TKey = keyof typeof TEXT; -const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; +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"); const plain = (e: unknown): string => { try { @@ -46,6 +91,40 @@ const plain = (e: unknown): string => { } }; +const PAGE = 12; +const MINE = "흐름도"; + +/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */ +let cache: Promise | null = null; +const loadAll = (): Promise => + (cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])]) + .then(([master, mine]) => [ + ...mine + .filter((i) => !i.sample) + .map((i): MasterLogicBrief => ({ + file: MINE, + 구분: MINE, + 상세구분: t("Mine"), + 키: i.key, + 원문번호: "", + 이름: i.name, + 결과단위: i.resultUnit, + })), + ...master, + ]) + .catch((e: unknown) => ((cache = null), Promise.reject(e)))); + +/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */ +const readNotes = new Map(); + +/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */ +async function flowOf( + item: Pick, +): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> { + if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] }; + return cfLogicFlow(item.file, item.키); +} + /** 다른 로직 입력 판 */ export function renderLogicInput( host: HTMLElement, @@ -55,32 +134,86 @@ export function renderLogicInput( ): void { const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic; if (ref) return void showChosen(host, lg, boxId, ref, on); + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); - const list = el("div", { className: "m02-dfip__list" }); - let all: CfListItem[] = []; - const draw = (): void => { - const word = q.input.value.trim().toLowerCase(); - const rows = all.filter( - (i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)), + const all = { value: "", text: t("All") }; + let book = ""; + let chapter = ""; + let page = 1; + let items: MasterLogicBrief[] = []; + const bookBox = createSelectField({ + label: t("Book"), + options: [all], + value: "", + compact: true, + onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()), + }); + const chapterBox = createSelectField({ + label: t("Chapter"), + options: [all], + value: "", + compact: true, + onChange: (v) => ((chapter = v), (page = 1), draw()), + }); + const fillChapters = (): void => + chapterBox.setOptions( + [all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))], + chapter, ); + const list = el("div", { className: "m02-dfip__list" }); + const info = el("span", { className: "m02-dfip__muted" }); + const prev = createButton({ + label: t("Prev"), + variant: "ghost", + size: "sm", + onClick: () => ((page -= 1), draw()), + }); + const next = createButton({ + label: t("Next"), + variant: "ghost", + size: "sm", + onClick: () => ((page += 1), draw()), + }); + + const draw = (): void => { + const rows = filterLogics( + items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌 + { text: q.input.value, book, chapter }, + ); + const range = pageRange(rows.length, page, PAGE); + page = Math.min(page, range.pages); + const shown = rows.slice((page - 1) * PAGE, page * PAGE); + info.textContent = t("Total", { + n: rows.length, + a: range.from, + b: range.to, + p: page, + m: range.pages, + }); + prev.disabled = page <= 1; + next.disabled = page >= range.pages; list.replaceChildren( - ...(rows.length - ? rows.map((i) => { + ...(shown.length + ? shown.map((i) => { const b = el("button", { className: "m02-dfip__row", - attrs: { type: "button" }, + attrs: { + type: "button", + ...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}), + }, children: [ el("span", { className: "m02-dfip__name", children: [ - el("strong", { text: i.name }), + el("strong", { text: i.이름 }), + el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }), el("span", { className: "m02-dfip__tag", - text: i.sample ? t("Sample") : t("Mine"), + text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분, }), ], }), - el("span", { className: "m02-dfip__muted", text: i.resultUnit }), + el("span", { className: "m02-dfip__muted", text: i.결과단위 }), ], }); b.addEventListener("click", () => void pick(i)); @@ -89,56 +222,114 @@ export function renderLogicInput( : [el("p", { className: "m02-dfip__muted", text: t("None") })]), ); }; - const pick = async (i: CfListItem): Promise => { + + /** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */ + const loadForCycle = async (key: string): Promise => { + const it = items.find((x) => x.키 === key); + return it ? (await flowOf(it)).flow : null; + }; + + const pick = async (i: MasterLogicBrief): Promise => { try { - const other = await cfGet(i.key); - const next = setLogicInput(lg, boxId, logicRefOf(i.key, other)); + if (i.키 === lg.key) return void showToast(t("Self"), "error"); + if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error"); + const got = await flowOf(i); + readNotes.set(i.키, { converted: got.converted, notes: got.notes }); + const picked = got.converted + ? masterRef(i.키, got.flow, true, i.결과단위) + : { ...logicRefOf(i.키, got.flow), name: i.이름 }; + const done = setLogicInput(lg, boxId, picked); // 기본 이름이면 고른 로직 이름으로 on.change({ - ...next, - boxes: next.boxes.map((b) => - b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b, + ...done, + boxes: done.boxes.map((b) => + b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b, ), }); } catch (e) { - showToast(plain(e), "error"); + showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error"); } }; - q.input.addEventListener("input", draw); + + let timer = 0; + q.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => ((page = 1), draw()), 200); + }); list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); - host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list); - void cfList().then( - (items) => ((all = items), draw()), + host.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("Help") }), + el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }), + info, + list, + el("div", { className: "m02-dfip__chips", children: [prev, next] }), + ); + void loadAll().then( + (rows) => { + items = rows; + bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], ""); + fillChapters(); + draw(); + }, (e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })), ); } -/** 고른 로직 — 내보내는 알약 · 받는 알약 */ +/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */ function showChosen( host: HTMLElement, lg: CfLogic, boxId: string, - ref: NonNullable["logic"], + ref: CfLogicRef, on: { change(lg: CfLogic): void }, ): void { - if (!ref) return; - const pillLine = (p: { name: string; unit: string }): HTMLElement => + const read = readNotes.get(ref.key); + const values = new Map(); + const errors = el("p", { className: "m02-dfip__muted is-bad" }); + const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement => el("div", { className: "m02-dfip__item", children: [ - el("span", { className: "m02-dfip__name", text: p.name }), - el("span", { className: "m02-dfip__muted", text: p.unit || "-" }), + el("span", { className: "m02-dfip__name", text: name }), + ...(valueNode + ? [valueNode] + : extra + ? [el("span", { className: "m02-dfip__muted", text: extra })] + : []), + el("span", { className: "m02-dfip__muted", text: unit || "-" }), ], }); host.replaceChildren( el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }), + ...(read?.converted + ? [ + el("p", { className: "m02-dfip__muted", text: t("Converted") }), + ...(read.notes.length + ? [ + el("details", { + children: [ + el("summary", { text: `${t("Notes")} · ${read.notes.length}` }), + ...read.notes.map((n) => el("p", { text: n })), + ], + }), + ] + : []), + ] + : []), el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }), ...(ref.outputs.length - ? ref.outputs.map(pillLine) + ? ref.outputs.map((o) => { + const v = el("strong", { className: "m02-dfip__value", text: "—" }); + values.set(o.name, v); + return line(o.name, o.unit, v); + }) : [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]), + errors, el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }), ...(ref.designs.length - ? ref.designs.map(pillLine) + ? ref.designs.map((d) => + line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")), + ) : [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]), el("div", { className: "m02-dfip__chips", @@ -158,4 +349,17 @@ function showChosen( ], }), ); + // 값 — 서버 계산 길(받는 알약 예시값으로) + errors.textContent = t("Wait"); + void cfLogicValue(ref.key, ref.examples ?? {}).then( + (r) => { + for (const [name, node] of values) { + const v = r.outputs[name] ?? (r.cost as unknown as Record)[name]; + if (typeof v === "number") node.textContent = fmt(v); + else if (typeof v === "string") node.textContent = v; + } + errors.textContent = r.errors.join(" · "); + }, + (e) => (errors.textContent = plain(e)), + ); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts new file mode 100644 index 000000000..6a90118bb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Paper.ts @@ -0,0 +1,341 @@ +/* ============================================================================= + * M02_DF_Paper.ts + * 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소 + * Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀. + * 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로). + * readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름). + * 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import * as api from "./M02_DF_Api"; +import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram"; +import * as ev from "./M02_DF_Eval"; +import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; +import { renderFuncPanel } from "./M02_DF_FuncPanel"; +import { renderInputPanel } from "./M02_DF_InputPanel"; +import { renderLinkPanel } from "./M02_DF_LinkPanel"; +import * as model from "./M02_DF_Model"; +import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; +import { renderOutputPanel } from "./M02_DF_OutputPanel"; +import { renderPillPanel } from "./M02_DF_PillPanel"; +import { renderSummary } from "./M02_DF_Summary"; +import type { CfSel } from "./M02_DF_Verify"; +import { + detailFrame, + drawHead, + drawWarnings, + fetchValues, + loadLogics, + queriesOf, + type DfPick, +} from "./M02_DF_Entry_Detail"; +import "./M02_DF_Entry.css"; + +const TEXT = { + Undo: ["실행 취소", "Undo"], + Color: ["상자 색", "Box colour"], + BoxName: ["상자 이름", "Box name"], + NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], + Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], + Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], + ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"], +} as const; + +const tp = (key: keyof typeof TEXT, v = ""): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v); + +export interface PaperOpts { + logic: CfLogic; + readOnly?: boolean; + /** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */ + onChange(lg: CfLogic): void; + /** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */ + logics?(key: string): Promise; +} + +export interface Paper { + /** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */ + parts: { + diagram: HTMLElement; + detail: HTMLElement; + summary: HTMLElement; + note: HTMLElement; + undo: HTMLButtonElement; + }; + get(): CfLogic; + /** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */ + set(lg: CfLogic): void; + undo(): void; + /** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */ + refit(): void; + redraw(): void; +} + +export function createPaper(opts: PaperOpts): Paper { + const readOnly = !!opts.readOnly; + let lg = opts.logic; + let stack: CfLogic[] = []; + let pick: DfPick = null; + let values: CfValues = {}; + let valueSig = ""; + let logics: NonNullable = {}; + let logicSig = ""; + let logicNotes: string[] = []; + let notes: string[] = []; + let broken: CfBreak[] = []; + let result: CfEvalResult; + let fitKey = 0; + const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); + + const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); + const diagramHost = el("div", { className: "m02-dfp__diagram" }); + const summaryHost = el("div", { className: "m02-dfp__summary" }); + const colorBox = el("input", { + className: "m02-df__color-input", + attrs: { type: "color", "aria-label": tp("Color") }, + }); + colorBox.addEventListener("change", () => { + const id = pickedBox(); + if (id) edit(model.setColor(lg, id, colorBox.value)); + }); + const colorRow = el("label", { + className: "m02-df__color", + children: [el("span", { text: tp("Color") }), colorBox], + }); + const boxName = createInputField({ label: tp("BoxName"), compact: true }); + boxName.input.addEventListener("change", () => { + const box = lg.boxes.find((b) => b.id === pickedBox()); + if (!box) return; + const next = boxName.input.value.trim(); + const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next); + if (taken) note.textContent = tp("NameTaken", next); + if (!next || taken || next === box.name) return void (boxName.input.value = box.name); + edit(model.updateBox(lg, box.id, { name: next })); + }); + // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만 + const funcHost = el("div"); + const outHost = el("div"); + const inHost = el("div"); + const linkHost = el("div"); + const pillHost = el("div"); + const detail = detailFrame(); + detail.settings.append(boxName.root, colorRow); + // 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨) + const lock = el("fieldset", { + className: "m02-dfp__lock", + children: [inHost, funcHost, outHost, linkHost, pillHost], + }); + lock.disabled = readOnly; + detail.body.append(lock); + if (readOnly) detail.root.prepend(el("p", { className: "m02-df__muted", text: tp("ReadOnly") })); + + const evalNow = (): CfEvalResult => { + try { + return ev.evalLogic(lg, values, { logics }); + } catch (e) { + const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] }; + } + }; + + /** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */ + const edit = (next: CfLogic): void => { + if (readOnly || next === lg) return; + stack = [...stack, lg]; + lg = next; + opts.onChange(lg); + redraw(); + }; + /** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */ + const editExample = (next: CfLogic): void => { + if (!readOnly) return edit(next); + lg = next; + redraw(); + }; + const editFromDiagram = (next: CfLogic): void => { + edit(next); + setTimeout(redraw); + }; + + const refreshValues = (): void => { + const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null; + const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q)); + if (sig === valueSig) return; + valueSig = sig; + void fetchValues(api.cfValues, lg, pill, model.elementPort).then( + (got) => { + if (sig !== valueSig) return; + values = got.values; + notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : []; + redraw(); + }, + (e: unknown) => { + notes = [tp("Values", api.cfPlain(e))]; + redraw(); + }, + ); + }; + + const refreshLogics = (): void => { + const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); + if (sig === logicSig) return; + logicSig = sig; + const get = + opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null)); + void loadLogics(lg, get).then((got) => { + if (sig !== logicSig) return; + logics = got.logics; + logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : []; + redraw(); + }); + }; + + const choose = (sel: CfSel): void => { + const box = lg.boxes.find((b) => b.id === sel.box); + pick = sel.link + ? { kind: "link", id: sel.link } + : box && sel.port + ? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) } + : box + ? { kind: "box", id: box.id } + : null; + redraw(); + }; + + /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ + const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { + try { + if (pick?.kind === "pill") { + const p = { box: pick.box, name: pick.port }; + return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) }; + } + const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id); + if (link) + return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) }; + } catch { + /* 따라갈 수 없으면 칠하지 않음 */ + } + return null; + }; + + const diagramOn = { + select: (id: string | null, pill?: DfPillSel): void => { + pick = pill + ? { kind: "pill", ...pill } + : !id + ? null + : lg.links.some((l) => l.id === id) + ? { kind: "link", id } + : { kind: "box", id }; + setTimeout(redraw); + }, + move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)), + link: (from: CfPort, to: CfPort): string | null => { + const got = model.link(lg, from, to); + if (got.why) return got.why; + editFromDiagram(got.lg); + return null; + }, + unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)), + add: (kind: CfKind, x: number, y: number): void => { + const got = model.addBox(lg, kind, x, y); + pick = { kind: "box", id: got.box.id }; + editFromDiagram(got.lg); + }, + remove: (id: string): void => { + if (pickedBox() === id) pick = null; + editFromDiagram(model.removeBox(lg, id)); + }, + }; + + const drawPanel = (): void => { + const boxId = pickedBox(); + if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null; + const box = lg.boxes.find((b) => b.id === pickedBox()); + drawHead(detail, lg, pick); + (detail.settings.parentElement as HTMLElement).hidden = !box || readOnly; + colorRow.hidden = !box; + boxName.root.hidden = !box || box.kind === "output"; + if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; + linkHost.hidden = pick?.kind !== "link"; + pillHost.hidden = pick?.kind !== "pill"; + if (pick?.kind === "link") + renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); + if (pick?.kind === "pill") + renderPillPanel( + pillHost, + lg, + pick.box, + pick.port, + { pick: (id) => choose({ box: id }) }, + { values, logics }, + ); + drawWarnings(detail, pick, broken); + 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") renderInputPanel(inHost, lg, box.id, { change: edit }); + if (box?.kind === "function") renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "output") + renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); + }; + + const redraw = (): void => { + result = evalNow(); + refreshValues(); + refreshLogics(); + const linkId = pick?.kind === "link" ? pick.id : ""; + if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; + try { + broken = ev.breaks(lg, values, { logics }); + } catch { + broken = result.breaks ?? []; + } + undo.disabled = readOnly || !stack.length; + renderDiagram(diagramHost, lg, diagramOn, { + values: result.ports, + selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, + selectedPill: + pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, + trace: traceNow(), + breaks: broken, + readOnly, + fitKey, + }); + drawPanel(); + renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values }); + note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back || readOnly) return; + stack = stack.slice(0, -1); + lg = back; + opts.onChange(lg); + redraw(); + }; + const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); + + redraw(); + return { + parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo }, + get: () => lg, + set: (next) => { + lg = next; + stack = []; + pick = null; + fitKey += 1; + redraw(); + }, + undo: doUndo, + refit: () => { + fitKey += 1; + redraw(); + }, + redraw, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts index 5341b5a83..5101ad946 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string { maximumFractionDigits: Math.max(0, r.places), }); } + +/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */ +let totalNow: CfRound = { places: 0, method: "버림" }; +void defaultTotalRound().then((r) => (totalNow = r)); +export const totalRoundNow = (): CfRound => totalNow; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts b/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts deleted file mode 100644 index 96bda504e..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* ============================================================================= - * M02_DF_Sample.ts - * 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬) - * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비 - * ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비 - * ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비 - * ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비 - * ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직 - * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). - * 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음. - * 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함 - * `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨. - * ========================================================================== */ - -import { currentLanguageIndex } from "@ui/ui_template_locale"; -import type { CfLogic } from "./M02_DF_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", - ], - ground: [ - "지반이 토사면 굴삭기로 · 아니면 브레이커로 — 조건 통과 선 둘 → 경비", - "Soil goes to the excavator, rock to the breaker — two gate lines → expense", - ], - hume: [ - "관경으로 흄관 품목을 찾음(재료 자리 · 규격에 관경 포함) → 본수 × 단가 → 재료비", - "Pipe size picks the hume pipe item (spec contains size) → count × price → material cost", - ], - linked: [ - "작업량 로직의 출력을 받아 장비 시간 → 경비", - "Takes the output of a productivity logic → machine hours → expense", - ], - 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", "ground", "hume", "linked", "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/_sample/masonry.json b/resources/tester/fixtures_df/costflow/masonry.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/masonry.json rename to resources/tester/fixtures_df/costflow/masonry.json diff --git a/M02_MasterTemplete/CostFlow/_sample/rate.json b/resources/tester/fixtures_df/costflow/rate.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/rate.json rename to resources/tester/fixtures_df/costflow/rate.json diff --git a/M02_MasterTemplete/CostFlow/_sample/value.json b/resources/tester/fixtures_df/costflow/value.json similarity index 100% rename from M02_MasterTemplete/CostFlow/_sample/value.json rename to resources/tester/fixtures_df/costflow/value.json diff --git a/M02_MasterTemplete/DataFlow/_sample/ground.json b/resources/tester/fixtures_df/ground.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/ground.json rename to resources/tester/fixtures_df/ground.json diff --git a/M02_MasterTemplete/DataFlow/_sample/hume.json b/resources/tester/fixtures_df/hume.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/hume.json rename to resources/tester/fixtures_df/hume.json diff --git a/M02_MasterTemplete/DataFlow/_sample/linked.json b/resources/tester/fixtures_df/linked.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/linked.json rename to resources/tester/fixtures_df/linked.json diff --git a/M02_MasterTemplete/DataFlow/_sample/masonry.json b/resources/tester/fixtures_df/masonry.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/masonry.json rename to resources/tester/fixtures_df/masonry.json diff --git a/M02_MasterTemplete/DataFlow/_sample/value.json b/resources/tester/fixtures_df/value.json similarity index 100% rename from M02_MasterTemplete/DataFlow/_sample/value.json rename to resources/tester/fixtures_df/value.json diff --git a/resources/tester/test_m01_logic_flowview.py b/resources/tester/test_m01_logic_flowview.py new file mode 100644 index 000000000..67a4d35ea --- /dev/null +++ b/resources/tester/test_m01_logic_flowview.py @@ -0,0 +1,184 @@ +"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 · +화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.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 stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => undefined) }); +const cache = {}; +function load(f) { + if (cache[f]) return cache[f].exports; + const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = (cache[f] = { exports: {} }); + const req = (id) => { + if (id.startsWith(".")) { + const p = path.resolve(path.dirname(f), id); + return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; + } + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const m = load(file); +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => m.flowRows(job.lg, job.ev, job.round)); +process.stdout.write(JSON.stringify(out)); +setTimeout(() => process.exit(0), 50); +""" + +ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}} + + +def box(id_, kind, name, unit="원", **extra): + return { + "id": id_, + "kind": kind, + "name": name, + "color": "#000", + "unit": unit, + "x": 0, + "y": 0, + **extra, + } + + +def out_box(id_, name, cost, unit="원", round_=None): + output = {"cost": cost, "unit": unit} + if round_: + output["round"] = round_ + return box(id_, "output", name, unit, output=output) + + +def func_box(id_, name, out_name, unit="시간"): + func = { + "sheet": {"id": "s-" + id_, "이름": name, "칸": {}}, + "inputs": [], + "variables": [], + "output": {"cell": "B1", "name": out_name, "unit": unit}, + } + return box(id_, "function", name, unit, func=func) + + +def logic(boxes): + return { + "key": None, + "name": "시험", + "resultUnit": "원/㎡", + "boxes": boxes, + "links": [], + "userElements": [], + } + + +def result(outputs, cost, ports=None, errors=None): + return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []} + + +def run(jobs: list[dict]) -> list: + node = "node" + done = subprocess.run( + [node, "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +@pytest.fixture(scope="module") +def sample(): + lg = logic( + [ + out_box("o1", "인건비", "노무비"), + out_box("o2", "재료값", "재료비"), + out_box("o3", "시간당 작업량", "값", "㎥/시간"), + out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}), + func_box("f1", "운반 시간", "시간", "시간"), + ] + ) + ev = result( + {"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239}, + { + "노무비": 3216.69, + "재료비": 100.04, + "경비": 1.239, + "값들": {"시간당 작업량": 12.5}, + "계": 3317.969, + }, + ports={"f1": {"시간": 6.024}}, + ) + return run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + + +def group(rows, key): + return next(g for g in rows["groups"] if g["key"] == key) + + +def test_group_order_and_costs_only(sample): + assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"] + + +def test_line_uses_output_round(sample): + # 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음) + assert group(sample, "labor")["lines"][0]["text"] == "3,216.6" + assert group(sample, "material")["lines"][0]["text"] == "100" + # 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림) + assert group(sample, "cost")["lines"][0]["text"] == "1.24" + + +def test_plain_value_is_not_rounded_and_has_no_subtotal(sample): + plain = group(sample, "plain") + assert plain["lines"][0]["text"] == "12.5" + assert plain["lines"][0]["unit"] == "㎥/시간" + assert plain["sub"] is None + + +def test_subtotal_and_total_floor_to_won(sample): + assert group(sample, "labor")["sub"] == 3216 + assert group(sample, "labor")["subText"] == "3,216" + assert sample["total"] == 3317 + assert sample["totalText"] == "3,317" + + +def test_function_rows(sample): + assert sample["funcs"] == [ + {"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"} + ] + + +def test_empty_group_is_hidden(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10}) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert [g["key"] for g in rows["groups"]] == ["labor"] + assert rows["total"] == 10 + + +def test_missing_value_shows_dash(): + lg = logic([out_box("o1", "인건비", "노무비")]) + ev = result( + {"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"] + ) + rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0] + assert group(rows, "labor")["lines"][0]["text"] == "—" + assert rows["errors"] == ["끊김"] diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py index 2acb47268..00d6fb661 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -1,4 +1,4 @@ -"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`. +"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`. · 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) · 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 @@ -21,7 +21,7 @@ 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((FOLDER / "_sample").glob("*.json"))} # fmt: skip +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"; diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py new file mode 100644 index 000000000..cc91ee13d --- /dev/null +++ b/resources/tester/test_m02_df_input_logic.py @@ -0,0 +1,230 @@ +"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_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" / "DataFlow" / "M02_DF_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; +(async () => { + const jobs = JSON.parse(fs.readFileSync(0, "utf8")); + const out = []; + for (const j of jobs) { + if (j.name === "wouldCycle") { + const graph = j.args[2]; + out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null)); + } else out.push(await m[j.name](...j.args)); + } + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +def call(name: str, *args): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout)[0] + + +def row(key, name, book, chapter, no="", unit="원/㎡", **more): + return { + "file": f"로직_{book}.json", + "구분": book, + "상세구분": chapter, + "키": key, + "원문번호": no, + "이름": name, + "결과단위": unit, + **more, + } + + +ITEMS = [ + row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"), + row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"), + row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"), + row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"), + row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"), +] + + +def keys(items): + return [i["키"] for i in items] + + +def test_찾기는_띄어쓰기로_나눈_말을_모두_포함(): + got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"}) + assert keys(got) == ["GF000219"] + assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"] + assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [ + "GF000219" + ] # 띄어쓰기는 가리지 않음 + assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [ + "GF000219", + "GF000220", + ] # 원문번호 + assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [ + "GC000051" + ] # 번호 · 대소문자 + assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS + + +def test_원문_장으로_거름(): + assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"] + got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"}) + assert keys(got) == ["GF000025"] + assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == [] + + +def test_원문_장_목록은_나온_차례(): + assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"] + assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"] + assert call("logicChapters", ITEMS, "") == [ + "13장 구조물", + "05장 녹화", + "01장 공통", + "내가 만든 것", + ] + + +def box(id_, kind, name, **more): + return { + "id": id_, + "kind": kind, + "name": name, + "color": "#fff", + "unit": "-", + "x": 0, + "y": 0, + **more, + } + + +FLOW = { + "key": "GF000219", + "name": "돌쌓기 메쌓기(인력)", + "resultUnit": "원/㎡", + "userElements": [], + "links": [], + "boxes": [ + box( + "b1", + "input", + "입력", + input={ + "mode": "variable", + "elements": [], + "variables": [ + {"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30}, + { + "name": "돌", + "type": "choice", + "unit": "", + "choices": ["깬돌", "견치돌"], + "example": "깬돌", + }, + ], + }, + ), + box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}), + ], +} + + +def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것(): + ref = call("masterRef", "GF000219", FLOW, False, "원/㎡") + assert ref["key"] == "GF000219" + assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}] + assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}] + assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"} + + +def test_옛_로직_변환은_노무비_재료비_경비_계_넷(): + ref = call("masterRef", "GF000219", FLOW, True, "원/㎡") + assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"] + assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"} + assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로 + + +def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것(): + inner = { + "mode": "logic", + "elements": [], + "variables": [], + "logic": { + "key": "GF000250", + "outputs": [{"name": "작업량", "unit": "㎥/시간"}], + "designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}], + "examples": {"지역": "서울"}, + }, + } + flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]} + flow["links"] = [ + {"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}} + ] + ref = call("logicRefOf", "X", flow) + names = [d["name"] for d in ref["designs"]] + assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌 + assert ref["examples"]["지역"] == "서울" + + +def test_자기_자신과_돌고_도는_로직은_막음(): + graph = { + "A": { + "boxes": [ + box( + "b", + "input", + "x", + input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}}, + ) + ] + }, + "B": { + "boxes": [ + box( + "b", + "input", + "x", + input={ + "mode": "logic", + "elements": [], + "variables": [], + "logic": {"key": "SELF"}, + }, + ) + ] + }, + "C": {"boxes": []}, + } + assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신 + assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF + assert call("wouldCycle", "SELF", "C", graph) is False + assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음 + assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음 diff --git a/resources/tester/test_m02_df_model.py b/resources/tester/test_m02_df_model.py index 3d39c8e64..10c2c925d 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -646,7 +646,9 @@ def test_쉬는_갈래와_문제_무게() -> None: def _ground() -> dict: - return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8")) + return json.loads( + (ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8") + ) def _server(lg: dict, values: dict) -> dict: diff --git a/resources/tester/test_m02_df_sample.py b/resources/tester/test_m02_df_sample.py index 11bc2f9af..342436f79 100644 --- a/resources/tester/test_m02_df_sample.py +++ b/resources/tester/test_m02_df_sample.py @@ -1,4 +1,4 @@ -"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`. +"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움). 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value) · 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) @@ -6,7 +6,7 @@ · 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음 · 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 · 입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133 -· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 +· 파일 안 씀 """ from __future__ import annotations @@ -14,7 +14,6 @@ from __future__ import annotations import json import math import re -import subprocess from decimal import Decimal from pathlib import Path @@ -22,8 +21,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow" -SAMPLE_TS = FOLDER / "M02_DF_Sample.ts" -SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip NAMES = ["masonry", "ground", "hume", "linked", "value"] BOX_COLOR = dict( re.findall( @@ -34,40 +32,6 @@ BOX_COLOR = dict( ) ) -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]: @@ -215,16 +179,6 @@ def test_견본_흐름_규칙_방식() -> None: } -def test_견본_목록_열기() -> None: - got = _node( - "out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });" - ) - assert [x["id"] for x in got["list"]] == NAMES - assert all(x["note"] for x in got["list"]) - assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"] - assert got["none"] is None - - # ── 값(내부 확인) ───────────────────────────────────────────────────────── @pytest.fixture(scope="module") def master():