From 712aac8e39051b7458b0b293487b256fb6baaa79 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:14:11 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EC=9E=87=EA=B8=B0=20=E2=80=94=20=EC=97=B4=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EA=B2=AC=EB=B3=B8=20=EB=B3=B5=EC=82=AC=20=C2=B7=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B0=92=20=C2=B7=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EA=B7=B8=EB=9E=A8=20=C2=B7=20=ED=95=A8=EC=88=98/?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=ED=8C=90=20=C2=B7=20=EC=9B=90=EA=B0=80=20?= =?UTF-8?q?=C2=B7=20=EC=A0=80=EC=9E=A5(=EC=84=9C=EB=B2=84=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0)=20=C2=B7=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/CostFlow/M02_CF_Entry.css | 15 +- M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 434 +++++++++++++++---- 2 files changed, 360 insertions(+), 89 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css index 98c3faa23..487d834fa 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css @@ -115,10 +115,6 @@ padding: var(--spacing-12); } -.m02-cf__diagram .m02-cf__part-body { - min-height: 60vh; -} - .m02-cf__lower { display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); @@ -189,3 +185,14 @@ font-size: var(--text-body-sm); white-space: nowrap; } + +.m02-cf__note { + margin: 0 0 var(--spacing-12); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m02-cf__note:empty { + display: none; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts index 84e7a2f91..f1e180935 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -1,9 +1,10 @@ /* ============================================================================= * M02_CF_Entry.ts - * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀 + * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게. - * 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음. + * 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary + * → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S. * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). * ========================================================================== */ @@ -17,21 +18,47 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { CfListItem } from "./M02_CF_Api"; -import type { CfLogic } from "./M02_CF_Model"; +import type { CfListItem, CfQuery } from "./M02_CF_Api"; +import type { CfEvalResult, CfValues } from "./M02_CF_Eval"; +import type { CfKind, CfLogic, CfPort } from "./M02_CF_Model"; import "./M02_CF_Entry.css"; +/** CostFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ +const loadMods = () => + Promise.all([ + import("./M02_CF_Model"), + import("./M02_CF_Eval"), + import("./M02_CF_Api"), + import("./M02_CF_Diagram"), + import("./M02_CF_FuncPanel"), + import("./M02_CF_OutputPanel"), + import("./M02_CF_Summary"), + import("./M02_CF_Sample"), + ]).then(([model, ev, api, diagram, func, output, summary, sample]) => ({ + model, + ev, + api, + diagram, + func, + output, + summary, + sample, + })); +type Mods = Awaited>; + const TEXT = { Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], Title: ["단가산출 다이어그램", "Cost diagram"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], + OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], Start_New: ["새로 만들기", "New"], Start_Mine: ["내 로직", "My logics"], Start_MineNone: ["아직 없음", "None yet"], - Start_Samples: ["견본", "Samples"], + Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], Name: ["로직 이름", "Logic name"], NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], Unit: ["결과 단위", "Result unit"], @@ -41,11 +68,24 @@ const TEXT = { Unsaved: ["저장 안 함", "Not saved"], Diagram: ["다이어그램", "Diagram"], Box: ["고른 상자", "Chosen box"], - Panel_Input: ["입력", "Input"], - Panel_Function: ["함수", "Function"], - Panel_Output: ["출력", "Output"], + PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], + InputSoon: ["입력 상자 판은 곧 붙음", "The input box panel is coming"], Summary: ["원가", "Cost"], - NotYet: ["준비 중", "Coming soon"], + NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], + SaveAnyway: [ + "고칠 곳이 있음 — {v} · 그래도 저장할까요?", + "There are issues — {v} · Save anyway?", + ], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], + Differs: [ + "저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름", + "Recalculated on save: {s} — differs from the screen value {c}", + ], + Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Deleted: ["지웠음", "Deleted"], + Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -55,17 +95,14 @@ const TEXT = { type TextKey = keyof typeof TEXT; -const te = (key: TextKey): string => - (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; +const te = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text }); -/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */ -const blankLogic = (name: string, unit: string): Promise => - import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null); -const loadList = (): Promise => - import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []); - /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ async function unitChoices(): Promise { const count = new Map(); @@ -83,6 +120,44 @@ async function unitChoices(): Promise { return units.length ? units : ["원/㎡"]; } +/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */ +function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] { + return lg.boxes.flatMap((b) => { + const inp = b.input; + if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return []; + const group = inp.group; + const els = inp.elements + .filter((e) => e.ref && !e.ref.startsWith("UE-")) + .map((e) => ({ + q: { id: `${b.id}\u0001${e.name}`, group, ref: e.ref, unit: e.unit }, + box: b.id, + port: e.name, + })); + const table = inp.table + ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] + : []; + return [...els, ...table]; + }); +} + +/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */ +async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> { + const asks = queriesOf(lg); + if (!asks.length) return { values: {}, errors: [] }; + const answers = await M.api.cfValues( + asks.map((a) => a.q), + lg.userElements as never, + ); + const values: CfValues = {}; + const errors: string[] = []; + for (const a of asks) { + const got = answers.find((x) => x.id === a.q.id); + if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`); + else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null; + } + return { values, errors }; +} + /** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ export function createCostFlowButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow }); @@ -90,8 +165,9 @@ export function createCostFlowButton(): HTMLButtonElement { function openCostFlow(): void { /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean } | null = null; + let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let units: string[] | null = null; + const mods = loadMods(); const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); @@ -103,7 +179,7 @@ function openCostFlow(): void { variant: "ghost", onClick: () => void (async () => { - if (await mayLeave()) shut(); + if (await mayLeave()) paper ? void showStart() : shut(); })(), }); const page = el("div", { @@ -128,42 +204,52 @@ function openCostFlow(): void { document.body.style.overflow = prevOverflow; }; page.addEventListener("keydown", (ev) => { - if (ev.key !== "Escape" || ev.defaultPrevented) return; - void (async () => { - if (await mayLeave()) shut(); - })(); + if (ev.defaultPrevented) return; + if (ev.key === "Escape") { + void (async () => { + if (await mayLeave()) shut(); + })(); + return; + } + if (!paper || !(ev.ctrlKey || ev.metaKey)) return; + const key = ev.key.toLowerCase(); + if (key === "z" && !ev.shiftKey) { + ev.preventDefault(); + paper.undo(); + } else if (key === "s") { + ev.preventDefault(); + void paper.save(); + } }); + const run = (job: (M: Mods) => Promise): void => { + body.replaceChildren(muted(te("Loading"))); + void mods.then(job).catch((e: unknown) => { + showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error"); + void showStart(); + }); + }; + /* ---------- 시작 화면 ---------- */ - const listRow = (item: CfListItem): HTMLElement => { + const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { const row = el("button", { className: "m02-cf__item", - attrs: { type: "button", title: item.saved }, + attrs: { type: "button", title }, children: [ - el("span", { className: "m02-cf__item-name", text: item.name }), - el("span", { className: "m02-cf__item-unit", text: item.resultUnit }), + el("span", { className: "m02-cf__item-name", text: name }), + el("span", { className: "m02-cf__item-unit", text: unit }), ], }); - row.addEventListener("click", () => showToast(te("NotYet"), "info")); + row.addEventListener("click", onClick); return row; }; - const showStart = (): void => { + const showStart = async (): Promise => { paper = null; strip.replaceChildren(); const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); - const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); - void loadList().then((all) => { - const mine = all.filter((x) => !x.sample); - const samples = all.filter((x) => x.sample); - mineHost.replaceChildren( - ...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]), - ); - sampleHost.replaceChildren( - ...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]), - ); - }); + const sampleHost = el("div", { className: "m02-cf__list" }); body.replaceChildren( el("div", { className: "m02-cf__start", @@ -171,7 +257,9 @@ function openCostFlow(): void { el("section", { className: "m02-cf__start-col", children: [ - bigButton(te("Start_New"), () => void openNew()), + bigButton(te("Start_New"), () => + run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), + ), el("h3", { text: te("Start_Mine") }), mineHost, ], @@ -183,41 +271,231 @@ function openCostFlow(): void { ], }), ); + const M = await mods; + sampleHost.replaceChildren( + ...M.sample.sampleList().map((s) => + listRow( + s.name, + s.resultUnit, + () => + run(async (M2) => { + const lg = M2.sample.sampleLoad(s.id); + if (lg) await showPaper(M2, lg, false); + }), + s.note, + ), + ), + ); + try { + const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); + mineHost.replaceChildren( + ...(mine.length + ? mine.map((x) => + listRow( + x.name, + x.resultUnit, + () => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)), + x.saved, + ), + ) + : [muted(te("Start_MineNone"))]), + ); + } catch (e) { + mineHost.replaceChildren(muted(M.api.cfPlain(e))); + } }; + const units_ = async (): Promise => (units ??= await unitChoices()); /* ---------- 고치는 화면 ---------- */ - const openNew = async (): Promise => { - units ??= await unitChoices(); - const lg = await blankLogic("", units[0]); - showPaper(lg, units); - }; + const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise => { + const choices = await units_(); + let lg = first; + let savedLg: CfLogic | null = saved ? first : null; + let stack: CfLogic[] = []; + let selected: string | null = null; + let values: CfValues = {}; + let valueSig = ""; + let notes: string[] = []; + let result: CfEvalResult; + let busy = false; - const showPaper = (lg: CfLogic | null, choices: string[]): void => { - let dirty = false; - paper = { dirty: () => dirty }; const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = true; - const touched = (): void => { - dirty = true; - unsaved.hidden = false; + const note = el("p", { className: "m02-cf__note", attrs: { role: "status" } }); + const diagramHost = el("div"); + const pickHint = muted(te("PickBox")); + // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) + const funcHost = el("div"); + const outHost = el("div"); + const inHost = el("div", { children: [muted(te("InputSoon"))] }); + const summaryHost = el("div"); + + const evalNow = (): CfEvalResult => { + try { + return M.ev.evalLogic(lg, values); + } catch (e) { + const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; + return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; + } }; + + const edit = (next: CfLogic): void => { + if (next === lg) return; + stack = [...stack, lg]; + lg = next; + redraw(); + }; + /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */ + const editFromDiagram = (next: CfLogic): void => { + edit(next); + setTimeout(redraw); + }; + + /** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ + const refreshValues = (): void => { + const sig = JSON.stringify(queriesOf(lg).map((a) => a.q)); + if (sig === valueSig) return; + valueSig = sig; + void fetchValues(M, lg).then( + (got) => { + if (sig !== valueSig) return; + values = got.values; + notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : []; + redraw(); + }, + (e: unknown) => { + notes = [te("Values", { v: M.api.cfPlain(e) })]; + redraw(); + }, + ); + }; + const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), - value: lg?.name ?? "", - onInput: touched, + value: lg.name, + onInput: (v) => edit({ ...lg, name: v }), }); name.root.classList.add("m02-cf__name"); + const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices]; const unit = createSelectField({ label: te("Unit"), - options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: lg?.resultUnit ?? choices[0], - onChange: touched, + options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), + value: lg.resultUnit, + onChange: (v) => edit({ ...lg, resultUnit: v }), + }); + + const diagramOn = { + select: (id: string | null): void => { + selected = id; + drawPanel(); + }, + move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), + link: (from: CfPort, to: CfPort): string | null => { + const got = M.model.link(lg, from, to); + if (got.why) return got.why; + editFromDiagram(got.lg); + return null; + }, + unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)), + add: (kind: CfKind, x: number, y: number): void => { + const got = M.model.addBox(lg, kind, x, y); + selected = got.box.id; + editFromDiagram(got.lg); + }, + remove: (id: string): void => { + if (selected === id) selected = null; + editFromDiagram(M.model.removeBox(lg, id)); + }, + }; + + const drawPanel = (): void => { + const box = lg.boxes.find((b) => b.id === selected); + pickHint.hidden = !!box; + funcHost.hidden = box?.kind !== "function"; + outHost.hidden = box?.kind !== "output"; + inHost.hidden = box?.kind !== "input"; + if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }); + if (box?.kind === "output") + M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); + }; + + const redraw = (): void => { + result = evalNow(); + refreshValues(); + unsaved.hidden = lg === savedLg; + undo.disabled = !stack.length; + deleteButton.hidden = !lg.key; + M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected }); + drawPanel(); + M.summary.renderSummary(summaryHost, lg, result, { change: edit }); + note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n"); + }; + + const save = async (): Promise => { + if (busy) return; + if (!lg.name.trim()) { + note.textContent = te("NoName"); + return void name.input.focus(); + } + let problems: string[] = []; + try { + problems = M.ev.checkLogic(lg, values); + } catch { + problems = []; + } + const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); + if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return; + busy = true; + note.textContent = te("Saving"); + const screen = result.cost.계; + try { + const got = await M.api.cfSave(lg); + lg = got.logic; + savedLg = lg; + stack = []; + redraw(); + const server = got.values.cost.계; + if (Math.abs(server - screen) > 0.5) + notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; + note.textContent = [...notes, ...result.errors].join("\n"); + showToast(te("Saved"), "success"); + } catch (e) { + note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) }); + } finally { + busy = false; + } + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back) return; + stack = stack.slice(0, -1); + lg = back; + name.input.value = lg.name; + unit.select.value = lg.resultUnit; + redraw(); + }; + + const doDelete = async (): Promise => { + if (!lg.key || busy) return; + if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return; + try { + await M.api.cfDelete(lg.key); + savedLg = lg; + showToast(te("Deleted"), "success"); + void showStart(); + } catch (e) { + note.textContent = M.api.cfPlain(e); + } + }; + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); + const deleteButton = createButton({ + label: te("Delete"), + variant: "danger", + onClick: () => void doDelete(), }); - const notYet = (): void => showToast(te("NotYet"), "info"); - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; strip.replaceChildren( name.root, unit.root, @@ -226,39 +504,33 @@ function openCostFlow(): void { className: "m02-cf__actions", children: [ undo, - createButton({ label: te("Save"), onClick: notYet }), - createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), + createButton({ label: te("Save"), onClick: () => void save() }), + deleteButton, ], }), ); body.replaceChildren( + note, el("div", { className: "m02-cf__layout", children: [ - part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]), + part(te("Diagram"), "m02-cf__diagram", [diagramHost]), el("div", { className: "m02-cf__lower", children: [ - part(te("Box"), "m02-cf__box", [ - el("div", { - className: "m02-cf__box-slots", - children: [ - slot(te("Panel_Input")), - slot(te("Panel_Function")), - slot(te("Panel_Output")), - ], - }), - ]), - part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]), + part(te("Box"), "m02-cf__box", [pickHint, inHost, funcHost, outHost]), + part(te("Summary"), "m02-cf__summary", [summaryHost]), ], }), ], }), ); - name.input.focus(); + paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; + redraw(); + if (!lg.name) name.input.focus(); }; - showStart(); + void showStart(); document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 document.body.append(page); page.tabIndex = -1; @@ -273,14 +545,6 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl }); } -/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */ -function slot(title: string): HTMLElement { - return el("div", { - className: "m02-cf__slot", - children: [el("h4", { text: title }), muted(te("NotYet"))], - }); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m02-cf__big"); From 7df1df570289b61403885ca08b23090056264012 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:20:36 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(m02-cf):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=ED=8C=90(renderInputPanel)=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EA=B8=B0=EA=B3=84=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=E3=80=8C=ED=82=A4#=EC=B9=B8=E3=80=8D=20=EC=9D=80=20=ED=82=A4?= =?UTF-8?q?=EC=99=80=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20=EB=82=98=EB=88=A0=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts index f1e180935..aaad09fec 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -36,7 +36,8 @@ const loadMods = () => import("./M02_CF_OutputPanel"), import("./M02_CF_Summary"), import("./M02_CF_Sample"), - ]).then(([model, ev, api, diagram, func, output, summary, sample]) => ({ + import("./M02_CF_InputPanel"), + ]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({ model, ev, api, @@ -45,6 +46,7 @@ const loadMods = () => output, summary, sample, + input, })); type Mods = Awaited>; @@ -69,7 +71,6 @@ const TEXT = { Diagram: ["다이어그램", "Diagram"], Box: ["고른 상자", "Chosen box"], PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], - InputSoon: ["입력 상자 판은 곧 붙음", "The input box panel is coming"], Summary: ["원가", "Cost"], NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], SaveAnyway: [ @@ -128,11 +129,13 @@ function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] { const group = inp.group; const els = inp.elements .filter((e) => e.ref && !e.ref.startsWith("UE-")) - .map((e) => ({ - q: { id: `${b.id}\u0001${e.name}`, group, ref: e.ref, unit: e.unit }, - box: b.id, - port: e.name, - })); + .map((e) => { + // 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김 + const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined]; + const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit }; + if (field) q.field = field; + return { q, box: b.id, port: e.name }; + }); const table = inp.table ? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }] : []; @@ -327,7 +330,7 @@ function openCostFlow(): void { // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) const funcHost = el("div"); const outHost = el("div"); - const inHost = el("div", { children: [muted(te("InputSoon"))] }); + const inHost = el("div"); const summaryHost = el("div"); const evalNow = (): CfEvalResult => { @@ -415,6 +418,7 @@ function openCostFlow(): void { funcHost.hidden = box?.kind !== "function"; outHost.hidden = box?.kind !== "output"; inHost.hidden = box?.kind !== "input"; + if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit }); if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }); if (box?.kind === "output") M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); From dfd4810bf1bd4a0612d87e22632634479de30794 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:21:27 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix(m02-cf):=20=EA=B2=AC=EB=B3=B8=20?= =?UTF-8?q?=EB=A9=94=EC=8C=93=EA=B8=B0=20=EB=86=92=EC=9D=B4=20=EC=A6=9D?= =?UTF-8?q?=EA=B0=80=EC=9C=A8=ED=91=9C=20=EC=9E=85=EB=A0=A5=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=EC=9D=84=20=EC=86=8C=EC=9A=94=EB=9F=89=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=8B=9C=ED=97=98=EC=9D=B4=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EA=B7=B8=EB=A3=B9=EA=B3=BC=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/CostFlow/_sample/masonry.json | 2 +- resources/tester/test_m02_cf_sample.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/M02_MasterTemplete/CostFlow/_sample/masonry.json index a57860530..3182b5a6a 100644 --- a/M02_MasterTemplete/CostFlow/_sample/masonry.json +++ b/M02_MasterTemplete/CostFlow/_sample/masonry.json @@ -73,7 +73,7 @@ "y": 460, "input": { "mode": "group", - "group": "계수", + "group": "소요량", "elements": [], "table": { "key": "QF000422" diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py index ec59c4ed4..863354316 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -224,6 +224,17 @@ def _run(lg: dict, master) -> dict: return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} +def test_견본_표_그룹이_마스터와_같음(master) -> None: + """표 입력 상자의 group = 그 표가 든 마스터 파일 그룹(소요량 · 계수)""" + cm, files = master + group = {str(t["키"]): d["그룹"] for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip + for lg in SAMPLES.values(): + for b in lg["boxes"]: + table = b.get("input", {}).get("table") + if table: + assert b["input"]["group"] == group[table["key"]], (b["id"], table["key"]) + + def test_견본1_원가_calc_와_같음(master) -> None: from M01_MasterData.LogicBuilder import M01_LB_Calc as lb From 2ea48832730080f225524e134aeb163fe1ddd81f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:21:55 +0900 Subject: [PATCH 04/15] =?UTF-8?q?fix(M02-CostFlow):=20FuncPanel=20?= =?UTF-8?q?=EC=B9=B8=20=EC=97=86=EB=8A=94=20=EB=B0=9B=EB=8A=94=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EC=95=84=EC=A7=81=20=EC=95=88=20=EB=86=93?= =?UTF-8?q?=EC=9D=80=20=EC=95=8C=EC=95=BD=EC=9C=BC=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=84=20-=20=EC=84=A0=20=EC=97=86=EC=9D=B4=EB=8F=84=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EA=B3=A0=20=EC=B9=B8=EC=97=90=20=EB=86=93?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EC=9E=A0=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts index f43861d16..84dad666f 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts @@ -24,7 +24,7 @@ const TEXT = { GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], Placed: ["놓임", "placed"], - Free: ["놓을 칸 없음", "not placed"], + Free: ["아직 안 놓은 알약", "not placed yet"], Busy: ["이미 쓰는 칸", "That cell is already in use"], Locked: [ "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", @@ -260,6 +260,8 @@ function incoming(st: State): { port: string; unit: string }[] { "-"); out.set(l.to.name, unit || "-"); } + // 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리) + for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-"); return [...out].map(([port, unit]) => ({ port, unit })); } @@ -273,7 +275,7 @@ function problems(st: State): string[] { } } for (const p of incoming(st)) { - if (!f.inputs.some((i) => i.port === p.port)) out.push(`${p.port}${tf("Unplaced")}`); + if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`); } if (!f.output.cell) out.push(tf("OutNone")); return out; @@ -285,12 +287,13 @@ function applyMarks(st: State): void { const f = funcOf(st); if (!f) return; const marks = new Map(); - for (const i of f.inputs) marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); + for (const i of f.inputs.filter((x) => x.cell)) + marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); for (const v of f.variables) if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` }); if (f.output.cell) marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` }); - const locked = new Set(f.inputs.map((i) => i.cell)); + const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell)); const list = problems(st); const bad = new Set(list.map((p) => p.split(" ")[0])); for (const c of st.parts.sheet.querySelectorAll(".aislo-cell")) { @@ -581,7 +584,7 @@ function onBook(st: State, book: Workbook): void { if (!f) return; const sheet = plain(structuredClone(book.시트[0])); const hit = f.inputs.find( - (i) => JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), + (i) => i.cell && JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), ); if (hit) { showToast(tf("Locked"), "error"); From 71c20453e4455a53d2ee6da8c05a64ec41d695b2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:22:01 +0900 Subject: [PATCH 05/15] =?UTF-8?q?fix(M02):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=E2=80=94=20=ED=95=A8=EC=88=98=20=EC=83=81=EC=9E=90=20=EB=AA=B8?= =?UTF-8?q?=ED=86=B5=20=C2=B7=20=E3=80=8C+=E3=80=8D=20=EB=B0=9B=EB=8A=94?= =?UTF-8?q?=20=EC=9E=90=EB=A6=AC=EC=97=90=20=EB=86=93=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=20=EC=95=8C=EC=95=BD=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20on.link(PLAN=2035-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받는 알약 0 개인 새 함수 상자도 이을 수 있음 · 받는 알약은 Model 이 새로 만듦 - 함수 상자 왼쪽 흐린 「+」 받는 자리 · 잇기 중이면 밝게 - ORCA: 빈 함수 상자 몸통 ← 면적 · 「+」 ← 높이 → on.link 두 번(이름 그대로) · CostFlow 전부 검사 초록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- .../CostFlow/M02_CF_Diagram.css | 18 +++++++++ M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts | 37 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css index fc9c8ee8f..467e29d7a 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.css @@ -256,3 +256,21 @@ stroke-width: 2; stroke-dasharray: 5 4; } + +/* 함수 상자 흐린 「+」 받는 자리 */ +.m02-cfdg__pill.is-plus { + justify-content: center; + min-width: 2rem; + border-style: dashed; + border-color: var(--color-text-muted); + background: transparent; + color: var(--color-text-muted); + opacity: 0.7; +} + +.m02-cfdg__pill.is-plus:hover, +.m02-cfdg.is-linking .m02-cfdg__pill.is-plus.is-target { + opacity: 1; + border-color: var(--cfdg-in); + color: var(--cfdg-in); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts index d8f55a7b5..e4febfee2 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts @@ -53,6 +53,10 @@ const TEXT = { From: ["어디서", "From"], To: ["어디로", "To"], Value: ["지금 값", "Now"], + PlusIn: [ + "받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김", + "Drop an output pill here to add an input", + ], LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"], Hint: [ "상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대", @@ -124,6 +128,9 @@ export function resolveDrop( if (target.box === from.box) return { why: tt("SameBox") }; if (target.dir === "out") return { why: tt("NotIn") }; const box = lg.boxes.find((b) => b.id === target.box); + // 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」) + if (box?.kind === "function" && target.name === null) + return { to: { box: box.id, name: from.name } }; const ins = box ? pillsOf(box).ins : []; if (!box || !ins.length) return { why: tt("NoIn") }; const name = @@ -300,6 +307,28 @@ export function renderDiagram( return btn; }; + /** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */ + const plusSlot = (b: CfBox): HTMLElement => { + const slot = el("button", { + className: `m02-cfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`, + text: "+", + attrs: { + type: "button", + "data-box": b.id, + "data-dir": "in", + "data-plus": "1", + title: tt("PlusIn"), + }, + }); + slot.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" }); + st.note = tt("PlusIn"); + redraw(); + }); + return slot; + }; + const boxEl = (b: CfBox): HTMLElement => { const { ins, outs } = pillsOf(b); const head = el("div", { @@ -323,7 +352,13 @@ export function renderDiagram( el("div", { className: "m02-cfdg__pills", children: [ - el("div", { className: "m02-cfdg__ins", children: ins.map((p) => pillEl(b, p, "in")) }), + el("div", { + className: "m02-cfdg__ins", + children: [ + ...ins.map((p) => pillEl(b, p, "in")), + ...(b.kind === "function" ? [plusSlot(b)] : []), + ], + }), el("div", { className: "m02-cfdg__outs", children: outs.map((p) => pillEl(b, p, "out")), From 1004768037c7ecee23bac74be6fd2d344c2a876a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:22:20 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat(m02-cf):=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=EC=97=90=20=EB=86=93=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EC=95=8C=EC=95=BD=EC=9D=84=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EC=96=B4=20=EC=9D=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - link: 받는 쪽이 함수 상자이고 그 이름 받는 알약이 없으면 새로 만듦(빈 이름이면 보낸 알약 이름 · 같으면 「이름 (2)」) · func.inputs 에 칸 없이 더함 - unlink · removeBox: 받는 알약이 칸에 안 놓였고 다른 선도 없으면 같이 지움 - 검사: 칸에 놓지 않은 받는 알약 알림 - 시험 13개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/CostFlow/M02_CF_Eval.ts | 6 +++ M02_MasterTemplete/CostFlow/M02_CF_Model.ts | 52 ++++++++++++++++----- resources/tester/test_m02_cf_model.py | 50 ++++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts index 4d0679995..7c3209560 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Eval.ts @@ -53,6 +53,10 @@ const TEXT = { ], NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"], NoOutput: ["출력 상자가 없음", "There is no output box"], + NoInCell: [ + "「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음", + "Pill 「{pill}」 of 「{box}」 is not placed in a cell", + ], NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], } as const; @@ -193,6 +197,8 @@ export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { out.push(tl("EmptyIn", { box: b.name, pill: p.name })); if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) out.push(tl("NoCell", { box: b.name })); + for (const i of b.func?.inputs ?? []) + if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port })); } if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); const ev = evalLogic(lg, values); diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts index 949ad705a..f6ada09ca 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts @@ -201,11 +201,10 @@ export function addBox( /** 상자 지우기 — 이어진 선도 지움 */ export function removeBox(lg: CfLogic, id: string): CfLogic { - return { - ...lg, - boxes: lg.boxes.filter((b) => b.id !== id), - links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), - }; + // 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리 + let next = lg; + for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id); + return { ...next, boxes: next.boxes.filter((b) => b.id !== id) }; } export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic { @@ -267,7 +266,9 @@ export function reaches(lg: CfLogic, from: string, to: string): boolean { return walk(from); } -/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */ +/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 + * 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 · + * 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { const a = boxOf(lg, from.box); const b = boxOf(lg, to.box); @@ -275,21 +276,48 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: if (a.kind === "output") return { lg, why: tl("FromOutput") }; if (b.kind === "input") return { lg, why: tl("ToInput") }; if (a.id === b.id) return { lg, why: tl("Self") }; - if (!portsOut(a).some((p) => p.name === from.name) || !portsIn(b).some((p) => p.name === to.name)) - return { lg, why: tl("NoPill") }; + if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") }; + const has = portsIn(b).some((p) => p.name === to.name); + if (!has && b.kind !== "function") return { lg, why: tl("NoPill") }; if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; - if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) + if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) return { lg, why: tl("Taken") }; + let next = lg; + let port = to.name; + if (!has) { + const used = portsIn(b).map((p) => p.name); + const base = to.name.trim() || from.name; + port = used.includes(base) ? uniqueSuffix(used, base) : base; + const func = b.func!; + next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } }); + } const id = nextId( "l", - lg.links.map((l) => l.id), + next.links.map((l) => l.id), ); return { - lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] }, + lg: { + ...next, + links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }], + }, why: null, }; } +/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */ +function uniqueSuffix(used: string[], base: string): string { + for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`; +} + +/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */ export function unlink(lg: CfLogic, id: string): CfLogic { - return { ...lg, links: lg.links.filter((l) => l.id !== id) }; + const gone = lg.links.find((l) => l.id === id); + const next = { ...lg, links: lg.links.filter((l) => l.id !== id) }; + const box = gone && boxOf(next, gone.to.box); + if (!gone || !box?.func) return next; + const port = box.func.inputs.find((i) => i.port === gone.to.name); + const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name); + if (!port || port.cell.trim() || still) return next; + const inputs = box.func.inputs.filter((i) => i !== port); + return updateBox(next, box.id, { func: { ...box.func, inputs } }); } diff --git a/resources/tester/test_m02_cf_model.py b/resources/tester/test_m02_cf_model.py index 8072c83af..0e9ea8cce 100644 --- a/resources/tester/test_m02_cf_model.py +++ b/resources/tester/test_m02_cf_model.py @@ -272,3 +272,53 @@ def test_알약_이름_규칙() -> None: "out(M.portsOut(lg.boxes[0]).map(p => p.name));" ) assert got == ["시멘트", "표", "길이"] + + +def test_새_함수_상자에_놓으면_받는_알약이_생김() -> None: + got = _run( + ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" + "let a = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' });\n" + "let b = M.link(a.lg, { box: area.id, name: '면적' }, { box: g.id, name: '' });\n" + "let c = M.link(b.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '석공' });\n" + "let d = M.link(c.lg, { box: f.id, name: '노무비' }, { box: g.id, name: '' });\n" + "const gb = (x) => x.boxes.find(y => y.id === g.id);\n" + "const inputs = gb(d.lg).func.inputs;\n" + "const check = E.checkLogic(d.lg, V).filter(x => x.includes('칸에 놓지 않음'));\n" + "const placed = M.updateBox(d.lg, g.id, { func: { ...gb(d.lg).func, inputs: inputs.map(i => i.port === '면적' ? { ...i, cell: 'B2' } : i) } });\n" + "const cutAll = placed.links.filter(l => l.to.box === g.id).reduce((x, l) => M.unlink(x, l.id), placed);\n" + "const removed = M.removeBox(d.lg, man.id);\n" + "out([a.why, b.why, c.why, d.why, inputs, M.portsIn(gb(d.lg)).map(p => p.name), d.lg.links.slice(-2).map(l => l.to)," + " check, gb(cutAll).func.inputs, gb(removed).func.inputs.map(i => i.port)," + " M.link(lg, { box: man.id, name: '석공' }, { box: o.id, name: '없음' }).why]);" + ) + a, b, c, d, inputs, ports, last_to, check, cut_all, removed, to_output = got + assert (a, b, c, d) == (None, None, "이미 선이 들어온 자리", None) + assert inputs == [ + {"port": "석공", "cell": ""}, + {"port": "면적", "cell": ""}, + {"port": "노무비", "cell": ""}, + ] + assert ports == ["석공", "면적", "노무비"] + assert last_to == [{"box": "b5", "name": "면적"}, {"box": "b5", "name": "노무비"}] + assert check == [ + "「함수 2」 의 받는 알약 「석공」 을 칸에 놓지 않음", + "「함수 2」 의 받는 알약 「면적」 을 칸에 놓지 않음", + "「함수 2」 의 받는 알약 「노무비」 을 칸에 놓지 않음", + ] + assert cut_all == [{"port": "면적", "cell": "B2"}] # 칸에 놓인 것만 남음 + assert removed == ["면적", "노무비"] # 석공 상자를 지우면 칸 없는 「석공」 받는 알약도 지움 + assert to_output == "알약이 없음" # 출력 상자는 받는 알약을 새로 만들지 않음 + + +def test_같은_이름_받는_알약은_번호() -> None: + got = _run( + ONE + "r = add(lg, 'function'); lg = r.lg; const g = r.box;\n" + "let x = M.link(lg, { box: man.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" + "x = M.link(x, { box: f.id, name: '노무비' }, { box: g.id, name: '' }).lg;\n" + "r = add(x, 'input'); x = r.lg; const m2 = r.box;\n" + "x = M.updateBox(x, m2.id, { input: { mode: 'group', group: '인력', elements: [{ ref: 'LB1', name: '석공', spec: '', unit: '인' }], variables: [] } });\n" + "x = M.link(x, { box: m2.id, name: '석공' }, { box: g.id, name: '' }).lg;\n" + "x = M.link(x, { box: area.id, name: '면적' }, { box: g.id, name: '할증' }).lg;\n" + "out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));" + ) + assert got == ["석공", "노무비", "석공 (2)", "할증"] From b571d7cecc9b69efb1b65275562729099d35bd53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:24:16 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat(m02-cf):=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=ED=8C=90=20=EB=A8=B8=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EC=83=89=20=EB=B0=94=EA=BE=B8=EA=B8=B0(?= =?UTF-8?q?=EC=A0=95=ED=95=A8=20=EC=A0=90=EA=B2=80=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/CostFlow/M02_CF_Entry.css | 22 ++++++++++++++++++++ M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 17 ++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css index 487d834fa..63f66e9fc 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.css +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.css @@ -196,3 +196,25 @@ .m02-cf__note:empty { display: none; } + +.m02-cf__color { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + margin-bottom: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-cf__color[hidden] { + display: none; +} + +.m02-cf__color-input { + width: 44px; + height: 32px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: none; + cursor: pointer; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts index aaad09fec..8e80d6524 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -72,6 +72,7 @@ const TEXT = { Box: ["고른 상자", "Chosen box"], PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], Summary: ["원가", "Cost"], + Color: ["상자 색", "Box colour"], NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], SaveAnyway: [ "고칠 곳이 있음 — {v} · 그래도 저장할까요?", @@ -327,6 +328,18 @@ function openCostFlow(): void { const note = el("p", { className: "m02-cf__note", attrs: { role: "status" } }); const diagramHost = el("div"); const pickHint = muted(te("PickBox")); + // 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나 + const colorBox = el("input", { + className: "m02-cf__color-input", + attrs: { type: "color", "aria-label": te("Color") }, + }); + colorBox.addEventListener("change", () => { + if (selected) edit(M.model.setColor(lg, selected, colorBox.value)); + }); + const colorRow = el("label", { + className: "m02-cf__color", + children: [el("span", { text: te("Color") }), colorBox], + }); // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) const funcHost = el("div"); const outHost = el("div"); @@ -415,6 +428,8 @@ function openCostFlow(): void { const drawPanel = (): void => { const box = lg.boxes.find((b) => b.id === selected); pickHint.hidden = !!box; + colorRow.hidden = !box; + if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; funcHost.hidden = box?.kind !== "function"; outHost.hidden = box?.kind !== "output"; inHost.hidden = box?.kind !== "input"; @@ -522,7 +537,7 @@ function openCostFlow(): void { el("div", { className: "m02-cf__lower", children: [ - part(te("Box"), "m02-cf__box", [pickHint, inHost, funcHost, outHost]), + part(te("Box"), "m02-cf__box", [pickHint, colorRow, inHost, funcHost, outHost]), part(te("Summary"), "m02-cf__summary", [summaryHost]), ], }), From 2be767d186b9ecdf59645a6c84bdfbab567851c3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:32:32 +0900 Subject: [PATCH 08/15] =?UTF-8?q?fix(m02-cf):=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EC=B6=9C=EB=A0=A5=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=84=20=EB=A1=9C=EC=A7=81=20=EC=95=88?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B2=B9=EC=B9=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addBox(function) 출력 이름 = 「결과 1」 「결과 2」 … (안 쓰인 가장 작은 번호) · A1 머리도 같은 이름 - 이름이 겹치면 link 가 받는 알약을 「이름 (2)」 로 만듦(이미 됨 · 시험 더함) - 시험 14개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M02_MasterTemplete/CostFlow/M02_CF_Model.ts | 12 ++++++++--- resources/tester/test_m02_cf_model.py | 24 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts index f6ada09ca..d19f3f431 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Model.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Model.ts @@ -188,13 +188,19 @@ export function addBox( ); const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y }; if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] }; - if (kind === "function") + if (kind === "function") { + // 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음 + const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : [])); + let n = 1; + while (taken.includes(`${tl("Result")} ${n}`)) n++; + const out = `${tl("Result")} ${n}`; box.func = { - sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: tl("Result") } } }, + sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } }, inputs: [], variables: [], - output: { cell: "B1", name: tl("Result"), unit: "-" }, + output: { cell: "B1", name: out, unit: "-" }, }; + } if (kind === "output") box.output = { cost: "노무비", unit: "-" }; return { lg: { ...lg, boxes: [...lg.boxes, box] }, box }; } diff --git a/resources/tester/test_m02_cf_model.py b/resources/tester/test_m02_cf_model.py index 0e9ea8cce..818541127 100644 --- a/resources/tester/test_m02_cf_model.py +++ b/resources/tester/test_m02_cf_model.py @@ -322,3 +322,27 @@ def test_같은_이름_받는_알약은_번호() -> None: "out(x.boxes.find(y => y.id === g.id).func.inputs.map(i => i.port));" ) assert got == ["석공", "노무비", "석공 (2)", "할증"] + + +def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호() -> None: + got = _run( + "let lg = M.newLogic(); let r = add(lg, 'function'); lg = r.lg; const f1 = r.box;\n" + "r = add(lg, 'function'); lg = r.lg; const f2 = r.box;\n" + "r = add(lg, 'function'); lg = r.lg; const f3 = r.box;\n" + "lg = M.removeBox(lg, f2.id); r = add(lg, 'function'); lg = r.lg; const f4 = r.box;\n" + "lg = M.link(lg, { box: f1.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" + "lg = M.link(lg, { box: f4.id, name: '결과 2' }, { box: f3.id, name: '' }).lg;\n" + "r = add(lg, 'function'); lg = r.lg; const f5 = r.box;\n" + "lg = M.updateBox(lg, f5.id, { func: { ...f5.func, output: { ...f5.func.output, name: '결과 1' } } });\n" + "lg = M.link(lg, { box: f5.id, name: '결과 1' }, { box: f3.id, name: '' }).lg;\n" + "out([[f1, f3, f4, f5].map(b => [b.func.output.name, b.func.sheet.칸.A1.값])," + " lg.boxes.find(b => b.id === f3.id).func.inputs.map(i => i.port)]);" + ) + names, ports = got + assert names == [ + ["결과 1", "결과 1"], + ["결과 3", "결과 3"], + ["결과 2", "결과 2"], + ["결과 4", "결과 4"], + ] + assert ports == ["결과 1", "결과 2", "결과 1 (2)"] From 7322c5af12c90ec6913e7f8974fed62c54507ad1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:48:49 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix(M02):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=E2=80=94=20=EC=95=8C=EC=95=BD=20=EA=B0=92=20=EC=89=BC=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20=ED=91=9C=20=E3=80=8C=C2=B7=20=E2=97=8B=EC=A4=84?= =?UTF-8?q?=E3=80=8D=20=C2=B7=20=EC=A0=80=EC=A0=88=EB=A1=9C=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=2090%=20=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=95=88=20?= =?UTF-8?q?=EB=82=B4=EB=A6=BC(PLAN=2035-7=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=E2=91=A1=20=E2=91=A2=20=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수는 천 단위 쉼표 · 소수 셋째 자리까지 · 끝 0 지움 · 글은 그대로 - 저절로 맞춤이 넘치면 왼쪽 위부터 보임 · 축소 단추로는 더 줄임 - 알약 대신 길(Model 준비 전) 걷음 · 682줄 · CostFlow 전부 검사 초록 - ORCA 견본 메쌓기: 97,882.512 · 268,908 · 0.364 · 표 · 40줄 · 처음 확대 0.9 · 한눈에 0.9 · 축소 0.75 · 알약 글 10.8px Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts | 49 +++++++------------ 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts index e4febfee2..5ba3662b6 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Diagram.ts @@ -93,29 +93,9 @@ export interface DiagramOpts { /* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */ -/** 알약 — Model 이 아직이면 상자 칸에서 바로 뽑음 */ +/** 알약 — Model 의 받는 · 내보내는 알약 */ export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } { - try { - return { ins: portsIn(box), outs: portsOut(box) }; - } catch { - if (box.kind === "input") { - const inp = box.input; - const outs = [ - ...(inp?.elements ?? []).map((e) => ({ name: e.name, unit: e.unit })), - ...(inp?.table ? [{ name: "표", unit: "-" }] : []), - ...(inp?.variables ?? []).map((v) => ({ name: v.name, unit: v.unit })), - ]; - return { ins: [], outs }; - } - if (box.kind === "function") { - const f = box.func; - return { - ins: (f?.inputs ?? []).map((p) => ({ name: p.port, unit: "-" })), - outs: f ? [{ name: f.output.name, unit: f.output.unit }] : [], - }; - } - return { ins: [{ name: "값", unit: box.unit }], outs: [] }; - } + return { ins: portsIn(box), outs: portsOut(box) }; } /** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */ @@ -170,7 +150,10 @@ export function arrange( return out; } -/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 */ +/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */ +export const FIT_MIN = 0.9; + +/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */ export function fitView( rects: { x: number; y: number; w: number; h: number }[], view: { w: number; h: number }, @@ -181,12 +164,11 @@ export function fitView( const y0 = Math.min(...rects.map((r) => r.y)) - pad; const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad; const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad; - const zoom = clampZoom(Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)); - return { - zoom, - panX: (view.w - (x1 - x0) * zoom) / 2 - x0 * zoom, - panY: (view.h - (y1 - y0) * zoom) / 2 - y0 * zoom, - }; + const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5))); + /** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */ + const place = (size: number, from: number, to: number): number => + Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom; + return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) }; } const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3))); @@ -208,10 +190,15 @@ interface HostState { const states = new WeakMap(); const SVG = "http://www.w3.org/2000/svg"; -const showValue = (v: unknown): string => { +/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */ +export const showValue = (v: unknown): string => { if (v === null || v === undefined || v === "") return ""; if (typeof v === "number") - return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4)); + return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : ""; + if (Array.isArray(v)) { + const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0; + return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`; + } return String(v).slice(0, 12); }; From 19fed5b90fac37c6beabbb85b273a1dec7574241 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:49:02 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A7=89=EB=8C=80=20=EC=8B=9C=ED=97=98=ED=8C=90=20?= =?UTF-8?q?=EB=84=B7=EC=9D=84=20=E3=80=8C=EC=8B=9C=ED=97=98=ED=8C=90=20?= =?UTF-8?q?=E2=96=BE=E3=80=8D=20=EB=AA=A9=EB=A1=9D=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A1=9C=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20CostFlow=20=EC=9B=90?= =?UTF-8?q?=EA=B0=80=20=EC=B9=B8=EC=97=90=20=EC=9E=85=EB=A0=A5=20=EA=B0=92?= =?UTF-8?q?=EB=8F=84=20=EB=84=98=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 64 +++++++++++++++++--- M02_MasterTemplete/CostFlow/M02_CF_Entry.ts | 10 ++- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 1daa1b757..b0daf8a6a 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -7,7 +7,7 @@ * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * ========================================================================== */ -import { t as L } from "@ui/ui_template_locale"; +import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; import { createButton, el, @@ -75,6 +75,50 @@ interface Opened extends Draft { const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); + +/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */ +function trialMenu(items: HTMLElement[]): HTMLElement { + const list = el("div", { attrs: { role: "menu" }, children: items }); + Object.assign(list.style, { + position: "absolute", + top: "calc(100% + 4px)", + left: "0", + zIndex: "20", + display: "none", + flexDirection: "column", + gap: "var(--spacing-4)", + padding: "var(--spacing-8)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-lg)", + background: "var(--color-surface-raised)", + boxShadow: "var(--shadow-lg)", + whiteSpace: "nowrap", + }); + const shut = (): void => { + list.style.display = "none"; + button.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", outside, true); + }; + const outside = (ev: Event): void => { + if (!wrap.contains(ev.target as Node)) shut(); + }; + const button = createButton({ + label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0], + variant: "ghost", + onClick: () => { + if (list.style.display !== "none") return shut(); + list.style.display = "flex"; + button.setAttribute("aria-expanded", "true"); + document.addEventListener("click", outside, true); + }, + }); + button.setAttribute("aria-haspopup", "menu"); + list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶 + const wrap = el("div", { children: [button, list] }); + Object.assign(wrap.style, { position: "relative", display: "inline-flex" }); + wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut()); + return wrap; +} const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -406,14 +450,16 @@ export async function mountM01LogicLab( el("h2", { text: tx("Title") }), pending, createButton({ label: tn("Open"), variant: "filled", onClick: startNew }), - // 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createEasyCalcButton(), - // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createLogicBuilderButton(), - // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createSheetLogicButton(), - // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 - createCostFlowButton(), + trialMenu([ + // 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createEasyCalcButton(), + // 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createLogicBuilderButton(), + // 시트 로직 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createSheetLogicButton(), + // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createCostFlowButton(), + ]), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts index 8e80d6524..d28a72125 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Entry.ts @@ -447,7 +447,15 @@ function openCostFlow(): void { deleteButton.hidden = !lg.key; M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected }); drawPanel(); - M.summary.renderSummary(summaryHost, lg, result, { change: edit }); + // 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임 + // ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠) + (M.summary.renderSummary as (...args: unknown[]) => void)( + summaryHost, + lg, + result, + { change: edit }, + { values }, + ); note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n"); }; From aacd21dd7c7cb719fba4c129bd99a149a68eeba3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:49:20 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix(m02-cf):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EB=8B=A8=EC=9C=84=EB=A5=BC=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EB=B3=80=EC=88=98=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EC=97=B4=20?= =?UTF-8?q?=EB=95=8C=20=EB=B0=94=EB=A1=9C=EC=9E=A1=EC=9D=8C(=EA=B2=AC?= =?UTF-8?q?=EB=B3=B8=20=EC=9D=B8=EB=A0=A5=20=EC=83=81=EC=9E=90=20=EC=9B=90?= =?UTF-8?q?/=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../CostFlow/M02_CF_InputPanel.ts | 4 +++ .../CostFlow/M02_CF_InputPanel_Build.ts | 10 ++++++ .../tester/test_m02_cf_inputpanel_build.py | 33 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts index 168a14005..bcaed88ba 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel.ts @@ -31,6 +31,7 @@ import { elementOf, machineFields, queryOf, + reconcileUnit, removeElement, setKind, valueOf, @@ -143,6 +144,9 @@ export function renderInputPanel( host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") })); return; } + // 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감 + const fixed = reconcileUnit(lg, boxId); + if (fixed !== lg) return on.change(fixed); const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] }; const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력"); const body = el("div", { className: "m02-cfip__body" }); diff --git a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts index ce2f0799b..1e392059b 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_InputPanel_Build.ts @@ -282,3 +282,13 @@ export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pi pills, ); } + +/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */ +export function reconcileUnit(lg: CfLogic, id: string): CfLogic { + const box = lg.boxes.find((b) => b.id === id); + if (!box?.input) return lg; + const want = unitOfInput(box.input); + return box.unit === want + ? lg + : { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) }; +} diff --git a/resources/tester/test_m02_cf_inputpanel_build.py b/resources/tester/test_m02_cf_inputpanel_build.py index 890b0639d..79aa5cb05 100644 --- a/resources/tester/test_m02_cf_inputpanel_build.py +++ b/resources/tester/test_m02_cf_inputpanel_build.py @@ -219,3 +219,36 @@ def test_환율_유가전력_알약_이름에_날짜가_안_들어감(): "값": 1900, } assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울" + + +def test_단위가_안_맞게_저장된_상자는_열_때_맞춤(): + el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2) + lg = logic() + lg["boxes"][0]["input"] = { + "mode": "group", + "group": "인력", + "elements": [el1, el2], + "variables": [], + } + assert lg["boxes"][0]["unit"] == "-" + fixed = run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0] + assert fixed["boxes"][0]["unit"] == "원/일" + again = run([{"op": "fn", "name": "reconcileUnit", "args": [fixed, "b1"]}])[0] + assert again == fixed + oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"} + lg["boxes"][0]["input"]["elements"].append(oil) + lg["boxes"][0]["unit"] = "원/일" + assert ( + run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] + == "-" + ) + one = { + "mode": "variable", + "elements": [], + "variables": [{"name": "면적", "type": "number", "unit": "㎡", "example": 1}], + } + lg["boxes"][0]["input"] = one + assert ( + run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"] + == "㎡" + ) From d297d3384e1ff4221e1a02ec9bc6935daee3a552 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:50:30 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix(m02-cf):=20=EA=B2=AC=EB=B3=B8=20?= =?UTF-8?q?=EC=9D=B8=EB=A0=A5=20=EC=9E=85=EB=A0=A5=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=9B=90/=EC=9D=BC=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=EB=8B=A8=EC=9C=84=EA=B0=80=20=EA=B0=99=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=83=81=EC=9E=90=20=EB=8B=A8=EC=9C=84=EB=A1=9C(?= =?UTF-8?q?=EC=8B=9C=ED=97=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/CostFlow/_sample/masonry.json | 2 +- resources/tester/test_m02_cf_sample.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/M02_MasterTemplete/CostFlow/_sample/masonry.json index 3182b5a6a..7ad4f5adb 100644 --- a/M02_MasterTemplete/CostFlow/_sample/masonry.json +++ b/M02_MasterTemplete/CostFlow/_sample/masonry.json @@ -86,7 +86,7 @@ "kind": "input", "name": "인력", "color": "#2f80ed", - "unit": "-", + "unit": "원/일", "x": 40, "y": 620, "input": { diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py index 863354316..1312cd508 100644 --- a/resources/tester/test_m02_cf_sample.py +++ b/resources/tester/test_m02_cf_sample.py @@ -124,6 +124,9 @@ def test_견본_모양(name: str) -> None: assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) if b["kind"] == "output": assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") + units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []} + if len(units) == 1: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙) + assert b["unit"] == next(iter(units)), b["id"] for link in lg["links"]: a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] assert link["from"]["name"] in _ports_out(a), link From 833e8e5772a896d8a952ef5f5986f1a4fd54ed81 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:51:27 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix(m02-cf):=20=EC=9B=90=EA=B0=80=20?= =?UTF-8?q?=ED=95=A9=EA=B3=84=20=E3=80=8C=EA=B3=A0=EC=B9=A0=20=EA=B2=83?= =?UTF-8?q?=E3=80=8D=20=EA=B2=80=EC=82=AC=EC=97=90=20=EB=B0=9B=EC=9D=80=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B0=92=EC=9D=84=20=EB=84=98=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderSummary 다섯째 인자 opts.values 를 checkLogic 에 전달. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M02_MasterTemplete/CostFlow/M02_CF_Summary.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts index f0cfb9284..94164b86b 100644 --- a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts +++ b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts @@ -9,7 +9,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createSelectField, el } from "@ui/ui_template_elements"; import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { checkLogic, type CfEvalResult } from "./M02_CF_Eval"; +import { checkLogic, type CfEvalResult, type CfValues } from "./M02_CF_Eval"; import type { CfBox, CfLogic, CfVariable } from "./M02_CF_Model"; import { showValue } from "./M02_CF_OutputPanel"; import "./M02_CF_Summary.css"; @@ -109,12 +109,13 @@ function sampleField( return el("label", { className: "m01-cfsum__field", children: [label, control] }); } -/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 */ +/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */ export function renderSummary( host: HTMLElement, lg: CfLogic, result: CfEvalResult, on?: { change(lg: CfLogic): void }, + opts?: { values?: CfValues }, ): void { const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); const perUnit = lg.resultUnit.split("/").slice(1).join("/"); @@ -187,7 +188,7 @@ export function renderSummary( /* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */ let checked: string[] = []; try { - checked = checkLogic(lg); + checked = checkLogic(lg, opts?.values ?? {}); } catch { checked = []; } From 12a60baeeb8965eb14e615bd588b37a1ce681132 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:35:01 +0900 Subject: [PATCH 14/15] =?UTF-8?q?chore(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=ED=9D=90=EB=A6=84=EB=8F=84=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=ED=8C=90=20=EC=A0=95=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/흐름도_정함.md | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_정함.md diff --git a/docs/raw/M01_쉬운단가/흐름도_정함.md b/docs/raw/M01_쉬운단가/흐름도_정함.md new file mode 100644 index 000000000..25134226b --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_정함.md @@ -0,0 +1,38 @@ +# 단가산출 흐름도 시험판 — 정함 (PLAN 35-9 · 브레인 · 10-01 17시 무렵) + +사용자 지시(그대로): 「전체에 대해 너가 제어해서 시험판을 하나 만들어봐. 이때 이전에 검토하였던 단가산출 다이어그램(시험)을 제외한 다른 시험판 들은 삭제해줘. 이번에 만드는 시험판도 신규 폴더에 넣어서 작업해줘. 필요없으면 바로 삭제할수 있게. … 너가 끝까지 구현하고 검증까지 진행해봐. 다시말하지만 자유도 높고 내가 이해하기 쉬운 화면을 만드는게 중요해.」 · 「데이터의 흐름과 값의 연결에 집중해야해. 이것만 잘 연결되면 단가산출 로직들을 만드는건 쉽게 해결될거야.」 +계획 = PLAN.md 35-8(a~o) 전체 · 근거 = 같은 폴더 `원가다이어그램_정함.md`(1판 약속 — 그대로 이어받음). + +## 목적 + +값 하나가 **어디서 생겨(출처) · 어떤 길과 규칙을 지나(선 · 선 규칙 · 함수) · 어디로 가는지(도착)** 를 사용자가 자유롭게 만들고 · 한눈에 따라가고 · 고칠 수 있게. 원가 숫자가 아니라 이 흐름이 검증 기준. + +## 정함 + +1. 폴더 `M02_MasterTemplete/DataFlow/` · 파일 머리 `M02_DF_` · 서버 길 `/api/m02/df/*` · 키 `DF-0001` · 저장 · 임시 = `_data/`(git 밖) · 견본 = `_sample/` · 단위 · 금액 표준 처음 값 = `_standard/` · 폴더째 지우면 끝. 1판(CostFlow)을 옮겨 시작하되 import 로 기대지 않음(CostFlow 는 그대로 남음). +2. 들어가는 곳 = 단가산출 막대 「시험판 ▾」 목록의 「단가산출 흐름도(시험)」 · 전체 화면 페이지. +3. 옛 시험판 셋(EasyCalc · LogicBuilder · SheetLogic) 폴더 · 그 시험 파일 · 막대 목록 항목 · 서버 등록 줄을 지움(사용자 지시) · `로직_자체.json` 의 사용자 로직(GX)은 마스터 자료라 그대로. +4. **흐름 넷** — ① 출처: 입력 상자(그룹 요소 · 표 · 변수 · **재료 자리** · **다른 로직**) ② 길: 선(알약 → 받는 자리) + **선 규칙** 하나(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ③ 처리: 함수 상자(엑셀 · 출력 하나) ④ 도착: 출력 상자(비용 종류 · 값) · 다른 로직의 입력 · 「설계 때 정할 것」. +5. **재료 요소 방식 셋**(재료만) — 고정(품목 하나) · 선택 자리(범위 = 구분 · 상세구분 · 찾는 말 · 기본 품목 · 검증 칸 · 프로젝트에서 고름) · 조건 찾기(범위 + 「규격 / 이름에 [알약] 포함 / 같음」 · 맞음 0 · 여럿 알림) · 알약 이름 = 자리 이름. 다른 그룹은 상자를 늘림. +6. **다른 로직 입력** — 저장한 흐름도 로직(또는 견본)을 입력 상자로 불러 그 출력(비용 · 값)을 내보내는 알약으로 · 그 로직의 「설계 때 정할 것」(변수 · 재료 자리)은 이 상자의 받는 알약으로(이어 주지 않으면 그 로직의 예시값). +7. **선 규칙**(선 하나에 하나 · 없으면 그대로) — 표에서 찾기(조건 칸마다 맞출 알약 · 꺼낼 칸) · 조건 통과(알약 · 같음 · 다름 · 이상 · 이하 · 초과 · 미만 · 값 — 아니면 넘기지 않음) · 바꾸기(× · ÷ · +% · −% · 단위 바꾸기 계수) · 끝수(자리 · 반올림 / 올림 / 버림). 규칙은 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임 · 한 알약에서 나간 조건 선의 겹침 · 어느 것도 안 맞음을 알림. +8. **흐름 따라가기** — 알약 · 선을 고르면 그 값이 온 길(출처 사슬)과 가는 길을 다이어그램에서 칠하고 상세 내용에 줄로 · 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건 · 맞는 재료 없음)을 상자 · 선 위에 표시. +9. **상세 내용**(옛 「고른 상자」) — 상자 · 알약 · 선 무엇을 골라도 바뀜 · 머리 왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆에 알약 이름 · 칸 차례 = 머리 · 설정 · 본문 · 경고. +10. **단위는 고르기** — 치면 걸러진 목록 · 고르기는 사용자 · 손글 안 받음 · 목록 = 단위 표준 + 마스터 결과단위 + 원 · 「-」 = 없음. +11. **단위 표준 · 금액 단위 표준** — 산림품셈 1-2-2(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md`) 「설계서의 단위 및 소수의 표준」(종목 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③ · 소수자리 「-」 = 0 · 단위 「-」 = 없음) + 「금액의 단위표준」(총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 미만 버림) · 시험판 안에서 표로 보고 고침(사용자가 원문 흠을 고침 · 저장 `_data`). +12. **끝수** — 함수 출력 · 출력 상자 · 선 규칙에 소수자리 · 방법 · 단위를 고르면 단위 표준에서 자리 제안(주 ① 반올림 · ② 항목 값 우선) · 출력(비용) 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림. +13. **화면** — 자유 배치 다이어그램(위 · 크게) · 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자 선택 안 됨 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 아래 상세 내용 · 오른쪽 아래 검증 칸(예시값 + 재료 자리 + 다른 로직의 설계 때 정할 것) · 단추 · 칸은 공용 작은 크기(createButton size "sm" · 덮어쓰기 금지) · 쉬운 말 · 밝음 · 어두움. +14. 보고 · 검증 기준 = 흐름(값이 어디서 와서 · 어떤 규칙을 지나 · 어디로 가는지가 화면에서 따라가지는지 · 끊긴 곳이 보이는지 · 몇 걸음에 만드나). 원가 숫자는 내부 확인만. + +## 파일 · 창 + +| 창 | 맡음 | +| --- | --- | +| sub7 | 0걸음: CostFlow → DataFlow 옮기기(이름 · 길 · 키 · 폴더 · 시험 사본) · 그다음 Model · Eval 넓히기(정함 5 · 6 · 7 · 8 · 12 · 설계 때 정할 것 목록) — 타입 먼저 | +| sub4 | 서버(정함 5 · 6 · 11 · 12) — 재료 자리 · 조건 찾기 단가 · 자재 찾기(쪽 · 띄어쓰기 AND · 규격) · 다른 로직 값 · 설계 때 정할 것 · 표준 표 읽기 · 쓰기 · 저장 다시 셈의 끝수 | +| sub8 | 다이어그램(정함 7 · 8 · 13) — 선 위 규칙 글 · 선 고르기 · 흐름 길 칠하기 · 끊긴 곳 표시 · 마우스 | +| sub2 | 상세 내용 판(정함 7 · 9 · 10 · 12 · 13) — 선 규칙 판 · 알약 판 · 함수 판 손질(작은 단추) · 단위 고르기 공용 파일 `M02_DF_UnitPick.ts` · 끝수 고르기 | +| sub3 | 입력 판(정함 5 · 6 · 10) — 재료 방식 셋 · 범위 · 조건 · 기본 품목 · 자재 찾기 고침 · 다른 로직 입력 | +| sub5 | 출력 판 · 검증 칸(정함 5 · 6 · 11 · 12) — 재료 자리 고르기 · 다른 로직 설계 값 · 출력 끝수 · 금액 표준 기본 · 끊긴 곳 목록 | +| sub6 | 표준 표 · 견본(정함 11 · 견본) — `_standard/단위표준.json` · `금액단위표준.json` · 시험판 안 표 보기 · 고치기 판 · 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) | +| sub1 | 옛 시험판 지우기(정함 3) · 막대 목록 · 들어가는 페이지 · 상세 내용 머리 · 잇기 · 끝 검증(흐름 기준) | From 1930b80aa42446cc78526fa4a5b215cb886bbe1e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:36:12 +0900 Subject: [PATCH 15/15] =?UTF-8?q?chore(M01):=20=ED=9D=90=EB=A6=84=EB=8F=84?= =?UTF-8?q?=20=EC=A0=95=ED=95=A8=20=E2=80=94=20=EA=B3=B5=EC=9C=A0=20docs?= =?UTF-8?q?=20=EC=82=AC=EB=B3=B8=20=EB=8B=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/M01_쉬운단가/흐름도_정함.md | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/흐름도_정함.md diff --git a/docs/raw/M01_쉬운단가/흐름도_정함.md b/docs/raw/M01_쉬운단가/흐름도_정함.md new file mode 100644 index 000000000..25134226b --- /dev/null +++ b/docs/raw/M01_쉬운단가/흐름도_정함.md @@ -0,0 +1,38 @@ +# 단가산출 흐름도 시험판 — 정함 (PLAN 35-9 · 브레인 · 10-01 17시 무렵) + +사용자 지시(그대로): 「전체에 대해 너가 제어해서 시험판을 하나 만들어봐. 이때 이전에 검토하였던 단가산출 다이어그램(시험)을 제외한 다른 시험판 들은 삭제해줘. 이번에 만드는 시험판도 신규 폴더에 넣어서 작업해줘. 필요없으면 바로 삭제할수 있게. … 너가 끝까지 구현하고 검증까지 진행해봐. 다시말하지만 자유도 높고 내가 이해하기 쉬운 화면을 만드는게 중요해.」 · 「데이터의 흐름과 값의 연결에 집중해야해. 이것만 잘 연결되면 단가산출 로직들을 만드는건 쉽게 해결될거야.」 +계획 = PLAN.md 35-8(a~o) 전체 · 근거 = 같은 폴더 `원가다이어그램_정함.md`(1판 약속 — 그대로 이어받음). + +## 목적 + +값 하나가 **어디서 생겨(출처) · 어떤 길과 규칙을 지나(선 · 선 규칙 · 함수) · 어디로 가는지(도착)** 를 사용자가 자유롭게 만들고 · 한눈에 따라가고 · 고칠 수 있게. 원가 숫자가 아니라 이 흐름이 검증 기준. + +## 정함 + +1. 폴더 `M02_MasterTemplete/DataFlow/` · 파일 머리 `M02_DF_` · 서버 길 `/api/m02/df/*` · 키 `DF-0001` · 저장 · 임시 = `_data/`(git 밖) · 견본 = `_sample/` · 단위 · 금액 표준 처음 값 = `_standard/` · 폴더째 지우면 끝. 1판(CostFlow)을 옮겨 시작하되 import 로 기대지 않음(CostFlow 는 그대로 남음). +2. 들어가는 곳 = 단가산출 막대 「시험판 ▾」 목록의 「단가산출 흐름도(시험)」 · 전체 화면 페이지. +3. 옛 시험판 셋(EasyCalc · LogicBuilder · SheetLogic) 폴더 · 그 시험 파일 · 막대 목록 항목 · 서버 등록 줄을 지움(사용자 지시) · `로직_자체.json` 의 사용자 로직(GX)은 마스터 자료라 그대로. +4. **흐름 넷** — ① 출처: 입력 상자(그룹 요소 · 표 · 변수 · **재료 자리** · **다른 로직**) ② 길: 선(알약 → 받는 자리) + **선 규칙** 하나(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ③ 처리: 함수 상자(엑셀 · 출력 하나) ④ 도착: 출력 상자(비용 종류 · 값) · 다른 로직의 입력 · 「설계 때 정할 것」. +5. **재료 요소 방식 셋**(재료만) — 고정(품목 하나) · 선택 자리(범위 = 구분 · 상세구분 · 찾는 말 · 기본 품목 · 검증 칸 · 프로젝트에서 고름) · 조건 찾기(범위 + 「규격 / 이름에 [알약] 포함 / 같음」 · 맞음 0 · 여럿 알림) · 알약 이름 = 자리 이름. 다른 그룹은 상자를 늘림. +6. **다른 로직 입력** — 저장한 흐름도 로직(또는 견본)을 입력 상자로 불러 그 출력(비용 · 값)을 내보내는 알약으로 · 그 로직의 「설계 때 정할 것」(변수 · 재료 자리)은 이 상자의 받는 알약으로(이어 주지 않으면 그 로직의 예시값). +7. **선 규칙**(선 하나에 하나 · 없으면 그대로) — 표에서 찾기(조건 칸마다 맞출 알약 · 꺼낼 칸) · 조건 통과(알약 · 같음 · 다름 · 이상 · 이하 · 초과 · 미만 · 값 — 아니면 넘기지 않음) · 바꾸기(× · ÷ · +% · −% · 단위 바꾸기 계수) · 끝수(자리 · 반올림 / 올림 / 버림). 규칙은 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임 · 한 알약에서 나간 조건 선의 겹침 · 어느 것도 안 맞음을 알림. +8. **흐름 따라가기** — 알약 · 선을 고르면 그 값이 온 길(출처 사슬)과 가는 길을 다이어그램에서 칠하고 상세 내용에 줄로 · 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건 · 맞는 재료 없음)을 상자 · 선 위에 표시. +9. **상세 내용**(옛 「고른 상자」) — 상자 · 알약 · 선 무엇을 골라도 바뀜 · 머리 왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆에 알약 이름 · 칸 차례 = 머리 · 설정 · 본문 · 경고. +10. **단위는 고르기** — 치면 걸러진 목록 · 고르기는 사용자 · 손글 안 받음 · 목록 = 단위 표준 + 마스터 결과단위 + 원 · 「-」 = 없음. +11. **단위 표준 · 금액 단위 표준** — 산림품셈 1-2-2(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md`) 「설계서의 단위 및 소수의 표준」(종목 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③ · 소수자리 「-」 = 0 · 단위 「-」 = 없음) + 「금액의 단위표준」(총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 미만 버림) · 시험판 안에서 표로 보고 고침(사용자가 원문 흠을 고침 · 저장 `_data`). +12. **끝수** — 함수 출력 · 출력 상자 · 선 규칙에 소수자리 · 방법 · 단위를 고르면 단위 표준에서 자리 제안(주 ① 반올림 · ② 항목 값 우선) · 출력(비용) 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림. +13. **화면** — 자유 배치 다이어그램(위 · 크게) · 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자 선택 안 됨 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 아래 상세 내용 · 오른쪽 아래 검증 칸(예시값 + 재료 자리 + 다른 로직의 설계 때 정할 것) · 단추 · 칸은 공용 작은 크기(createButton size "sm" · 덮어쓰기 금지) · 쉬운 말 · 밝음 · 어두움. +14. 보고 · 검증 기준 = 흐름(값이 어디서 와서 · 어떤 규칙을 지나 · 어디로 가는지가 화면에서 따라가지는지 · 끊긴 곳이 보이는지 · 몇 걸음에 만드나). 원가 숫자는 내부 확인만. + +## 파일 · 창 + +| 창 | 맡음 | +| --- | --- | +| sub7 | 0걸음: CostFlow → DataFlow 옮기기(이름 · 길 · 키 · 폴더 · 시험 사본) · 그다음 Model · Eval 넓히기(정함 5 · 6 · 7 · 8 · 12 · 설계 때 정할 것 목록) — 타입 먼저 | +| sub4 | 서버(정함 5 · 6 · 11 · 12) — 재료 자리 · 조건 찾기 단가 · 자재 찾기(쪽 · 띄어쓰기 AND · 규격) · 다른 로직 값 · 설계 때 정할 것 · 표준 표 읽기 · 쓰기 · 저장 다시 셈의 끝수 | +| sub8 | 다이어그램(정함 7 · 8 · 13) — 선 위 규칙 글 · 선 고르기 · 흐름 길 칠하기 · 끊긴 곳 표시 · 마우스 | +| sub2 | 상세 내용 판(정함 7 · 9 · 10 · 12 · 13) — 선 규칙 판 · 알약 판 · 함수 판 손질(작은 단추) · 단위 고르기 공용 파일 `M02_DF_UnitPick.ts` · 끝수 고르기 | +| sub3 | 입력 판(정함 5 · 6 · 10) — 재료 방식 셋 · 범위 · 조건 · 기본 품목 · 자재 찾기 고침 · 다른 로직 입력 | +| sub5 | 출력 판 · 검증 칸(정함 5 · 6 · 11 · 12) — 재료 자리 고르기 · 다른 로직 설계 값 · 출력 끝수 · 금액 표준 기본 · 끊긴 곳 목록 | +| sub6 | 표준 표 · 견본(정함 11 · 견본) — `_standard/단위표준.json` · `금액단위표준.json` · 시험판 안 표 보기 · 고치기 판 · 견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) | +| sub1 | 옛 시험판 지우기(정함 3) · 막대 목록 · 들어가는 페이지 · 상세 내용 머리 · 잇기 · 끝 검증(흐름 기준) |