diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index c9faeac41..a74a98bb9 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -41,6 +41,8 @@ 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 "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ @@ -447,6 +449,8 @@ export async function mountM01LogicLab( trialMenu([ // 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움 createCostFlowButton(), + // 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움 + createDataFlowButton(), ]), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css index 3b453c20b..0047e4863 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.css @@ -67,12 +67,6 @@ padding: var(--spacing-16) var(--spacing-24) var(--spacing-24); } -.m02-df__big { - min-height: 56px; - padding: 0 var(--spacing-24); - font-size: var(--text-body); -} - /* 시작 화면 */ .m02-df__start { display: grid; @@ -218,3 +212,72 @@ background: none; cursor: pointer; } + +/* 상세 내용 — 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약 이름) · 설정 · 본문 · 경고 */ +.m02-df__detail-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m02-df__detail-head h3 { + margin: 0; + padding: 0; + border: 0; +} + +.m02-df__detail-name { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: flex-end; + gap: var(--spacing-8); +} + +.m02-df__detail-pill { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-df__detail-row { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) 0; + border-bottom: 1px dashed var(--color-border); +} + +.m02-df__detail-row:last-child { + border-bottom: 0; +} + +.m02-df__detail-row h4 { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-df__detail-warn { + margin: 0; + padding-left: var(--spacing-16); + font-size: var(--text-body-sm); +} + +.m02-df__detail-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-4) var(--spacing-12); + margin: 0; +} + +.m02-df__detail-dl dd { + margin: 0; +} + +.m02-df__detail-trace { + margin: 0; + padding-left: var(--spacing-16); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts index 7fa2de71a..5f35149da 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry.ts @@ -1,11 +1,11 @@ /* ============================================================================= * M02_DF_Entry.ts - * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 - * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 + * 단가산출 흐름도(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md`(1판 약속 = 원가다이어그램_정함.md) · 이 폴더째 지워도 됨 * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게. - * 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary - * → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S. - * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). + * 잇기: 열기 → 입력 값(cfValues) → renderDiagram · 「상세 내용」(상자 · 알약 · 선 고름마다 판을 바꿔 끼움 — + * M02_DF_Entry_Detail) · renderSummary → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장(서버 대조) · 지우기 · + * 실행 취소 · Ctrl+Z · Ctrl+S. 단추 · 칸은 공용 작은 크기. 바깥 페이지 하나만 세로로 밀림. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -23,6 +23,14 @@ import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { CfListItem, CfQuery } from "./M02_DF_Api"; import type { CfEvalResult, CfValues } from "./M02_DF_Eval"; import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model"; +import { + detailFrame, + drawHead, + drawWarnings, + lineBody, + pillBody, + type DfPick, +} from "./M02_DF_Entry_Detail"; import "./M02_DF_Entry.css"; /** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ @@ -37,7 +45,8 @@ const loadMods = () => import("./M02_DF_Summary"), import("./M02_DF_Sample"), import("./M02_DF_InputPanel"), - ]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({ + import("./M02_DF_Rule"), + ]).then(([model, ev, api, diagram, func, output, summary, sample, input, rule]) => ({ model, ev, api, @@ -47,12 +56,13 @@ const loadMods = () => summary, sample, input, + rule, })); type Mods = Awaited>; const TEXT = { - Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], - Title: ["단가산출 다이어그램", "Cost diagram"], + Open: ["단가산출 흐름도(시험)", "Cost flow (trial)"], + Title: ["단가산출 흐름도", "Cost flow"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], @@ -69,8 +79,6 @@ const TEXT = { Delete: ["지우기", "Delete"], Unsaved: ["저장 안 함", "Not saved"], Diagram: ["다이어그램", "Diagram"], - Box: ["고른 상자", "Chosen box"], - PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"], Summary: ["원가", "Cost"], Color: ["상자 색", "Box colour"], NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"], @@ -164,7 +172,7 @@ async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; er /** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ export function createDataFlowButton(): HTMLButtonElement { - return createButton({ label: te("Open"), variant: "filled", onClick: openDataFlow }); + return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow }); } function openDataFlow(): void { @@ -181,6 +189,7 @@ function openDataFlow(): void { const close = createButton({ label: te("Close"), variant: "ghost", + size: "sm", onClick: () => void (async () => { if (await mayLeave()) paper ? void showStart() : shut(); @@ -317,7 +326,8 @@ function openDataFlow(): void { let lg = first; let savedLg: CfLogic | null = saved ? first : null; let stack: CfLogic[] = []; - let selected: string | null = null; + let pick: DfPick = null; + const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); let values: CfValues = {}; let valueSig = ""; let notes: string[] = []; @@ -327,14 +337,14 @@ function openDataFlow(): void { const unsaved = createTag(te("Unsaved"), "warning"); const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); const diagramHost = el("div"); - const pickHint = muted(te("PickBox")); // 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나 const colorBox = el("input", { className: "m02-df__color-input", attrs: { type: "color", "aria-label": te("Color") }, }); colorBox.addEventListener("change", () => { - if (selected) edit(M.model.setColor(lg, selected, colorBox.value)); + const id = pickedBox(); + if (id) edit(M.model.setColor(lg, id, colorBox.value)); }); const colorRow = el("label", { className: "m02-df__color", @@ -345,6 +355,11 @@ function openDataFlow(): void { const outHost = el("div"); const inHost = el("div"); const summaryHost = el("div"); + const lineHost = el("div"); + const pillHost = el("div"); + const detail = detailFrame(); + detail.settings.append(colorRow); + detail.body.append(inHost, funcHost, outHost, lineHost, pillHost); const evalNow = (): CfEvalResult => { try { @@ -403,7 +418,16 @@ function openDataFlow(): void { const diagramOn = { select: (id: string | null): void => { - selected = id; + pick = !id + ? null + : lg.links.some((l) => l.id === id) + ? { kind: "link", id } + : { kind: "box", id }; + drawPanel(); + }, + /** 알약 고름 — 다이어그램(sub8)이 알려 주면 상세 내용이 알약 판으로 */ + pill: (p: { box: string; name: string; dir: "in" | "out" } | null): void => { + pick = p ? { kind: "pill", ...p } : null; drawPanel(); }, move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), @@ -416,19 +440,50 @@ function openDataFlow(): void { 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; + pick = { kind: "box", id: got.box.id }; editFromDiagram(got.lg); }, remove: (id: string): void => { - if (selected === id) selected = null; + if (pickedBox() === id) pick = null; editFromDiagram(M.model.removeBox(lg, id)); }, }; const drawPanel = (): void => { - const box = lg.boxes.find((b) => b.id === selected); - pickHint.hidden = !!box; + 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); colorRow.hidden = !box; + lineHost.hidden = pick?.kind !== "link"; + pillHost.hidden = pick?.kind !== "pill"; + if (pick?.kind === "link") { + const linkId = pick.id; + const rule = lg.links.find((l) => l.id === linkId)?.rule; + let text: string | null = null; + try { + text = rule ? M.rule.ruleLabel(rule) : null; + } catch { + text = rule ? rule.kind : null; + } + lineBody(lineHost, lg, pick.id, text); + } + if (pick?.kind === "pill") { + const port = { box: pick.box, name: pick.name }; + pillBody( + pillHost, + lg, + () => M.model.traceBack(lg, port), + () => M.model.traceForward(lg, port), + ); + } + let checks: string[] = []; + try { + checks = M.ev.checkLogic(lg, values); + } catch { + checks = []; + } + drawWarnings(detail, lg, pick, [...checks, ...(result?.errors ?? [])]); if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; funcHost.hidden = box?.kind !== "function"; outHost.hidden = box?.kind !== "output"; @@ -445,6 +500,8 @@ function openDataFlow(): void { unsaved.hidden = lg === savedLg; undo.disabled = !stack.length; deleteButton.hidden = !lg.key; + const selected = + pick?.kind === "box" || pick?.kind === "link" ? pick.id : (pick?.box ?? null); M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected }); drawPanel(); // 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임 @@ -517,10 +574,11 @@ function openDataFlow(): void { } }; - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); + const undo = createButton({ label: te("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); const deleteButton = createButton({ label: te("Delete"), variant: "danger", + size: "sm", onClick: () => void doDelete(), }); strip.replaceChildren( @@ -531,7 +589,7 @@ function openDataFlow(): void { className: "m02-df__actions", children: [ undo, - createButton({ label: te("Save"), onClick: () => void save() }), + createButton({ label: te("Save"), size: "sm", onClick: () => void save() }), deleteButton, ], }), @@ -544,10 +602,7 @@ function openDataFlow(): void { part(te("Diagram"), "m02-df__diagram", [diagramHost]), el("div", { className: "m02-df__lower", - children: [ - part(te("Box"), "m02-df__box", [pickHint, colorRow, inHost, funcHost, outHost]), - part(te("Summary"), "m02-df__summary", [summaryHost]), - ], + children: [detail.root, part(te("Summary"), "m02-df__summary", [summaryHost])], }), ], }), @@ -573,7 +628,5 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl } function bigButton(label: string, onClick: () => void): HTMLButtonElement { - const button = createButton({ label, variant: "filled", onClick }); - button.classList.add("m02-df__big"); - return button; + return createButton({ label, variant: "filled", size: "sm", onClick }); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts new file mode 100644 index 000000000..be13d2dd1 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail.ts @@ -0,0 +1,183 @@ +/* ============================================================================= + * M02_DF_Entry_Detail.ts + * 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름) + * · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움. + * 선 · 알약 본문은 규칙 판 · 알약 판(sub2)이 오기 전까지 이 파일이 흐름 줄(보내는 곳 → 받는 곳 · 출처 사슬 · 가는 곳)만 보임. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import type { CfLogic, CfPort, CfStep } from "./M02_DF_Model"; + +const TEXT = { + Detail: ["상세 내용", "Details"], + Settings: ["설정", "Settings"], + Body: ["본문", "Content"], + Warn: ["경고", "Warnings"], + WarnNone: ["경고 없음", "No warnings"], + PickSome: [ + "다이어그램에서 상자 · 알약 · 선을 고르면 여기에 그 상세 내용이 보임", + "Choose a box, pill or line in the diagram", + ], + Line: ["선", "Line"], + From: ["보내는 곳", "From"], + To: ["받는 곳", "To"], + Rule: ["선 규칙", "Line rule"], + RuleNone: ["없음 — 값을 그대로 보냄", "None — sends the value as is"], + Back: ["온 길(출처)", "Came from"], + Forward: ["가는 길", "Goes to"], + NoTrace: ["아직 따라갈 수 없음", "Can't trace yet"], + Nowhere: ["이어진 곳 없음", "Not connected"], + In: ["받는 알약", "Incoming pill"], + Out: ["내보내는 알약", "Outgoing pill"], +} as const; + +const tt = (key: keyof typeof TEXT): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; + +/** 고른 것 — 상자 · 선 · 알약 */ +export type DfPick = + | { kind: "box"; id: string } + | { kind: "link"; id: string } + | { kind: "pill"; box: string; name: string; dir: "in" | "out" } + | null; + +export interface DetailParts { + root: HTMLElement; + title: HTMLElement; + pill: HTMLElement; + hint: HTMLElement; + settings: HTMLElement; + body: HTMLElement; + warn: HTMLElement; +} + +const row = (label: string, host: HTMLElement): HTMLElement => + el("div", { + className: "m02-df__detail-row", + children: [el("h4", { text: label }), host], + }); + +/** 상세 내용 칸 — 머리 + 설정 · 본문 · 경고 */ +export function detailFrame(): DetailParts { + const title = el("strong", { className: "m02-df__detail-title" }); + const pill = el("span", { className: "m02-df__detail-pill" }); + const hint = el("p", { className: "m02-df__muted", text: tt("PickSome") }); + const settings = el("div", { className: "m02-df__detail-settings" }); + const body = el("div", { className: "m02-df__detail-body" }); + const warn = el("ul", { className: "m02-df__detail-warn" }); + const root = el("section", { + className: "m02-df__part m02-df__detail", + children: [ + el("div", { + className: "m02-df__detail-head", + children: [ + el("h3", { text: tt("Detail") }), + el("div", { className: "m02-df__detail-name", children: [title, pill] }), + ], + }), + el("div", { + className: "m02-df__part-body", + children: [ + hint, + row(tt("Settings"), settings), + row(tt("Body"), body), + row(tt("Warn"), warn), + ], + }), + ], + }); + return { root, title, pill, hint, settings, body, warn }; +} + +const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id; +const portText = (lg: CfLogic, p: CfPort): string => `${boxName(lg, p.box)} · ${p.name}`; + +/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */ +export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void { + d.hint.hidden = !!pick; + for (const r of [d.settings, d.body, d.warn]) (r.parentElement as HTMLElement).hidden = !pick; + const link = pick?.kind === "link" ? lg.links.find((l) => l.id === pick.id) : undefined; + d.title.textContent = + pick?.kind === "box" + ? boxName(lg, pick.id) + : pick?.kind === "pill" + ? boxName(lg, pick.box) + : link + ? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}` + : ""; + d.pill.textContent = + pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.name}` : ""; + d.pill.hidden = pick?.kind !== "pill"; +} + +/** 경고 — 화면 계산 · 검사 글 가운데 고른 것(상자 이름 · 알약 이름)이 든 줄만 */ +export function drawWarnings(d: DetailParts, lg: CfLogic, pick: DfPick, all: string[]): void { + const names: string[] = []; + if (pick?.kind === "box") names.push(`「${boxName(lg, pick.id)}」`); + if (pick?.kind === "pill") names.push(`「${boxName(lg, pick.box)}」`); + if (pick?.kind === "link") { + const l = lg.links.find((x) => x.id === pick.id); + if (l) names.push(`「${boxName(lg, l.from.box)}」`, `「${boxName(lg, l.to.box)}」`); + } + const mine = [...new Set(all)].filter( + (w) => names.some((n) => w.includes(n)) && (pick?.kind !== "pill" || w.includes(pick.name)), + ); + d.warn.replaceChildren( + ...(mine.length + ? mine.map((w) => el("li", { text: w })) + : [el("li", { className: "m02-df__muted", text: tt("WarnNone") })]), + ); +} + +/** 선 본문 — 보내는 곳 → 받는 곳 · 선 규칙 글(규칙 판이 오면 그쪽이 그림) */ +export function lineBody( + host: HTMLElement, + lg: CfLogic, + id: string, + ruleText: string | null, +): void { + const l = lg.links.find((x) => x.id === id); + if (!l) return void host.replaceChildren(); + host.replaceChildren( + el("dl", { + className: "m02-df__detail-dl", + children: [ + el("dt", { text: tt("From") }), + el("dd", { text: portText(lg, l.from) }), + el("dt", { text: tt("To") }), + el("dd", { text: portText(lg, l.to) }), + el("dt", { text: tt("Rule") }), + el("dd", { text: ruleText ?? tt("RuleNone") }), + ], + }), + ); +} + +/** 알약 본문 — 온 길(출처 사슬) · 가는 길 · 흐름 따라가기(Model traceBack · traceForward)가 아직이면 한 줄 */ +export function pillBody( + host: HTMLElement, + lg: CfLogic, + back: () => CfStep[], + forward: () => CfStep[], +): void { + const list = (steps: () => CfStep[]): HTMLElement => { + let got: CfStep[]; + try { + got = steps(); + } catch { + return el("p", { className: "m02-df__muted", text: tt("NoTrace") }); + } + if (!got.length) return el("p", { className: "m02-df__muted", text: tt("Nowhere") }); + return el("ol", { + className: "m02-df__detail-trace", + children: got.map((s) => el("li", { text: portText(lg, { box: s.box, name: s.port }) })), + }); + }; + host.replaceChildren( + el("h5", { text: tt("Back") }), + list(back), + el("h5", { text: tt("Forward") }), + list(forward), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index 34b1a7b72..e268be002 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -37,6 +37,8 @@ import { valueOf, type M01Row, } from "./M02_DF_InputPanel_Build"; +import { renderLogicInput } from "./M02_DF_InputPanel_Logic"; +import { renderMaterial } from "./M02_DF_InputPanel_Material"; import { renderTablePick } from "./M02_DF_InputPanel_Table"; import { renderVariables } from "./M02_DF_InputPanel_Vars"; import "./M02_DF_InputPanel.css"; @@ -62,6 +64,7 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise const TEXT = { Kind: ["무엇을 넣을까요?", "What goes in?"], Variable: ["변수", "Variable"], + Logic: ["다른 로직", "Another logic"], 인력: ["인력", "Labor"], 재료: ["재료", "Material"], 유가전력: ["유가·전력", "Fuel · power"], @@ -148,22 +151,26 @@ export function renderInputPanel( 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 kind: CfGroup | "변수" | "다른 로직" = + input.mode === "variable" + ? "변수" + : input.mode === "logic" + ? "다른 로직" + : (input.group ?? "인력"); const body = el("div", { className: "m02-dfip__body" }); - // ① 갈래 — 그룹 여덟 + 변수 + // ① 갈래 — 그룹 여덟 + 다른 로직 + 변수 const kinds = el("div", { className: "m02-dfip__chips", attrs: { role: "tablist", "aria-label": t("Kind") }, - children: [...GROUPS, "변수" as const].map((k) => { - const b = el("button", { - className: `m02-dfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`, - attrs: { type: "button", role: "tab" }, - text: k === "변수" ? t("Variable") : t(k), - }); - b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k))); - return b; - }), + children: [...GROUPS, "다른 로직" as const, "변수" as const].map((k) => + createButton({ + label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k), + variant: k === kind ? "filled" : "pill", + size: "sm", + onClick: () => k !== kind && on.change(setKind(lg, boxId, k)), + }), + ), }); const unit = el("p", { className: "m02-dfip__unit", @@ -172,7 +179,9 @@ export function renderInputPanel( host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body); if (kind === "변수") renderVariables(body, lg, boxId, on); + else if (kind === "다른 로직") renderLogicInput(body, lg, boxId, on); else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on); + else if (kind === "재료") renderMaterial(body, lg, boxId, on, (l, e) => addElement(l, boxId, e)); else renderGroup(body, lg, box, kind, on); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index c55f0fd3e..f6263d2ce 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -4,7 +4,19 @@ * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts` * ========================================================================== */ -import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_DF_Model"; +import type { + CfBox, + CfElementRef, + CfGroup, + CfInput, + CfLogic, + CfLogicRef, + CfMaterialMode, + CfMaterialRule, + CfPort, + CfScope, + CfVariable, +} from "./M02_DF_Model"; /** 입력 상자가 고르는 그룹 — 화면 차례 */ export const GROUPS: CfGroup[] = [ @@ -105,6 +117,7 @@ export function pillNames(box: CfBox): string[] { const i = box.input; if (!i) return []; if (i.table) return ["표"]; + if (i.mode === "logic") return (i.logic?.outputs ?? []).map((o) => o.name); return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name); } @@ -117,9 +130,153 @@ export function queryOf(group: CfGroup, ref: string): Record { return { group, ref }; } +/* ── 재료 요소 방식 셋 · 다른 로직 입력(브레인 정함 — sub7 Model 타입이 오면 그 타입으로 바꿈) ── */ + +/* ── 재료 요소 방식 셋 · 다른 로직 입력 — 타입은 Model(`CfMaterialMode` · `CfScope` · `CfMaterialRule` · `CfLogicRef`) ── */ + +/** 자재 한 품목 — 서버 `/api/m02/df/materials` 한 줄 */ +export interface MatItem { + ref: string; + name: string; + spec: string; + unit: string; + price: number | null; + 구분?: string; + 상세구분?: string; +} + +const squash = (v: unknown): string => + String(v ?? "") + .replace(/\s+/g, "") + .toLowerCase(); + +/** 규칙이 이 품목을 잡나 — value = 변수 알약 값 */ +export function matches( + item: Pick, + rule: Pick, + value: unknown, +): boolean { + const v = squash(value); + if (!v) return false; + const target = squash(rule.field === "규격" ? item.spec : item.name); + return rule.op === "같음" ? target === v : target.includes(v); +} + +/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */ +export function catchNote( + found: number, + firstName = "", +): { kind: "none" | "one" | "many"; text: string } { + if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" }; + if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` }; + return { + kind: "many", + text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`, + }; +} + +/** 쪽 범위 — total 개를 size 씩 · page 는 1 부터 · 앞 번호 · 뒤 번호(1 부터) */ +export function pageRange( + total: number, + page: number, + size: number, +): { from: number; to: number; pages: number } { + const pages = Math.max(1, Math.ceil(total / size)); + const p = Math.min(Math.max(1, page), pages); + return { from: total ? (p - 1) * size + 1 : 0, to: Math.min(total, p * size), pages }; +} + +/** 이 로직의 변수 알약 — 입력 상자 변수마다(규칙의 「알약」 고르기 목록) */ +export function variablePills( + lg: CfLogic, +): { port: CfPort; unit: string; example: CfVariable["example"] }[] { + return lg.boxes.flatMap((b) => + b.input?.mode === "variable" + ? b.input.variables.map((v) => ({ + port: { box: b.id, name: v.name }, + unit: v.unit, + example: v.example, + })) + : [], + ); +} + +/** 선택 자리 · 조건 찾기 요소 — ref 는 기본 품목(없으면 빈 글) */ +export function materialElement( + mode: CfMaterialMode, + name: string, + scope: CfScope, + extra: { rule?: CfMaterialRule; item?: Pick } = {}, +): CfElementRef { + const item = extra.item; + return { + ref: mode === "rule" ? "" : (item?.ref ?? ""), + name: name.trim(), + slot: name.trim(), + spec: item?.spec ?? "", + unit: item?.unit || "-", + mode, + scope: Object.fromEntries(Object.entries(scope).filter(([, v]) => v)) as CfScope, + ...(mode === "rule" && extra.rule ? { rule: extra.rule } : {}), + ...(mode === "choice" && item?.ref ? { default: item.ref } : {}), + }; +} + +/** 재료 요소를 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ +export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null { + if (!el.name) return "자리 이름을 적어 주세요"; + if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; + if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; + if (el.mode !== "fixed" && !Object.keys(el.scope ?? {}).length) + return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요"; + return null; +} + +/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리) */ +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 }[] = []; + 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 || "-" }); + } + const seen = new Set(); + return { + key, + 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}`))), + }; +} + +/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 그 알약에서 나간 선도 지움 */ +export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic { + return { + ...lg, + boxes: lg.boxes.map((b) => { + if (b.id !== id || !b.input) return b; + const input: CfInput = ref + ? { mode: "logic", elements: [], variables: [], logic: ref } + : { mode: "variable", elements: [], variables: [] }; + return { ...b, input, unit: unitOfInput(input) }; + }), + links: lg.links.filter((l) => l.from.box !== id), + }; +} + /** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */ export function unitOfInput(input: CfInput): string { if (input.table) return "-"; + if (input.mode === "logic") { + const u = (input.logic?.outputs ?? []).map((o) => o.unit || "-"); + return u.length && u.every((x) => x === u[0]) ? u[0] : "-"; + } const units = ( input.mode === "variable" ? input.variables.map((v) => v.unit) @@ -179,12 +336,19 @@ const touch = ( const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] }; /** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */ -export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic { +export function setKind( + lg: CfLogic, + id: string, + kind: CfGroup | "변수" | "다른 로직", + pills?: Pills, +): CfLogic { return touch( lg, id, (b) => { const cur = inputOf(b); + if (kind === "다른 로직") + return { ...b, input: { mode: "logic", elements: [], variables: [] }, unit: "-" }; if (kind === "변수") return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } }; const same = cur.mode === "group" && cur.group === kind; @@ -204,7 +368,7 @@ export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pi id, (b) => { const cur = inputOf(b); - return cur.elements.some((e) => e.ref === el.ref || e.name === el.name) + return cur.elements.some((e) => (el.ref !== "" && e.ref === el.ref) || e.name === el.name) ? b : { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } }; }, @@ -212,6 +376,19 @@ export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pi ); } +/** 이름으로 빼기 — 재료 조건 찾기 요소는 품목 키가 없어 이름(알약 이름)으로 가름 */ +export function removeElementNamed(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => ({ + ...b, + input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.name !== name) }, + }), + pills, + ); +} + export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic { return touch( lg, diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts new file mode 100644 index 000000000..a59e4e3d4 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * M02_DF_InputPanel_Logic.ts + * 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면 + * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) + * 글 [한, 영] 짝은 아래 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"; + +const TEXT = { + Help: [ + "저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀", + "Use a saved logic or sample as an input", + ], + Search: ["로직 이름으로 찾기", "Search by name"], + Loading: ["찾는 중…", "Searching…"], + None: ["찾는 로직 없음", "No logic found"], + Sample: ["견본", "Sample"], + Mine: ["내가 만든 것", "Mine"], + Chosen: ["고른 로직", "Chosen logic"], + 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"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; + +const plain = (e: unknown): string => { + try { + return cfPlain(e); + } catch { + return e instanceof Error ? e.message : t("Failed"); + } +}; + +/** 다른 로직 입력 판 */ +export function renderLogicInput( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, +): 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)), + ); + list.replaceChildren( + ...(rows.length + ? rows.map((i) => { + const b = el("button", { + className: "m02-dfip__row", + attrs: { type: "button" }, + children: [ + el("span", { + className: "m02-dfip__name", + children: [ + el("strong", { text: i.name }), + el("span", { + className: "m02-dfip__tag", + text: i.sample ? t("Sample") : t("Mine"), + }), + ], + }), + el("span", { className: "m02-dfip__muted", text: i.resultUnit }), + ], + }); + b.addEventListener("click", () => void pick(i)); + return b; + }) + : [el("p", { className: "m02-dfip__muted", text: t("None") })]), + ); + }; + const pick = async (i: CfListItem): Promise => { + try { + const other = await cfGet(i.key); + const next = setLogicInput(lg, boxId, logicRefOf(i.key, other)); + // 기본 이름이면 고른 로직 이름으로 + on.change({ + ...next, + boxes: next.boxes.map((b) => + b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b, + ), + }); + } catch (e) { + showToast(plain(e), "error"); + } + }; + q.input.addEventListener("input", draw); + 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()), + (e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })), + ); +} + +/** 고른 로직 — 내보내는 알약 · 받는 알약 */ +function showChosen( + host: HTMLElement, + lg: CfLogic, + boxId: string, + ref: NonNullable["logic"], + on: { change(lg: CfLogic): void }, +): void { + if (!ref) return; + const pillLine = (p: { name: string; unit: string }): 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 || "-" }), + ], + }); + host.replaceChildren( + el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }), + el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }), + ...(ref.outputs.length + ? ref.outputs.map(pillLine) + : [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]), + el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }), + ...(ref.designs.length + ? ref.designs.map(pillLine) + : [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]), + el("div", { + className: "m02-dfip__chips", + children: [ + createButton({ + label: t("Change"), + variant: "ghost", + size: "sm", + onClick: () => on.change(setKind(lg, boxId, "다른 로직")), + }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(setLogicInput(lg, boxId, null)), + }), + ], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts new file mode 100644 index 000000000..d24043210 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts @@ -0,0 +1,582 @@ +/* ============================================================================= + * M02_DF_InputPanel_Material.ts + * 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수) + * 서버 `/api/m02/df/materials` 를 먼저 부르고 아직 없으면 M01 `/pick?kind=price` 로 같은 모양을 만듦 + * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch"; +import { cfValues } from "./M02_DF_Api"; +import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model"; +import { + catchNote, + checkMaterial, + elementOf, + matches, + materialElement, + pageRange, + removeElementNamed, + variablePills, + type MatItem, +} from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"], + None: ["아직 고른 재료가 없음", "Nothing chosen yet"], + Remove: ["빼기", "Remove"], + Fixed: ["고정", "Fixed"], + Choice: ["선택 자리", "Choice slot"], + Rule: ["조건 찾기", "Find by rule"], + FixedHelp: ["품목 하나를 못 박음", "One fixed item"], + ChoiceHelp: [ + "자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)", + "A slot — pick the item per project", + ], + RuleHelp: [ + "변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관", + "Find by a variable pill", + ], + Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], + AllSub: ["전체", "All"], + SubLabel: ["구분", "Group"], + DetailLabel: ["상세구분", "Detail"], + Loading: ["찾는 중…", "Searching…"], + NoRow: ["찾는 재료 없음", "Nothing found"], + Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], + Prev: ["이전", "Prev"], + Next: ["다음", "Next"], + SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], + Scope: ["범위", "Scope"], + Word: ["찾는 말(범위 안에서)", "Word in scope"], + Default: ["기본 품목", "Default item"], + DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"], + DefaultClear: ["기본 품목 비움", "Clear"], + RuleField: ["찾는 칸", "Field"], + FieldSpec: ["규격", "Spec"], + FieldName: ["이름", "Name"], + RuleOp: ["조건", "Condition"], + OpIn: ["에 알약 값이 들어 있음", "contains the pill value"], + OpEq: ["이 알약 값과 같음", "equals the pill value"], + RulePill: ["변수 알약", "Variable pill"], + NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], + Put: ["넣기", "Add"], + Pick: ["넣기", "Add"], + Preview: ["지금 잡히는 재료", "Items caught now"], + Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"], + RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"], + ScopeLine: ["범위 {s}", "scope {s}"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); + +/* ── 자재 찾기 ── */ +export interface MatQuery { + q: string; + sub?: string; + detail?: string; + page: number; + size: number; +} +export interface MatPage { + total: number; + page: number; + size: number; + items: MatItem[]; +} + +async function getJson(path: string): Promise { + const res = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + if (!res.ok) throw new Error(String(res.status)); + return (await res.json()) as T; +} + +/** 서버가 없으면 M01 값 줄에서 같은 모양으로 — 띄어쓰기 AND(이름 + 규격) · 쪽은 앞에서 잘라 줌 */ +async function fallback(q: MatQuery): Promise { + const r = await getJson<{ items: Record[] }>( + `/m01/pick?${new URLSearchParams({ kind: "price", q: q.q, limit: "200" })}`, + ); + const rows = r.items.filter((i) => !q.sub || i["구분"] === q.sub); + const items = rows.map((i): MatItem => ({ + ref: String(i["ref"]), + name: String(i["이름"] ?? ""), + spec: String(i["규격"] ?? ""), + unit: String(i["단위"] ?? ""), + price: typeof i["값"] === "number" ? i["값"] : null, + 구분: String(i["구분"] ?? ""), + })); + const from = (q.page - 1) * q.size; + return { + total: items.length, + page: q.page, + size: q.size, + items: items.slice(from, from + q.size), + }; +} + +export async function searchMaterials(q: MatQuery): Promise { + const params = new URLSearchParams({ + q: q.q, + page: String(q.page), + size: String(q.size), + sub: q.sub ?? "", + detail: q.detail ?? "", + }); + try { + return await getJson(`/m02/df/materials?${params}`); + } catch { + return fallback(q); + } +} + +let subsCache: SubInfo[] | null = null; +async function materialSubs(): Promise { + if (subsCache) return subsCache; + const files = await fetchFiles("재료"); + const f = files.find((x) => x.file.includes("자재품목")) ?? files[0]; + subsCache = await fetchSubs({ file: f.file }); + return subsCache; +} + +/** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */ +function scopeBoxes( + init: { sub: string; detail: string }, + onChange: () => void, +): { root: HTMLElement; get(): { sub: string; detail: string } } { + const state = { ...init }; + const all = { value: "", text: t("AllSub") }; + const subBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("SubLabel"), + onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()), + }); + const detailBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("DetailLabel"), + onChange: (v) => ((state.detail = v), onChange()), + }); + let subs: SubInfo[] = []; + const fill = (): void => { + const d = subs.find((s) => s.name === state.sub)?.details ?? []; + detailBox.root.hidden = !d.length; + detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail); + }; + fill(); + void materialSubs().then( + (s) => { + subs = s; + subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub); + fill(); + }, + () => undefined, + ); + return { + root: el("div", { className: "m02-dfip__bar", children: [subBox.root, detailBox.root] }), + get: () => ({ ...state }), + }; +} + +/** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */ +function itemList( + onPick: (item: MatItem) => void, + base?: () => { sub: string; detail: string }, +): HTMLElement { + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); + const list = el("div", { className: "m02-dfip__list" }); + const info = el("span", { className: "m02-dfip__muted" }); + let page = 1; + let seq = 0; + const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load())); + const scope = (): { sub: string; detail: string } => (base ? base() : own!.get()); + const prev = createButton({ + label: t("Prev"), + variant: "ghost", + size: "sm", + onClick: () => ((page -= 1), void load()), + }); + const next = createButton({ + label: t("Next"), + variant: "ghost", + size: "sm", + onClick: () => ((page += 1), void load()), + }); + const load = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); + try { + const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 }); + if (mine !== seq) return; + const range = pageRange(r.total, page, 12); + info.textContent = t("Total", { + n: r.total, + a: range.from, + b: range.to, + p: Math.min(page, range.pages), + m: range.pages, + }); + prev.disabled = page <= 1; + next.disabled = page >= range.pages; + list.replaceChildren( + ...(r.items.length + ? r.items.map((it) => { + const b = el("button", { + className: "m02-dfip__row", + attrs: { type: "button" }, + children: [ + el("span", { + className: "m02-dfip__name", + children: [ + el("strong", { text: it.name }), + el("span", { className: "m02-dfip__muted", text: ` ${it.spec}` }), + ], + }), + el("span", { + className: "m02-dfip__muted", + text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(), + }), + ], + }); + b.addEventListener("click", () => onPick(it)); + return b; + }) + : [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]), + ); + } catch { + if (mine === seq) + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Failed") })); + } + }; + let timer = 0; + q.input.addEventListener( + "input", + () => ( + window.clearTimeout(timer), + (timer = window.setTimeout(() => ((page = 1), void load()), 250)) + ), + ); + void load(); + return el("div", { + className: "m02-dfip__find", + children: [ + ...(own ? [own.root] : []), + q.root, + info, + list, + el("div", { className: "m02-dfip__chips", children: [prev, next] }), + ], + }); +} + +/* ── 잡히는 재료 미리 보기 ── */ +export async function previewOf( + el1: CfElementRef, + lg: CfLogic, +): Promise<{ found: number; names: string[]; cheapest: MatItem | null }> { + const scope = el1.scope ?? {}; + const word = scope.찾는말 ?? ""; + if (el1.mode === "rule" && el1.rule) { + const pill = variablePills(lg).find( + (p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name, + ); + const value = pill?.example; + if (value === null || value === undefined || value === "") + return { found: 0, names: [], cheapest: null }; + const r = await searchMaterials({ + q: `${word} ${value}`.trim(), + sub: scope.구분, + detail: scope.상세구분, + page: 1, + size: 200, + }); + const hit = r.items.filter((i) => matches(i, el1.rule!, value)); + return { + found: hit.length, + names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), + cheapest: cheapestOf(hit), + }; + } + const r = await searchMaterials({ + q: word, + sub: scope.구분, + detail: scope.상세구분, + page: 1, + size: 200, + }); + return { + found: r.total, + names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), + cheapest: cheapestOf(r.items), + }; +} + +const cheapestOf = (items: MatItem[]): MatItem | null => + items + .filter((i) => i.price !== null) + .sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null; + +/** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */ +function summaryOf(e: CfElementRef, lg: CfLogic): string { + const parts: string[] = []; + const scope = Object.entries(e.scope ?? {}) + .map(([, v]) => v) + .join(" / "); + if (scope) parts.push(t("ScopeLine", { s: scope })); + if (e.rule) { + const p = variablePills(lg).find( + (x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name, + ); + parts.push( + t("RuleLine", { + f: e.rule.field, + o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"), + n: e.rule.from.name, + v: p?.example ?? "—", + }), + ); + } + return parts.join(" · "); +} + +/** 재료 판 — 넣은 재료 목록 + 방식 셋 */ +export function renderMaterial( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, + addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + const els = (box?.input?.elements ?? []) as CfElementRef[]; + const chosen = el("div", { className: "m02-dfip__chosen" }); + const body = el("div", { className: "m02-dfip__stack" }); + let mode: CfMaterialMode = "fixed"; + + const drawChosen = (): void => { + chosen.replaceChildren( + el("h5", { text: `${t("Chosen")} · ${els.length}` }), + ...(els.length + ? els.map((e) => { + const val = el("strong", { className: "m02-dfip__value", text: "—" }); + const note = el("span", { className: "m02-dfip__muted" }); + const kind = + e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed"); + const row = el("div", { + className: "m02-dfip__item", + children: [ + el("span", { + className: "m02-dfip__name", + children: [ + el("span", { className: "m02-dfip__tag", text: kind }), + el("strong", { text: ` ${e.name}` }), + el("div", { className: "m02-dfip__muted", text: summaryOf(e, lg) }), + note, + ], + }), + val, + el("span", { className: "m02-dfip__muted", text: e.unit || "-" }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(removeElementNamed(lg, boxId, e.name)), + }), + ], + }); + const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref; + if (ref) + void cfValues([{ id: ref, group: "재료", ref }] as never) + .then((a) => + a.forEach( + (x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)), + ), + ) + .catch(() => undefined); + if (e.mode && e.mode !== "fixed") + void previewOf(e, lg).then( + (p) => { + const n = catchNote(p.found, p.names[0]); + note.textContent = `${t("Preview")}: ${n.text}`; + note.classList.toggle("is-bad", n.kind !== "one"); + if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price); + }, + () => undefined, + ); + return row; + }) + : [el("p", { className: "m02-dfip__muted", text: t("None") })]), + ); + }; + + const chips = el("div", { className: "m02-dfip__chips" }); + const drawChips = (): void => + chips.replaceChildren( + ...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) => + createButton({ + label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"), + variant: mode === m ? "filled" : "pill", + size: "sm", + onClick: () => ((mode = m), drawChips(), drawBody()), + }), + ), + ); + + const drawBody = (): void => { + if (mode === "fixed") { + body.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("FixedHelp") }), + itemList((it) => + on.change( + addEl(lg, { + ...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }), + mode: "fixed", + }), + ), + ), + ); + return; + } + // 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기 + const name = createInputField({ + type: "text", + label: t("SlotName"), + compact: true, + placeholder: "예: 흄관", + }); + const word = createInputField({ type: "text", label: t("Word"), compact: true }); + let def: MatItem | null = null; + const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh()); + const note = el("p", { className: "m02-dfip__note" }); + const pills = variablePills(lg); + const fieldBox = createSelectField({ + label: t("RuleField"), + options: [ + { value: "규격", text: t("FieldSpec") }, + { value: "이름", text: t("FieldName") }, + ], + value: "규격", + compact: true, + onChange: () => void refresh(), + }); + const opBox = createSelectField({ + label: t("RuleOp"), + options: [ + { value: "포함", text: t("OpIn") }, + { value: "같음", text: t("OpEq") }, + ], + value: "포함", + compact: true, + onChange: () => void refresh(), + }); + const pillBox = createSelectField({ + label: t("RulePill"), + options: pills.map((p, i) => ({ + value: String(i), + text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, + })), + value: "0", + compact: true, + onChange: () => void refresh(), + }); + const defNote = el("p", { className: "m02-dfip__muted" }); + const build = (): CfElementRef => { + const sc = scope.get(); + const rule: CfMaterialRule | undefined = + mode === "rule" && pills[Number(pillBox.select.value)] + ? { + field: fieldBox.select.value as CfMaterialRule["field"], + op: opBox.select.value as CfMaterialRule["op"], + from: pills[Number(pillBox.select.value)].port, + } + : undefined; + return materialElement( + mode, + name.input.value, + { 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() }, + { rule, item: def ?? undefined }, + ); + }; + let seq = 0; + const refresh = async (): Promise => { + const mine = ++seq; + defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone"); + const cand = build(); + note.textContent = `${t("Preview")}: …`; + try { + const p = await previewOf(cand, lg); + if (mine !== seq) return; + const n = catchNote(p.found, p.names[0]); + note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`; + note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; + } catch { + if (mine === seq) note.textContent = t("Failed"); + } + }; + name.input.addEventListener("input", () => name.setError(undefined)); + word.input.addEventListener("input", () => void refresh()); + const add = createButton({ + label: t("Put"), + size: "sm", + onClick: () => { + const cand = build(); + const why = checkMaterial(cand, els); + name.setError(why ?? undefined); + if (why) return void showToast(why, "error"); + on.change(addEl(lg, cand)); + }, + }); + const clear = createButton({ + label: t("DefaultClear"), + variant: "ghost", + size: "sm", + onClick: () => ((def = null), void refresh()), + }); + body.replaceChildren( + el("p", { + className: "m02-dfip__muted", + text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"), + }), + name.root, + el("h5", { text: t("Scope") }), + scope.root, + word.root, + ...(mode === "rule" + ? pills.length + ? [fieldBox.root, opBox.root, pillBox.root] + : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })] + : [ + el("h5", { text: t("Default") }), + defNote, + clear, + itemList((it) => ((def = it), void refresh())), + ]), + note, + add, + ); + void refresh(); + }; + + host.replaceChildren(chosen, chips, body); + drawChosen(); + drawChips(); + drawBody(); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts b/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts index 73367bcaf..96bda504e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Sample.ts @@ -1,13 +1,15 @@ /* ============================================================================= * M02_DF_Sample.ts - * 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬) - * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비 - * ② 공구손료(비율) — 노무비 × 율 → 출력 재료비 - * ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값 + * 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬) + * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비 + * ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비 + * ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비 + * ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비 + * ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직 * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). - * 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」). - * 모양 = `M02_DF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port · - * output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨. + * 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음. + * 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함 + * `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -21,7 +23,18 @@ const NOTE: Record = { "변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비", "Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost", ], - rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material 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)", @@ -29,7 +42,7 @@ const NOTE: Record = { }; /** 보여 줄 차례 */ -const ORDER = ["masonry", "rate", "value"]; +const ORDER = ["masonry", "ground", "hume", "linked", "value"]; export interface CfSampleItem { /** 파일 이름(확장자 뺌) */ diff --git a/M02_MasterTemplete/DataFlow/_sample/ground.json b/M02_MasterTemplete/DataFlow/_sample/ground.json new file mode 100644 index 000000000..8e1be6bfb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_sample/ground.json @@ -0,0 +1,423 @@ +{ + "key": "SAMPLE-ground", + "name": "견본 · 지반에 따라 굴삭기 / 브레이커", + "resultUnit": "원/㎥", + "boxes": [ + { + "id": "in-ground", + "kind": "input", + "name": "지반 · 굴착량", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 60, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "지반", + "type": "choice", + "unit": "-", + "example": "토사", + "choices": ["토사", "암"] + }, + { + "name": "굴착량", + "type": "number", + "unit": "㎥", + "example": 100 + } + ] + } + }, + { + "id": "in-cost", + "kind": "input", + "name": "장비 시간당 경비", + "color": "#2f80ed", + "unit": "원/시간", + "x": 40, + "y": 300, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "굴삭기 시간당 경비", + "type": "number", + "unit": "원/시간", + "example": 60000 + }, + { + "name": "브레이커 시간당 경비", + "type": "number", + "unit": "원/시간", + "example": 80000 + } + ] + } + }, + { + "id": "fn-exc", + "kind": "function", + "name": "굴삭기로 파기", + "color": "#7b4fd6", + "unit": "원", + "x": 380, + "y": 40, + "func": { + "sheet": { + "id": "s-fn-exc", + "이름": "굴삭기로 파기", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "굴착량" + }, + "C2": { + "값": "받는 값 · 지반이 토사일 때만 옴" + }, + "A3": { + "값": "시간당 경비" + }, + "C3": { + "값": "받는 값 · 원/시간" + }, + "A4": { + "값": "시간당 작업량" + }, + "B4": { + "값": 30 + }, + "C4": { + "값": "㎥/시간 · 고침" + }, + "A5": { + "값": "작업 시간" + }, + "B5": { + "식": "B2/B4" + }, + "C5": { + "값": "시간" + }, + "A6": { + "값": "굴삭기 경비" + }, + "B6": { + "식": "B5*B3" + }, + "C6": { + "값": "원" + } + } + }, + "inputs": [ + { + "port": "굴착량", + "cell": "B2" + }, + { + "port": "시간당 경비", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B6", + "name": "굴삭기 경비", + "unit": "원" + } + } + }, + { + "id": "fn-brk", + "kind": "function", + "name": "브레이커로 깨기", + "color": "#7b4fd6", + "unit": "원", + "x": 380, + "y": 300, + "func": { + "sheet": { + "id": "s-fn-brk", + "이름": "브레이커로 깨기", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "굴착량" + }, + "C2": { + "값": "받는 값 · 지반이 토사가 아닐 때만 옴" + }, + "A3": { + "값": "시간당 경비" + }, + "C3": { + "값": "받는 값 · 원/시간" + }, + "A4": { + "값": "시간당 작업량" + }, + "B4": { + "값": 8 + }, + "C4": { + "값": "㎥/시간 · 고침" + }, + "A5": { + "값": "작업 시간" + }, + "B5": { + "식": "B2/B4" + }, + "C5": { + "값": "시간" + }, + "A6": { + "값": "브레이커 경비" + }, + "B6": { + "식": "B5*B3" + }, + "C6": { + "값": "원" + } + } + }, + "inputs": [ + { + "port": "굴착량", + "cell": "B2" + }, + { + "port": "시간당 경비", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B6", + "name": "브레이커 경비", + "unit": "원" + } + } + }, + { + "id": "fn-sum", + "kind": "function", + "name": "1㎥ 경비", + "color": "#7b4fd6", + "unit": "원/㎥", + "x": 720, + "y": 170, + "func": { + "sheet": { + "id": "s-fn-sum", + "이름": "1㎥ 경비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "굴삭기 경비" + }, + "C2": { + "값": "받는 값 · 안 오면 빈 칸 = 0" + }, + "A3": { + "값": "브레이커 경비" + }, + "C3": { + "값": "받는 값 · 안 오면 빈 칸 = 0" + }, + "A4": { + "값": "굴착량" + }, + "C4": { + "값": "받는 값 · ㎥" + }, + "A5": { + "값": "1㎥ 경비" + }, + "B5": { + "식": "(B2+B3)/B4" + }, + "C5": { + "값": "둘 중 온 것 ÷ 굴착량" + } + } + }, + "inputs": [ + { + "port": "굴삭기 경비", + "cell": "B2" + }, + { + "port": "브레이커 경비", + "cell": "B3" + }, + { + "port": "굴착량", + "cell": "B4" + } + ], + "variables": [], + "output": { + "cell": "B5", + "name": "1㎥ 경비", + "unit": "원/㎥" + } + } + }, + { + "id": "out-exp", + "kind": "output", + "name": "경비", + "color": "#27ae60", + "unit": "원/㎥", + "x": 1040, + "y": 170, + "output": { + "cost": "경비", + "unit": "원/㎥", + "round": { + "places": 1, + "method": "버림" + } + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-ground", + "name": "굴착량" + }, + "to": { + "box": "fn-exc", + "name": "굴착량" + }, + "rule": { + "kind": "gate", + "from": { + "box": "in-ground", + "name": "지반" + }, + "op": "=", + "value": "토사" + } + }, + { + "id": "l2", + "from": { + "box": "in-ground", + "name": "굴착량" + }, + "to": { + "box": "fn-brk", + "name": "굴착량" + }, + "rule": { + "kind": "gate", + "from": { + "box": "in-ground", + "name": "지반" + }, + "op": "≠", + "value": "토사" + } + }, + { + "id": "l3", + "from": { + "box": "in-cost", + "name": "굴삭기 시간당 경비" + }, + "to": { + "box": "fn-exc", + "name": "시간당 경비" + } + }, + { + "id": "l4", + "from": { + "box": "in-cost", + "name": "브레이커 시간당 경비" + }, + "to": { + "box": "fn-brk", + "name": "시간당 경비" + } + }, + { + "id": "l5", + "from": { + "box": "fn-exc", + "name": "굴삭기 경비" + }, + "to": { + "box": "fn-sum", + "name": "굴삭기 경비" + } + }, + { + "id": "l6", + "from": { + "box": "fn-brk", + "name": "브레이커 경비" + }, + "to": { + "box": "fn-sum", + "name": "브레이커 경비" + } + }, + { + "id": "l7", + "from": { + "box": "in-ground", + "name": "굴착량" + }, + "to": { + "box": "fn-sum", + "name": "굴착량" + } + }, + { + "id": "l8", + "from": { + "box": "fn-sum", + "name": "1㎥ 경비" + }, + "to": { + "box": "out-exp", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/DataFlow/_sample/hume.json b/M02_MasterTemplete/DataFlow/_sample/hume.json new file mode 100644 index 000000000..a9e9efadb --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_sample/hume.json @@ -0,0 +1,207 @@ +{ + "key": "SAMPLE-hume", + "name": "견본 · 흄관 관경 조건 찾기", + "resultUnit": "원/m", + "boxes": [ + { + "id": "in-size", + "kind": "input", + "name": "관경 · 연장", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 60, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "관경", + "type": "choice", + "unit": "mm", + "example": 600, + "choices": [400, 450, 500, 600, 700, 800, 900, 1000, 1100, 1200] + }, + { + "name": "연장", + "type": "number", + "unit": "m", + "example": 10 + } + ] + } + }, + { + "id": "in-pipe", + "kind": "input", + "name": "흄관", + "color": "#2f80ed", + "unit": "본", + "x": 40, + "y": 300, + "input": { + "mode": "group", + "group": "재료", + "variables": [], + "elements": [ + { + "ref": "MT014991", + "name": "흄관", + "spec": "Φ600mm", + "unit": "본", + "mode": "rule", + "scope": { + "구분": "관·이음관", + "상세구분": "흄관", + "찾는말": "접속흄관" + }, + "rule": { + "field": "규격", + "op": "포함", + "from": { + "box": "in-size", + "name": "관경" + } + }, + "default": "MT014991", + "slot": "흄관" + } + ] + } + }, + { + "id": "fn-pipe", + "kind": "function", + "name": "흄관 재료비", + "color": "#7b4fd6", + "unit": "원/m", + "x": 380, + "y": 160, + "func": { + "sheet": { + "id": "s-fn-pipe", + "이름": "흄관 재료비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "연장" + }, + "C2": { + "값": "받는 값 · m" + }, + "A3": { + "값": "흄관 단가" + }, + "C3": { + "값": "받는 값 · 원/본(관경으로 찾은 품목)" + }, + "A4": { + "값": "1본 길이" + }, + "B4": { + "값": 2.5 + }, + "C4": { + "값": "m · 고침" + }, + "A5": { + "값": "본수" + }, + "B5": { + "식": "ROUNDUP(B2/B4,0)" + }, + "C5": { + "값": "올림" + }, + "A6": { + "값": "1m 재료비" + }, + "B6": { + "식": "B5*B3/B2" + }, + "C6": { + "값": "본수 × 단가 ÷ 연장" + } + } + }, + "inputs": [ + { + "port": "연장", + "cell": "B2" + }, + { + "port": "흄관 단가", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B6", + "name": "1m 재료비", + "unit": "원/m" + } + } + }, + { + "id": "out-mat", + "kind": "output", + "name": "재료비", + "color": "#27ae60", + "unit": "원/m", + "x": 720, + "y": 160, + "output": { + "cost": "재료비", + "unit": "원/m", + "round": { + "places": 1, + "method": "버림" + } + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-size", + "name": "연장" + }, + "to": { + "box": "fn-pipe", + "name": "연장" + } + }, + { + "id": "l2", + "from": { + "box": "in-pipe", + "name": "흄관" + }, + "to": { + "box": "fn-pipe", + "name": "흄관 단가" + } + }, + { + "id": "l3", + "from": { + "box": "fn-pipe", + "name": "1m 재료비" + }, + "to": { + "box": "out-mat", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/DataFlow/_sample/linked.json b/M02_MasterTemplete/DataFlow/_sample/linked.json new file mode 100644 index 000000000..1538c4f5d --- /dev/null +++ b/M02_MasterTemplete/DataFlow/_sample/linked.json @@ -0,0 +1,302 @@ +{ + "key": "SAMPLE-linked", + "name": "견본 · 다른 로직 잇기(작업량 → 장비 시간)", + "resultUnit": "원/㎥", + "boxes": [ + { + "id": "in-logic", + "kind": "input", + "name": "덤프트럭 시간당 작업량", + "color": "#2f80ed", + "unit": "㎥/시간", + "x": 40, + "y": 60, + "input": { + "mode": "logic", + "elements": [], + "variables": [], + "logic": { + "key": "value", + "name": "견본 · 덤프트럭 시간당 작업량(값)", + "outputs": [ + { + "name": "시간당 작업량", + "unit": "㎥/시간" + } + ], + "designs": [ + { + "name": "q", + "unit": "㎥" + }, + { + "name": "f", + "unit": "-" + }, + { + "name": "E", + "unit": "-" + }, + { + "name": "Cm", + "unit": "분" + } + ] + } + } + }, + { + "id": "in-var", + "kind": "input", + "name": "운반량 · 경비", + "color": "#2f80ed", + "unit": "-", + "x": 40, + "y": 300, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "운반량", + "type": "number", + "unit": "㎥", + "example": 120 + }, + { + "name": "덤프트럭 시간당 경비", + "type": "number", + "unit": "원/시간", + "example": 50000 + } + ] + } + }, + { + "id": "fn-hours", + "kind": "function", + "name": "운반 시간", + "color": "#7b4fd6", + "unit": "시간", + "x": 380, + "y": 60, + "func": { + "sheet": { + "id": "s-fn-hours", + "이름": "운반 시간", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "시간당 작업량" + }, + "C2": { + "값": "받는 값 · 다른 로직 출력" + }, + "A3": { + "값": "운반량" + }, + "C3": { + "값": "받는 값 · ㎥" + }, + "A4": { + "값": "운반 시간" + }, + "B4": { + "식": "B3/B2" + }, + "C4": { + "값": "운반량 ÷ 시간당 작업량" + } + } + }, + "inputs": [ + { + "port": "시간당 작업량", + "cell": "B2" + }, + { + "port": "운반량", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "운반 시간", + "unit": "시간", + "round": { + "places": 2, + "method": "반올림" + } + } + } + }, + { + "id": "fn-cost", + "kind": "function", + "name": "1㎥ 운반 경비", + "color": "#7b4fd6", + "unit": "원/㎥", + "x": 700, + "y": 180, + "func": { + "sheet": { + "id": "s-fn-cost", + "이름": "1㎥ 운반 경비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "운반 시간" + }, + "C2": { + "값": "받는 값 · 시간" + }, + "A3": { + "값": "시간당 경비" + }, + "C3": { + "값": "받는 값 · 원/시간" + }, + "A4": { + "값": "운반량" + }, + "C4": { + "값": "받는 값 · ㎥" + }, + "A5": { + "값": "1㎥ 운반 경비" + }, + "B5": { + "식": "B2*B3/B4" + }, + "C5": { + "값": "시간 × 경비 ÷ 운반량" + } + } + }, + "inputs": [ + { + "port": "운반 시간", + "cell": "B2" + }, + { + "port": "시간당 경비", + "cell": "B3" + }, + { + "port": "운반량", + "cell": "B4" + } + ], + "variables": [], + "output": { + "cell": "B5", + "name": "1㎥ 운반 경비", + "unit": "원/㎥" + } + } + }, + { + "id": "out-exp", + "kind": "output", + "name": "경비", + "color": "#27ae60", + "unit": "원/㎥", + "x": 1020, + "y": 180, + "output": { + "cost": "경비", + "unit": "원/㎥", + "round": { + "places": 1, + "method": "버림" + } + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-logic", + "name": "시간당 작업량" + }, + "to": { + "box": "fn-hours", + "name": "시간당 작업량" + } + }, + { + "id": "l2", + "from": { + "box": "in-var", + "name": "운반량" + }, + "to": { + "box": "fn-hours", + "name": "운반량" + } + }, + { + "id": "l3", + "from": { + "box": "fn-hours", + "name": "운반 시간" + }, + "to": { + "box": "fn-cost", + "name": "운반 시간" + } + }, + { + "id": "l4", + "from": { + "box": "in-var", + "name": "덤프트럭 시간당 경비" + }, + "to": { + "box": "fn-cost", + "name": "시간당 경비" + } + }, + { + "id": "l5", + "from": { + "box": "in-var", + "name": "운반량" + }, + "to": { + "box": "fn-cost", + "name": "운반량" + } + }, + { + "id": "l6", + "from": { + "box": "fn-cost", + "name": "1㎥ 운반 경비" + }, + "to": { + "box": "out-exp", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/DataFlow/_sample/rate.json b/M02_MasterTemplete/DataFlow/_sample/rate.json deleted file mode 100644 index cf1ff8428..000000000 --- a/M02_MasterTemplete/DataFlow/_sample/rate.json +++ /dev/null @@ -1,146 +0,0 @@ -{ - "key": "SAMPLE-rate", - "name": "견본 · 공구손료(비율)", - "resultUnit": "원", - "boxes": [ - { - "id": "in-var", - "kind": "input", - "name": "바탕 값", - "color": "#2f80ed", - "unit": "-", - "x": 40, - "y": 80, - "input": { - "mode": "variable", - "elements": [], - "variables": [ - { - "name": "노무비", - "type": "number", - "unit": "원", - "example": 100000 - }, - { - "name": "공구손료율", - "type": "number", - "unit": "%", - "example": 3 - } - ] - } - }, - { - "id": "fn-rate", - "kind": "function", - "name": "공구손료", - "color": "#7b4fd6", - "unit": "원", - "x": 340, - "y": 80, - "func": { - "sheet": { - "id": "cf-tool", - "이름": "공구손료", - "칸": { - "A1": { - "값": "이름" - }, - "B1": { - "값": "값" - }, - "C1": { - "값": "설명" - }, - "A2": { - "값": "노무비" - }, - "C2": { - "값": "받는 값 · 원" - }, - "A3": { - "값": "공구손료율" - }, - "C3": { - "값": "받는 값 · %" - }, - "A4": { - "값": "공구손료" - }, - "B4": { - "식": "B2*B3/100" - }, - "C4": { - "값": "노무비 × 율 ÷ 100" - } - } - }, - "inputs": [ - { - "port": "노무비", - "cell": "B2" - }, - { - "port": "공구손료율", - "cell": "B3" - } - ], - "variables": [], - "output": { - "cell": "B4", - "name": "공구손료", - "unit": "원" - } - } - }, - { - "id": "out-mat", - "kind": "output", - "name": "재료비", - "color": "#27ae60", - "unit": "원", - "x": 640, - "y": 80, - "output": { - "cost": "재료비", - "unit": "원" - } - } - ], - "links": [ - { - "id": "l1", - "from": { - "box": "in-var", - "name": "노무비" - }, - "to": { - "box": "fn-rate", - "name": "노무비" - } - }, - { - "id": "l2", - "from": { - "box": "in-var", - "name": "공구손료율" - }, - "to": { - "box": "fn-rate", - "name": "공구손료율" - } - }, - { - "id": "l3", - "from": { - "box": "fn-rate", - "name": "공구손료" - }, - "to": { - "box": "out-mat", - "name": "값" - } - } - ], - "userElements": [] -} diff --git a/main.py b/main.py index a499660bc..33befc4bb 100644 --- a/main.py +++ b/main.py @@ -643,6 +643,11 @@ app.include_router( __import__("M02_MasterTemplete.CostFlow.M02_CF_Router", fromlist=["router"]).router, dependencies=system_admin_only, ) +# 단가산출 흐름도 시험 길 — 폴더째 지우면 이 줄도 지움 +app.include_router( + __import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router, + dependencies=system_admin_only, +) # 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서). app.include_router(m02_master_template_router, dependencies=system_admin_only) app.include_router(m02_layers_router, dependencies=protected) diff --git a/resources/tester/test_m02_df_inputpanel_material.py b/resources/tester/test_m02_df_inputpanel_material.py new file mode 100644 index 000000000..43355562d --- /dev/null +++ b/resources/tester/test_m02_df_inputpanel_material.py @@ -0,0 +1,246 @@ +"""단가산출 흐름도 입력 판 — 재료 요소 방식 셋 · 쪽 넘기기 · 다른 로직 입력(`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; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(jobs.map((j) => m[j.name](...j.args)))); +""" + + +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] + + +PLAIN = {"color": "#fff", "unit": "-", "x": 0, "y": 0} + + +def logic() -> dict: + return { + "key": None, + "name": "견본", + "resultUnit": "원/㎡", + "userElements": [], + "boxes": [ + { + "id": "b1", + "kind": "input", + "name": "입력", + **PLAIN, + "input": {"mode": "variable", "elements": [], "variables": []}, + }, + { + "id": "b2", + "kind": "input", + "name": "관경", + **PLAIN, + "input": { + "mode": "variable", + "elements": [], + "variables": [{"name": "관경", "type": "text", "unit": "", "example": "300"}], + }, + }, + ], + "links": [], + } + + +HUME = {"name": "각형흄관", "spec": "D=300mm"} + + +def test_규칙이_품목을_잡음_규격_이름_포함_같음(): + in_spec = {"field": "규격", "op": "포함"} + assert call("matches", HUME, in_spec, "300") is True + assert call("matches", HUME, in_spec, "400") is False + assert call("matches", HUME, {"field": "이름", "op": "포함"}, "흄관") is True + assert call("matches", HUME, {"field": "이름", "op": "같음"}, "흄관") is False + assert ( + call("matches", HUME, {"field": "이름", "op": "같음"}, "각형 흄관") is True + ) # 띄어쓰기는 가리지 않음 + assert call("matches", HUME, in_spec, "") is False # 알약 값이 비면 안 잡음 + + +def test_잡힌_수에_따른_알림(): + assert call("catchNote", 0)["kind"] == "none" + one = call("catchNote", 1, "각형흄관 D=300mm") + assert one["kind"] == "one" and "각형흄관" in one["text"] + many = call("catchNote", 3) + assert many["kind"] == "many" and "3 개" in many["text"] + + +def test_쪽_넘기기_범위(): + assert call("pageRange", 25, 1, 12) == {"from": 1, "to": 12, "pages": 3} + assert call("pageRange", 25, 3, 12) == {"from": 25, "to": 25, "pages": 3} + assert call("pageRange", 25, 9, 12) == { + "from": 25, + "to": 25, + "pages": 3, + } # 쪽이 넘치면 마지막 쪽 + assert call("pageRange", 0, 1, 12) == {"from": 0, "to": 0, "pages": 1} + + +def test_변수_알약_목록(): + pills = call("variablePills", logic()) + assert pills == [{"port": {"box": "b2", "name": "관경"}, "unit": "", "example": "300"}] + + +def test_선택_자리와_조건_찾기_요소(): + item = {"ref": "MT009686", "spec": "D=300mm", "unit": "M"} + slot = call( + "materialElement", + "choice", + "흄관", + {"구분": "관·이음관", "상세구분": "", "찾는말": "흄관"}, + {"item": item}, + ) + assert slot["mode"] == "choice" and slot["name"] == "흄관" and slot["slot"] == "흄관" + assert slot["default"] == "MT009686" and slot["scope"] == { + "구분": "관·이음관", + "찾는말": "흄관", + } + rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}} + found = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule}) + assert found["mode"] == "rule" and found["ref"] == "" and found["rule"] == rule + assert "default" not in found + + +def test_재료_요소_검사(): + ok = call("materialElement", "choice", "흄관", {"찾는말": "흄관"}) + assert call("checkMaterial", ok, []) is None + assert call("checkMaterial", {**ok, "name": ""}, []) == "자리 이름을 적어 주세요" + assert call("checkMaterial", ok, [ok]) == "같은 이름의 알약이 이미 있음" + assert "범위" in call("checkMaterial", call("materialElement", "choice", "흄관", {}), []) + rule_no_pill = call( + "materialElement", + "rule", + "흄관", + {"찾는말": "흄관"}, + {"rule": {"field": "규격", "op": "포함", "from": {"box": "", "name": ""}}}, + ) + assert "변수 알약" in call("checkMaterial", rule_no_pill, []) + + +def test_조건_찾기_요소는_품목_키가_없어도_따로_넣고_이름으로_뺌(): + rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}} + a = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule}) + b = call("materialElement", "rule", "받침", {"찾는말": "받침"}, {"rule": rule}) + lg = call("addElement", logic(), "b1", a) + lg = call("addElement", lg, "b1", b) + assert [e["name"] for e in lg["boxes"][0]["input"]["elements"]] == ["흄관", "받침"] + assert call("addElement", lg, "b1", a) == lg # 같은 이름은 한 번만 + gone = call("removeElementNamed", lg, "b1", "흄관") + assert [e["name"] for e in gone["boxes"][0]["input"]["elements"]] == ["받침"] + + +def other_logic() -> dict: + return { + "key": "DF-0009", + "name": "시간당 작업량", + "resultUnit": "㎥/시간", + "userElements": [], + "links": [], + "boxes": [ + { + "id": "i1", + "kind": "input", + "name": "조건", + **PLAIN, + "input": { + "mode": "variable", + "elements": [], + "variables": [{"name": "q", "type": "number", "unit": "㎥", "example": 6}], + }, + }, + { + "id": "i2", + "kind": "input", + "name": "재료", + **PLAIN, + "input": { + "mode": "group", + "group": "재료", + "elements": [ + { + "ref": "", + "name": "흄관", + "spec": "", + "unit": "M", + "mode": "rule", + "slot": "흄관", + } + ], + "variables": [], + }, + }, + { + "id": "o1", + "kind": "output", + "name": "작업량", + **PLAIN, + "unit": "㎥/시간", + "output": {"cost": "값", "unit": "㎥/시간"}, + }, + ], + } + + +def test_다른_로직_입력_알약(): + ref = call("logicRefOf", "DF-0009", other_logic()) + assert ref["key"] == "DF-0009" and ref["name"] == "시간당 작업량" + assert ref["outputs"] == [{"name": "작업량", "unit": "㎥/시간"}] + assert ref["designs"] == [{"name": "q", "unit": "㎥"}, {"name": "흄관", "unit": "M"}] + + +def test_다른_로직_상자는_출력_단위를_따르고_빼면_변수로_돌아감(): + ref = call("logicRefOf", "DF-0009", other_logic()) + lg = call("setLogicInput", logic(), "b1", ref) + box = lg["boxes"][0] + assert box["input"]["mode"] == "logic" and box["input"]["logic"]["key"] == "DF-0009" + assert box["unit"] == "㎥/시간" + assert call("pillNames", box) == ["작업량"] + lg["links"] = [ + {"id": "l1", "from": {"box": "b1", "name": "작업량"}, "to": {"box": "b2", "name": "x"}} + ] + back = call("setLogicInput", lg, "b1", None) + assert back["boxes"][0]["input"]["mode"] == "variable" and back["links"] == [] + assert call("reconcileUnit", lg, "b1") == lg # 다른 로직 상자 단위는 안 건드림 + + +def test_갈래를_다른_로직으로_바꾸면_고른_것을_비움(): + el = {"ref": "LB1", "name": "석공", "spec": "", "unit": "원/일"} + lg = call("addElement", call("setKind", logic(), "b1", "인력"), "b1", el) + out = call("setKind", lg, "b1", "다른 로직") + assert ( + out["boxes"][0]["input"]["mode"] == "logic" and out["boxes"][0]["input"]["elements"] == [] + ) diff --git a/resources/tester/test_m02_df_sample.py b/resources/tester/test_m02_df_sample.py index a5c12d17b..e74d11578 100644 --- a/resources/tester/test_m02_df_sample.py +++ b/resources/tester/test_m02_df_sample.py @@ -1,17 +1,19 @@ -"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`. +"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`. -· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) -· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 - (Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만 -· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100 +견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value) +· 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) +· 선 규칙(조건 통과 · 끝수) · 재료 방식(조건 찾기) · 다른 로직 입력의 알약이 있는 자리를 가리킴 +· 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음 +· 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 · + 입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133 · `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 """ from __future__ import annotations import json +import math import re -import shutil import subprocess from decimal import Decimal from pathlib import Path @@ -22,6 +24,15 @@ 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 +NAMES = ["masonry", "ground", "hume", "linked", "value"] +BOX_COLOR = dict( + re.findall( + r'(\w+): "(#[0-9a-fA-F]{6})"', + re.search( + r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_DF_Model.ts").read_text(encoding="utf-8") + ).group(1), + ) +) HOOKS = r""" import { readFileSync } from "node:fs"; @@ -58,13 +69,15 @@ def _node(body: str) -> object: return json.loads(result.stdout) -# ── 모양 ───────────────────────────────────────────────────────────────── +# ── 알약 · 선 ───────────────────────────────────────────────────────────── def _ports_out(box: dict) -> list[str]: if box["kind"] == "function": return [box["func"]["output"]["name"]] if box["kind"] == "output": return [] inp = box["input"] + if inp["mode"] == "logic": + return [p["name"] for p in inp["logic"]["outputs"]] return ( [e["name"] for e in inp.get("elements", [])] + (["표"] if inp.get("table") else []) @@ -78,10 +91,28 @@ def _ports_in(box: dict) -> list[str]: return ["값"] if box["kind"] == "output" else [] +def _deps(lg: dict, box_id: str) -> list[dict]: + """그 상자가 기대는 알약 — 들어온 선 · 그 선 규칙의 조건 알약 · 재료 조건 알약""" + out = [] + for link in lg["links"]: + if link["to"]["box"] != box_id: + continue + out.append(link["from"]) + rule = link.get("rule") or {} + if rule.get("kind") == "gate": + out.append(rule["from"]) + out += [c["from"] for c in rule.get("conds", [])] + box = next(b for b in lg["boxes"] if b["id"] == box_id) + for e in (box.get("input") or {}).get("elements", []): + if e.get("rule"): + out.append(e["rule"]["from"]) + return out + + def _order(lg: dict) -> list[dict]: - """함수 상자 차례(앞 값을 받는 것이 뒤) — 순환이면 실패""" + """상자 차례(기대는 것이 앞) — 순환이면 실패""" boxes = {b["id"]: b for b in lg["boxes"]} - need = {b["id"]: {link["from"]["box"] for link in lg["links"] if link["to"]["box"] == b["id"]} for b in lg["boxes"]} # fmt: skip + need = {i: {p["box"] for p in _deps(lg, i)} for i in boxes} done: list[str] = [] while len(done) < len(boxes): ready = [i for i in boxes if i not in done and need[i] <= set(done)] @@ -90,18 +121,24 @@ def _order(lg: dict) -> list[dict]: return [boxes[i] for i in done] -# Model 기본 색(`M02_DF_Model.ts` BOX_COLOR) — 견본 색은 이것과 같아야 함 -BOX_COLOR = dict( - re.findall( - r'(\w+): "(#[0-9a-fA-F]{6})"', - re.search( - r"BOX_COLOR[^{]*\{([^}]*)\}", (FOLDER / "M02_DF_Model.ts").read_text(encoding="utf-8") - ).group(1), - ) -) +def _trace_back(lg: dict, box_id: str) -> set[tuple[str, str]]: + """그 상자 값이 온 길의 출처 알약(입력 상자) — 선 · 규칙 조건 · 재료 조건을 거꾸로 끝까지""" + boxes = {b["id"]: b for b in lg["boxes"]} + seen: set[tuple[str, str]] = set() + todo = [box_id] + while todo: + cur = todo.pop() + for p in _deps(lg, cur): + if boxes[p["box"]]["kind"] == "input": + seen.add((p["box"], p["name"])) + todo.append(p["box"]) # 재료 조건처럼 입력 상자도 다른 알약에 기댐 + else: + todo.append(p["box"]) + return seen -@pytest.mark.parametrize("name", ["masonry", "rate", "value"]) +# ── 모양 ───────────────────────────────────────────────────────────────── +@pytest.mark.parametrize("name", NAMES) def test_견본_모양(name: str) -> None: lg = SAMPLES[name] assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == [] @@ -111,59 +148,98 @@ def test_견본_모양(name: str) -> None: assert len(set(sheets)) == len(sheets) for b in lg["boxes"]: assert b["kind"] in ("input", "function", "output") - assert ( - b["color"] == BOX_COLOR[b["kind"]] - and b["unit"] - and isinstance(b["x"], int) - and isinstance(b["y"], int) - ) + assert b["color"] == BOX_COLOR[b["kind"]] and b["unit"] + assert isinstance(b["x"], int) and isinstance(b["y"], int) if b["kind"] == "function": fn = b["func"] - cells = fn["sheet"]["칸"] - assert "식" in cells[fn["output"]["cell"]] # 출력 하나 = 수식 칸 + assert "식" in fn["sheet"]["칸"][fn["output"]["cell"]] # 출력 하나 = 수식 칸 assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) if b["kind"] == "output": assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") units = {e["unit"] for e in (b.get("input") or {}).get("elements") or []} - if len(units) == 1: # 요소 단위가 모두 같으면 상자 단위도 그것(공통 단위 규칙) + if len(units) == 1: assert b["unit"] == next(iter(units)), b["id"] for link in lg["links"]: a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] assert link["from"]["name"] in _ports_out(a), link assert link["to"]["name"] in _ports_in(z), link - # 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오 + assert a["kind"] != "output" and a["x"] < z["x"] for b in lg["boxes"]: for port in _ports_in(b): got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port] assert len(got) == 1, (b["id"], port) - for link in lg["links"]: - assert boxes[link["from"]["box"]]["kind"] != "output" - assert boxes[link["from"]["box"]]["x"] < boxes[link["to"]["box"]]["x"] + # 규칙 · 재료 조건이 가리키는 알약은 있는 자리 + for b in lg["boxes"]: + for p in _deps(lg, b["id"]): + assert p["name"] in _ports_out(boxes[p["box"]]), p _order(lg) +def test_견본_흐름_규칙_방식() -> None: + """견본마다 보여 주려는 흐름 — 조건 통과 선 둘 · 재료 조건 찾기 · 다른 로직 입력 · 출처 사슬""" + g = SAMPLES["ground"] + gates = [x for x in g["links"] if (x.get("rule") or {}).get("kind") == "gate"] + assert len(gates) == 2 and {x["from"]["name"] for x in gates} == {"굴착량"} + assert {x["rule"]["op"] for x in gates} == {"=", "≠"} and len( + {x["rule"]["value"] for x in gates} + ) == 1 + assert ("in-ground", "지반") in _trace_back(g, "out-exp") # 조건 알약도 출처 사슬에 + + h = SAMPLES["hume"] + pipe = next(b for b in h["boxes"] if b["id"] == "in-pipe")["input"]["elements"][0] + assert pipe["mode"] == "rule" and pipe["slot"] == pipe["name"] and pipe["rule"]["op"] == "포함" + assert pipe["scope"]["상세구분"] == "흄관" and pipe["default"] == pipe["ref"] + assert _trace_back(h, "out-mat") == { + ("in-size", "연장"), + ("in-pipe", "흄관"), + ("in-size", "관경"), + } + + lk = SAMPLES["linked"] + logic = next(b for b in lk["boxes"] if b["id"] == "in-logic")["input"]["logic"] + called = SAMPLES[logic["key"]] + assert [p["name"] for p in logic["outputs"]] == [ + b["name"] for b in called["boxes"] if b["kind"] == "output" + ] + assert [p["name"] for p in logic["designs"]] == [ + v["name"] for b in called["boxes"] if b["kind"] == "input" for v in b["input"]["variables"] + ] + assert ("in-logic", "시간당 작업량") in _trace_back(lk, "out-exp") + + m = SAMPLES["masonry"] + assert _trace_back(m, "out-pay") >= { + ("in-var", "높이"), + ("in-poom", "표"), + ("in-rate", "표"), + ("in-labor", "석공"), + } + + def test_견본_목록_열기() -> None: got = _node( - "out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });" + "out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });" ) - assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"] + 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"]["boxes"] == SAMPLES["masonry"]["boxes"] + assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"] assert got["none"] is None -# ── 값 ─────────────────────────────────────────────────────────────────── +# ── 값(내부 확인) ───────────────────────────────────────────────────────── @pytest.fixture(scope="module") def master(): from M01_MasterData import M01_MasterData_Store as store cm = store.cm - files = cm.load(folder=store.FOLDER) - return cm, files + return cm, cm.load(folder=store.FOLDER) + + +def _plain(v): + return float(v) if isinstance(v, Decimal) else v def _table_rows(table: dict) -> list[list]: - """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)""" + """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙""" conds, lines = table.get("조건") or {}, table.get("줄") or [] names = [*conds, *(table.get("값칸") or {})] split = { @@ -175,37 +251,69 @@ def _table_rows(table: dict) -> list[list]: line: list = [] for n in names: v = r.get(n) - if n in split: - line += list(v) if isinstance(v, list) else [v, v] - else: - line.append(v) + line += (list(v) if isinstance(v, list) else [v, v]) if n in split else [v] rows.append(line) return [head, *rows] -def _plain(v): - return float(v) if isinstance(v, Decimal) else v +def _cut(value, rnd): + if value is None or not rnd: + return value + k = 10 ** rnd["places"] + way = {"반올림": lambda x: math.floor(x + 0.5), "올림": math.ceil, "버림": math.floor}[ + rnd["method"] + ] + return way(round(value * k, 9)) / k -def _run(lg: dict, master) -> dict: - """상자 차례대로 — 입력 값(예시값 · 노임 · 표) → 함수 상자 시트에 넣고 엔진으로 셈 → 출력 상자 값""" +def _gate(rule, value, ports): + have = ports[rule["from"]["box"]][rule["from"]["name"]] + want = rule["value"] + ok = {"=": have == want, "≠": have != want, "≥": have >= want, "≤": have <= want, ">": have > want, "<": have < want} # fmt: skip + return value if ok[rule["op"]] else None + + +def _run(lg: dict, master, examples: dict | None = None) -> dict: + """상자 차례대로 — 입력(예시값 · 노임 · 표 · 재료 · 다른 로직) → 선 규칙 → 함수 시트(엔진) → 끝수 → 출력""" from common_util.common_util_spreadsheet import recalc_workbooks cm, files = master m = cm.mf.Master(files) tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip + items = next(d for d in files.values() if d.get("그룹") == "재료" and any("흄관" in str(r.get("이름")) for r in d["줄"]))["줄"] # fmt: skip ports: dict[str, dict] = {} for b in _order(lg): - into = {x["to"]["name"]: ports[x["from"]["box"]][x["from"]["name"]] for x in lg["links"] if x["to"]["box"] == b["id"]} # fmt: skip + into = {} + for x in lg["links"]: + if x["to"]["box"] == b["id"]: + value, rule = ports[x["from"]["box"]][x["from"]["name"]], x.get("rule") or {} + if rule.get("kind") == "gate": + value = _gate(rule, value, ports) + elif rule.get("kind") == "round": + value = _cut(value, rule) + into[x["to"]["name"]] = value if b["kind"] == "input": inp = b["input"] - got = {v["name"]: v["example"] for v in inp.get("variables", [])} - got |= { - e["name"]: _plain(cm.mf.element(m, e["ref"], {})[0]) - for e in inp.get("elements", []) + got = { + v["name"]: (examples or {}).get(v["name"], v["example"]) + for v in inp.get("variables", []) } + for e in inp.get("elements", []): + key = e["ref"] + if ( + e.get("mode") == "rule" + ): # 범위 안 품목 중 규격 / 이름에 조건 알약 값이 든 것 하나 + want = str(ports[e["rule"]["from"]["box"]][e["rule"]["from"]["name"]]) + sc = e["scope"] + hits = [r for r in items if r.get("구분") == sc["구분"] and r.get("상세구분") == sc["상세구분"] + and sc["찾는말"] in r.get("이름", "") and want in str(r.get(e["rule"]["field"], ""))] # fmt: skip + assert len(hits) == 1, (want, [h["키"] for h in hits]) + key = hits[0]["키"] + got[e["name"]] = _plain(cm.mf.element(m, key, {})[0]) if inp.get("table"): got["표"] = _table_rows(tables[inp["table"]["key"]]) + if inp["mode"] == "logic": + got = _run(SAMPLES[inp["logic"]["key"]], master) # 설계 값은 그 로직의 예시값 ports[b["id"]] = got elif b["kind"] == "function": fn = b["func"] @@ -213,63 +321,48 @@ def _run(lg: dict, master) -> dict: for i in fn["inputs"]: value = into[i["port"]] col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:]) - block = value if isinstance(value, list) else [[value]] - for r, line in enumerate(block): + for r, line in enumerate(value if isinstance(value, list) else [[value]]): for c, x in enumerate(line): if x is not None: sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)} - book = {"종류": "통합문서", "판": 1, "열": "cf", "서식": [{}], "시트": [sheet]} - out = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] - assert "수" in out, out - ports[b["id"]] = {fn["output"]["name"]: float(out["수"])} + book = {"종류": "통합문서", "판": 1, "열": "df", "서식": [{}], "시트": [sheet]} + got = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] + assert "수" in got, (b["id"], got) + ports[b["id"]] = { + fn["output"]["name"]: _cut(float(got["수"]), fn["output"].get("round")) + } else: - ports[b["id"]] = {"값": into["값"]} - return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} + ports[b["id"]] = {"값": _cut(into["값"], b["output"].get("round"))} + return {b["name"]: 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: +def test_값_메쌓기_value_는_calc_와_같음(master) -> None: from M01_MasterData.LogicBuilder import M01_LB_Calc as lb - cm, files = master - row = next( - r - for d in files.values() - if d.get("그룹") == "로직" - for r in d["줄"] - if r["키"] == "GF000219" - ) + _, files = master + rows = {r["키"]: r for d in files.values() if d.get("그룹") == "로직" for r in d["줄"]} + raw = lambda k: json.loads(json.dumps(rows[k], default=_plain)) # noqa: E731 given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} - want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {}) - assert want["ok"], want - got = _run(SAMPLES["masonry"], master)["out-pay"] - print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}") - assert abs(got - float(want["sums"]["계"])) < 1 - - -def test_견본3_값_calc_와_같음_견본2_비율(master) -> None: - from M01_MasterData.LogicBuilder import M01_LB_Calc as lb - - cm, files = master - row = next( - r - for d in files.values() - if d.get("그룹") == "로직" - for r in d["줄"] - if r["키"] == "GF000133" - ) + want = lb.calc(raw("GF000219"), {**given, "초과증가율": 80}, {}) + assert abs(_run(SAMPLES["masonry"], master)["노무비"] - float(want["sums"]["계"])) < 1 given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} - given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」 - want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {}) - assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"]) - assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100 + given["qt"] = given.pop("q") + assert _run(SAMPLES["value"], master)["시간당 작업량"] == float( + lb.calc(raw("GF000133"), given, {})["result"] + ) + + +def test_값_지반_흄관_다른로직(master) -> None: + # 지반 = 토사 → 굴삭기 길만 · 암 → 브레이커 길만(조건 통과 안 된 선은 빈 칸 = 0) + assert _run(SAMPLES["ground"], master)["경비"] == 100 / 30 * 60000 / 100 + assert _run(SAMPLES["ground"], master, {"지반": "암"})["경비"] == 100 / 8 * 80000 / 100 + # 관경 600 → Φ600mm 품목 · 4 본 × 단가 ÷ 10 m · 0.1 원 미만 버림 + cm, files = master + price = float(cm.mf.element(cm.mf.Master(files), "MT014991", {})[0]) + assert _run(SAMPLES["hume"], master)["재료비"] == _cut( + 4 * price / 10, {"places": 1, "method": "버림"} + ) + # 다른 로직(작업량 15.552) → 운반 시간 120 ÷ 15.552 = 7.72(소수 둘째 반올림) → 7.72 × 50,000 ÷ 120 + assert _run(SAMPLES["linked"], master)["경비"] == _cut( + 7.72 * 50000 / 120, {"places": 1, "method": "버림"} + )