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_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts new file mode 100644 index 000000000..6ab1911b7 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts @@ -0,0 +1,254 @@ +/* ============================================================================= + * M02_DF_Detail.ts + * 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음 + * 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval"; +import { + portsIn, + portsOut, + traceBack, + traceForward, + type CfBox, + type CfGateOp, + type CfLink, + type CfLogic, + type CfPort, + type CfRound, + type CfRule, + type CfStep, +} from "./M02_DF_Model"; +import { applyRule, ruleLabel } from "./M02_DF_Rule"; + +const TEXT = { + Table: ["표 {n}줄", "table, {n} rows"], + Empty: ["값 없음", "no value"], + LabelLookup: ["표에서 「{col}」 찾기", "Look up “{col}” in the table"], + LabelGate: ["「{pill}」 {op} {value} 일 때만 보냄", "Send only when “{pill}” {op} {value}"], + LabelScale: ["값 {op} {value}", "value {op} {value}"], + LabelRound: ["소수 {places}자리 {method}", "{places} decimals, {method}"], + NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"], + Blocked: ["통과 안 됨", "does not pass"], + NoRowLater: ["표에서 맞는 줄은 계산 뒤에 보임", "The matching row shows after calculation"], + EmptyIn: ["빈 받는 자리 — 선이 없음", "Empty receiving spot — no line"], + NoCell: ["받은 알약을 칸에 놓지 않음", "The received pill is not placed in a cell"], + Dead: ["아직 어디로도 안 감", "Goes nowhere yet"], + NoValue: ["값이 아직 없음", "No value yet"], +} as const; + +export const td = (key: keyof typeof TEXT, fill: Record = {}): string => + Object.entries(fill).reduce( + (s, [k, v]) => s.replace(`{${k}}`, String(v)), + TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, + ); + +export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"]; +export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"]; + +export const boxOf = (lg: CfLogic, id: string): CfBox | undefined => + lg.boxes.find((b) => b.id === id); + +export const portText = (lg: CfLogic, p: CfPort): string => + `${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`; + +/** 계산 한 번 — 못 하면 null */ +export function evalSafe(lg: CfLogic, values?: CfValues): CfEvalResult | null { + try { + return evalLogic(lg, values); + } catch { + return null; + } +} + +export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined => + ev?.ports[p.box]?.[p.name]; + +/** 화면에 보일 값 글 — 표는 줄 수 */ +export function showValue(v: CfValue | undefined): string { + if (v === undefined || v === null) return td("Empty"); + if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) }); + return typeof v === "number" ? String(Number(v.toPrecision(12))) : String(v); +} + +export function unitOf(lg: CfLogic, p: CfPort): string { + const box = boxOf(lg, p.box); + return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-"; +} + +/** 알약이 나가는(out) 쪽인가 */ +export const isOut = (box: CfBox, name: string): boolean => + portsOut(box).some((p) => p.name === name); + +/* ── 규칙 ─────────────────────────────────────────────────────────────── */ + +const num = (v: unknown): number | null => { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) && v !== "" && v !== null ? n : null; +}; + +/** 조건 통과 — 숫자면 숫자끼리 · 아니면 글끼리 */ +export function gatePass(op: CfGateOp, v: unknown, value: number | string): boolean { + const a = num(v); + const b = num(value); + const [x, y] = a !== null && b !== null ? [a, b] : [String(v ?? ""), String(value)]; + switch (op) { + case "=": + return x === y; + case "≠": + return x !== y; + case "≥": + return x >= y; + case "≤": + return x <= y; + case ">": + return x > y; + default: + return x < y; + } +} + +export function roundValue(r: CfRound, v: number): number { + const k = 10 ** r.places; + const f = r.method === "올림" ? Math.ceil : r.method === "버림" ? Math.floor : Math.round; + return f(v * k) / k; +} + +/** 선 위 규칙 글 — 모델이 준비되면 그것, 아니면 같은 뜻의 글 */ +export function labelOf(lg: CfLogic, rule: CfRule | undefined): string { + if (!rule) return td("NoRule"); + try { + return ruleLabel(rule); + } catch { + if (rule.kind === "lookup") return td("LabelLookup", { col: rule.col }); + if (rule.kind === "gate") + return td("LabelGate", { pill: portText(lg, rule.from), op: rule.op, value: rule.value }); + if (rule.kind === "scale") return td("LabelScale", { op: rule.op, value: rule.value }); + return td("LabelRound", { places: rule.places, method: rule.method }); + } +} + +/** 지나가기 전 값 → 지난 뒤 값 미리 보기 */ +export function preview( + link: CfLink, + rule: CfRule | undefined, + ev: CfEvalResult | null, +): { before: string; after: string } { + const raw = valueOf(ev, link.from); + const before = showValue(raw); + if (!rule) return { before, after: before }; + const conds: Record = {}; + if (rule.kind === "lookup") for (const c of rule.conds) conds[c.col] = valueOf(ev, c.from); + try { + const r = applyRule(rule, raw, conds); + return { + before, + after: r.value === null ? (r.why ?? td("Empty")) : showValue(r.value as CfValue), + }; + } catch { + const n = num(raw); + if (rule.kind === "gate") + return { + before, + after: gatePass(rule.op, valueOf(ev, rule.from), rule.value) ? before : td("Blocked"), + }; + if (rule.kind === "lookup" || n === null) return { before, after: td("NoRowLater") }; + if (rule.kind === "round") return { before, after: showValue(roundValue(rule, n)) }; + const v = rule.value; + const out = + rule.op === "×" + ? n * v + : rule.op === "÷" + ? v + ? n / v + : NaN + : rule.op === "+%" + ? n * (1 + v / 100) + : n * (1 - v / 100); + return { before, after: Number.isFinite(out) ? showValue(out) : td("Empty") }; + } +} + +/** 같은 알약에서 나간 조건 선 — 지금 값으로 통과하는 수 */ +export function gateReport( + lg: CfLogic, + link: CfLink, + ev: CfEvalResult | null, +): { total: number; pass: string[] } { + const same = lg.links.filter( + (l) => + l.from.box === link.from.box && l.from.name === link.from.name && l.rule?.kind === "gate", + ); + const pass = same + .filter((l) => { + const r = l.rule as Extract; + return gatePass(r.op, valueOf(ev, r.from), r.value); + }) + .map((l) => l.id); + return { total: same.length, pass }; +} + +/* ── 흐름 · 끊김 ─────────────────────────────────────────────────────── */ + +/** 거슬러 오르는 길(dir "in" = 온 길) · 따라가는 길(dir "out") — 모델 것, 없으면 작은 셈 */ +export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] { + try { + return dir === "in" ? traceBack(lg, p) : traceForward(lg, p); + } catch { + const out: CfStep[] = []; + const seen = new Set(); + const walk = (port: CfPort, back: boolean): void => { + const key = `${port.box}/${port.name}`; + if (seen.has(key)) return; + seen.add(key); + const box = boxOf(lg, port.box); + if (!box) return; + if (back) { + const into = lg.links.filter( + (l) => l.to.box === port.box && (isOut(box, port.name) || l.to.name === port.name), + ); + for (const l of into) { + out.push({ box: l.from.box, port: l.from.name, dir: "out", link: l.id, rule: l.rule }); + walk(l.from, true); + } + } else { + const outs = lg.links.filter((l) => l.from.box === port.box && l.from.name === port.name); + for (const l of outs) { + out.push({ box: l.to.box, port: l.to.name, dir: "in", link: l.id, rule: l.rule }); + const next = boxOf(lg, l.to.box); + for (const o of next ? portsOut(next) : []) walk({ box: next!.id, name: o.name }, false); + } + } + }; + walk(p, dir === "in"); + return out; + } +} + +/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */ +export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] { + try { + return breaks(lg) + .filter((b) => b.box === boxId && (!name || !b.port || b.port === name)) + .map((b) => b.text); + } catch { + // 모델의 끊긴 곳 목록이 준비되기 전 — 같은 뜻의 작은 셈 + } + const box = boxOf(lg, boxId); + if (!box) return []; + const out: string[] = []; + for (const p of portsIn(box)) { + if (name && p.name !== name) continue; + if (!lg.links.some((l) => l.to.box === boxId && l.to.name === p.name)) out.push(td("EmptyIn")); + else if (box.func?.inputs.some((i) => i.port === p.name && !i.cell)) out.push(td("NoCell")); + } + for (const p of portsOut(box)) { + if (name && p.name !== name) continue; + if (!lg.links.some((l) => l.from.box === boxId && l.from.name === p.name)) out.push(td("Dead")); + } + return out; +} + +export const noValue = (): string => td("NoValue"); 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_Eval.ts b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts index f471e0145..cb0516e43 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Eval.ts @@ -1,7 +1,9 @@ /* ============================================================================= * M02_DF_Eval.ts - * 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈 - * (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음 + * 단가산출 흐름도 계산 · 검사 — 상자를 기댐 차례대로(입력 → 선 규칙 → 함수 · 다른 로직 → 출력) 셈 + * 함수 상자 = 시트를 우리 스프레드시트 엔진으로 셈(받는 칸에 앞 값) · 선 규칙 · 끝수도 같은 엔진(`M02_DF_Rule`) + * 출력 상자를 비용 종류별로 모아 원가 · 끊긴 곳(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 · + * 조건 겹침 · 조건 안 맞음 · 오류) · DOM 없음 * 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게 * ========================================================================== */ @@ -9,19 +11,50 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; -import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_DF_Model"; +import { + OUTPUT_PORT, + depends, + designInputs, + elementPort, + portsIn, + portsOut, + ruleSources, + type CfBox, + type CfLink, + type CfLogic, +} from "./M02_DF_Model"; +import { applyRound, applyRule, ruleLabel } from "./M02_DF_Rule"; /** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */ export type CfValue = number | string | boolean | null | (number | string | null)[][]; -/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */ +/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값 · 다른 로직 출력) */ export type CfValues = Record>; type Scalar = number | string | boolean | null; +/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */ +export interface CfBreak { + kind: + | "빈 받는 자리" + | "통과 안 됨" + | "표에 없는 조건" + | "맞는 재료 없음" + | "조건 겹침" + | "조건 안 맞음" + | "오류"; + box: string; + port?: string; + link?: string; + /** 쉬운 말 */ + text: string; +} + export interface CfEvalResult { /** 상자 id → 내보내는 알약 이름 → 값 */ ports: Record>; + /** 상자 id → 받는 알약 이름 → 선 규칙을 지나 들어온 값 */ + arrived?: Record>; /** 출력 상자 id → 값 */ outputs: Record; cost: { @@ -34,6 +67,14 @@ export interface CfEvalResult { }; /** 쉬운 말 */ errors: string[]; + /** 끊긴 곳(셈 때 생긴 것) */ + breaks?: CfBreak[]; +} + +/** 다른 로직 입력을 셀 때 쓸 로직들(저장 · 견본 key → 로직) */ +export interface CfEvalOptions { + logics?: Record; + depth?: number; } const TEXT = { @@ -58,6 +99,40 @@ const TEXT = { "Pill 「{pill}」 of 「{box}」 is not placed in a cell", ], NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"], + /** {from} {box} {rule} */ + NotPassed: [ + "「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음", + "Line 「{from}」 → 「{box}」 doesn't pass ({rule})", + ], + NoRow: [ + "「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음", + "Line 「{from}」 → 「{box}」 — no matching table row", + ], + ManyRows: [ + "「{from}」 → 「{box}」 선 — 표에 맞는 줄이 여럿", + "Line 「{from}」 → 「{box}」 — several table rows match", + ], + RuleError: [ + "「{from}」 → 「{box}」 선 규칙을 셀 수 없음", + "Line 「{from}」 → 「{box}」 rule can't be worked out", + ], + /** {box} {pill} */ + NoMaterial: [ + "「{box}」 의 「{pill}」 — 조건에 맞는 재료가 없음", + "「{pill}」 of 「{box}」 — no material matches", + ], + Overlap: [ + "「{box}」 의 「{pill}」 에서 나간 조건 선이 둘 이상 통과함", + "Several condition lines from 「{pill}」 of 「{box}」 pass", + ], + NoneMatch: [ + "「{box}」 의 「{pill}」 에서 나간 조건 선이 하나도 통과하지 않음", + "No condition line from 「{pill}」 of 「{box}」 passes", + ], + NoLogic: [ + "「{box}」 이 부르는 로직을 찾을 수 없음", + "The logic used by 「{box}」 can't be found", + ], } as const; const tl = (key: keyof typeof TEXT, fill: Record = {}): string => @@ -68,23 +143,22 @@ const tl = (key: keyof typeof TEXT, fill: Record = {}): string = /* ── 차례 ───────────────────────────────────────────────────────────────── */ -/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */ -function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } { - const funcs = lg.boxes.filter((b) => b.kind === "function"); +/** 상자 차례 — 기댐(선 · 규칙 조건 · 재료 조건)대로 · 돌고 도는 상자는 loop */ +function order(lg: CfLogic): { boxes: CfBox[]; loop: CfBox[] } { + const deps = depends(lg); const done: CfBox[] = []; - const left = new Set(funcs.map((f) => f.id)); + const left = new Set(lg.boxes.map((b) => b.id)); for (let moved = true; moved && left.size;) { moved = false; - for (const f of funcs) { - if (!left.has(f.id)) continue; - const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box)); - if (waits) continue; - done.push(f); - left.delete(f.id); + for (const b of lg.boxes) { + if (!left.has(b.id)) continue; + if (deps.some((d) => d.to === b.id && d.from !== b.id && left.has(d.from))) continue; + done.push(b); + left.delete(b.id); moved = true; } } - return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) }; + return { boxes: done, loop: lg.boxes.filter((b) => left.has(b.id)) }; } /* ── 칸 ─────────────────────────────────────────────────────────────────── */ @@ -113,16 +187,18 @@ const fromCalc = (v: CalcValue | undefined): Scalar => { return v.오류; }; -/* ── 계산 ───────────────────────────────────────────────────────────────── */ +/* ── 상자마다 ───────────────────────────────────────────────────────────── */ /** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */ -function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record { - const given = values[box.id] ?? {}; - const pills = portsOut(box); +function inputPorts( + lg: CfLogic, + box: CfBox, + given: Record, +): Record { const out: Record = {}; const inp = box.input; if (!inp) return out; - // portsOut 차례 = 요소 · 표 · 변수 + const pills = portsOut(box); const sources: CfValue[] = [ ...(inp.mode === "group" ? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null) @@ -134,16 +210,16 @@ function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record, + arrive: Record, given: Record, errors: string[], ): Scalar { const func = box.func!; const sheet: Sheet = structuredClone(func.sheet); - for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null); + for (const i of func.inputs) put(sheet.칸, i.cell, arrive[i.port] ?? given[i.port] ?? null); for (const v of func.variables) if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example); const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); @@ -154,79 +230,203 @@ function runFunc( } const at = func.output.cell.toUpperCase(); const c = sheet.칸[at]; - if (!c) return null; - return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); + const got = !c ? null : c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null); + return typeof got === "number" && func.output.round ? applyRound(func.output.round, got) : got; } -/** 화면 계산 */ -export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult { +/* ── 계산 ───────────────────────────────────────────────────────────────── */ + +/** 화면 계산 — opts.logics 가 있으면 다른 로직 입력을 그 로직으로 셈(없으면 values 의 값) */ +export function evalLogic( + lg: CfLogic, + values: CfValues = {}, + opts: CfEvalOptions = {}, +): CfEvalResult { const errors: string[] = []; + const found: CfBreak[] = []; const ports: CfEvalResult["ports"] = {}; - for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values); - const into = (box: string): Record => - Object.fromEntries( - lg.links - .filter((l) => l.to.box === box) - .map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]), + const arrived: NonNullable = {}; + const name = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id; + const valueOf = (box: string, pill: string): CfValue => ports[box]?.[pill] ?? null; + const passed = new Map(); + /** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */ + const blocked = new Set(); + /** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */ + const through = (l: CfLink): CfValue => { + const v = valueOf(l.from.box, l.from.name); + if (!l.rule) return v; + if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null; + const conds = Object.fromEntries( + ruleSources(l.rule).map((p) => [p.name, valueOf(p.box, p.name)]), ); - const { funcs, loop } = order(lg); - for (const f of loop) errors.push(tl("Loop", { box: f.name })); - for (const f of funcs) - ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) }; + const res = applyRule(l.rule, v, conds); + if (l.rule.kind === "gate") passed.set(l.id, res.why === null); + const fill = { from: name(l.from.box), box: name(l.to.box), rule: ruleLabel(l.rule) }; + if (res.why === "통과 안 됨") + found.push({ + kind: "통과 안 됨", + box: l.to.box, + port: l.to.name, + link: l.id, + text: tl("NotPassed", fill), + }); + else if (res.why) { + const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류"; + const key = + res.why === "표에 없는 조건" + ? "NoRow" + : res.why === "표에 여럿 맞음" + ? "ManyRows" + : "RuleError"; + const text = tl(key, fill); + found.push({ kind, box: l.to.box, port: l.to.name, link: l.id, text }); + errors.push(text); + } + if (res.why) blocked.add(l.to.box); + return res.why ? null : res.value; + }; + const arrive = (box: CfBox): Record => { + const got = Object.fromEntries( + lg.links.filter((l) => l.to.box === box.id).map((l) => [l.to.name, through(l)]), + ); + arrived[box.id] = got; + return got; + }; + const { boxes, loop } = order(lg); + for (const b of loop) errors.push(tl("Loop", { box: b.name })); const outputs: CfEvalResult["outputs"] = {}; const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; - for (const b of lg.boxes.filter((x) => x.kind === "output")) { - const v = into(b.id)[OUTPUT_PORT] ?? null; - const scalar: Scalar = Array.isArray(v) ? null : v; - outputs[b.id] = scalar; - const kind = b.output?.cost ?? "값"; - if (kind === "값") cost.값들[b.name] = scalar; - else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; - else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); + for (const b of boxes) { + const given = values[b.id] ?? {}; + if (b.kind === "input" && b.input?.mode !== "logic") { + ports[b.id] = inputPorts(lg, b, given); + for (const e of b.input?.elements ?? []) + if (e.mode === "rule" && ports[b.id][elementPort(e)] === null) { + const text = tl("NoMaterial", { box: b.name, pill: elementPort(e) }); + found.push({ kind: "맞는 재료 없음", box: b.id, port: elementPort(e), text }); + errors.push(text); + } + } else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors); + else if (b.kind === "function") { + const got = arrive(b); + ports[b.id] = { + [b.func!.output.name]: blocked.has(b.id) ? null : runFunc(b, got, given, errors), + }; + } else { + const v = arrive(b)[OUTPUT_PORT] ?? null; + let scalar: Scalar = Array.isArray(v) ? null : v; + const round = b.output?.round; + if (typeof scalar === "number" && round) scalar = applyRound(round, scalar); + outputs[b.id] = scalar; + const kind = b.output?.cost ?? "값"; + if (kind === "값") cost.값들[b.name] = scalar; + else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar; + else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name })); + } } cost.계 = cost.노무비 + cost.재료비 + cost.경비; - return { ports, outputs, cost, errors }; + gates(lg, passed, name, found, errors); + return { ports, arrived, outputs, cost, errors, breaks: found }; } -/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ -export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] { - const out: string[] = []; +/** 다른 로직 입력 — 그 로직을 받은 값(설계 때 정할 것)으로 셈 · 없으면 넘긴 값 */ +function otherLogic( + box: CfBox, + arrive: Record, + given: Record, + opts: CfEvalOptions, + errors: string[], +): Record { + const ref = box.input?.logic; + const out: Record = {}; + const other = ref ? opts.logics?.[ref.key] : undefined; + if (!other || (opts.depth ?? 0) > 8) { + if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name })); + for (const p of ref?.outputs ?? []) out[p.name] = given[p.name] ?? null; + return out; + } + const inner: CfValues = {}; + for (const d of designInputs(other)) { + const v = d.port in arrive ? arrive[d.port] : given[d.port]; + if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v; + } + const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 }); + for (const e of ev.errors) errors.push(`「${box.name}」 · ${e}`); + for (const p of ref?.outputs ?? []) { + const ob = other.boxes.find((b) => b.kind === "output" && b.name === p.name); + out[p.name] = ob ? (ev.outputs[ob.id] ?? null) : (given[p.name] ?? null); + } + return out; +} + +/** 한 알약에서 나간 조건 선(둘 이상) — 겹침 · 안 맞음 */ +function gates( + lg: CfLogic, + passed: Map, + name: (id: string) => string, + found: CfBreak[], + errors: string[], +): void { + const groups = new Map(); + for (const l of lg.links) + if (l.rule?.kind === "gate") { + const k = `${l.rule.from.box}|${l.rule.from.name}|${l.from.box}|${l.from.name}`; + groups.set(k, [...(groups.get(k) ?? []), l]); + } + for (const list of groups.values()) { + if (list.length < 2) continue; + const n = list.filter((l) => passed.get(l.id)).length; + if (n === 1) continue; + const g = list[0].rule as Extract; + const fill = { box: name(g.from.box), pill: g.from.name }; + const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill); + found.push({ + kind: n > 1 ? "조건 겹침" : "조건 안 맞음", + box: g.from.box, + port: g.from.name, + text, + }); + errors.push(text); + } +} + +/* ── 검사 ───────────────────────────────────────────────────────────────── */ + +/** 끊긴 곳 목록 — 빈 받는 자리(다른 로직의 받는 알약은 설계 때 정할 것이라 뺌) + 셈 때 생긴 것 */ +export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): CfBreak[] { + const out: CfBreak[] = []; for (const b of lg.boxes) { + if (b.kind === "input") continue; for (const p of portsIn(b)) if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) - out.push(tl("EmptyIn", { box: b.name, pill: p.name })); + out.push({ + kind: "빈 받는 자리", + box: b.id, + port: p.name, + text: tl("EmptyIn", { box: b.name, pill: p.name }), + }); + } + return [...out, ...(evalLogic(lg, values, opts).breaks ?? [])]; +} + +/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 칸에 안 놓인 받는 알약 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */ +export function checkLogic(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): string[] { + const out: string[] = []; + for (const b of lg.boxes) { + if (b.kind !== "input") + for (const p of portsIn(b)) + if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) + out.push(tl("EmptyIn", { box: b.name, pill: p.name })); if (b.kind === "function" && !parseA1(b.func?.output.cell ?? "")) out.push(tl("NoCell", { box: b.name })); for (const i of b.func?.inputs ?? []) if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port })); } if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput")); - const ev = evalLogic(lg, values); - for (const b of lg.boxes.filter((x) => x.kind === "input")) + const ev = evalLogic(lg, values, opts); + for (const b of lg.boxes.filter((x) => x.kind === "input" && x.input?.mode !== "logic")) for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {})) if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill)) out.push(tl("NoValue", { box: b.name, pill })); - return [...out, ...ev.errors]; -} - -/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */ -export interface CfBreak { - kind: - | "빈 받는 자리" - | "통과 안 됨" - | "표에 없는 조건" - | "맞는 재료 없음" - | "조건 겹침" - | "조건 안 맞음" - | "오류"; - box: string; - port?: string; - link?: string; - /** 쉬운 말 */ - text: string; -} - -/** 끊긴 곳 목록(넓힘 · 준비 중) */ -export function breaks(_lg: CfLogic, _values: CfValues = {}): CfBreak[] { - throw new Error("준비 중"); + return [...new Set([...out, ...ev.errors])]; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css index 80fb0f252..7497c4a10 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css @@ -50,13 +50,6 @@ font-size: var(--text-body-sm); } -.m02-dfpanel .ui-btn, -.m02-dfpanel .ui-input, -.m02-dfpanel .ui-select { - min-height: 44px; - font-size: var(--text-body); -} - .m02-dfpanel-form { align-items: flex-end; padding: var(--spacing-8) var(--spacing-12); @@ -92,8 +85,7 @@ display: inline-flex; align-items: center; gap: var(--spacing-4); - min-height: 44px; - padding: 0 var(--spacing-12); + padding: var(--spacing-4) var(--spacing-12); border: 2px solid; border-radius: var(--radius-pills); background: var(--color-surface); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index a38ce4aba..45e3b2be5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -7,100 +7,18 @@ * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ -import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { CalcValues, SpreadsheetHandle, Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; -import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model"; +import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; +import { createRoundRow } from "./M02_DF_RoundRow"; +import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; +import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; import "./M02_DF_FuncPanel.css"; -const TEXT = { - Unit: ["단위", "Unit"], - Cell: ["고른 칸", "Selected cell"], - Got: ["받은 알약", "Received pills"], - GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], - NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], - Placed: ["놓임", "placed"], - Free: ["아직 안 놓은 알약", "not placed yet"], - Busy: ["이미 쓰는 칸", "That cell is already in use"], - Locked: [ - "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", - "This cell is filled by the wire — cannot edit", - ], - Vars: ["변수", "Variables"], - MakeVar: ["변수 만들기", "Make variable"], - NoVars: ["아직 변수가 없음", "No variables yet"], - VarName: ["이름", "Name"], - VarType: ["갈래", "Type"], - Number: ["숫자", "Number"], - Choice: ["선택", "Choice"], - Text: ["글", "Text"], - VarUnit: ["단위", "Unit"], - VarMin: ["최소", "Min"], - VarMax: ["최대", "Max"], - VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], - VarExample: ["예시값", "Example value"], - VarCell: ["붙을 칸", "Attach to"], - Make: ["만들기", "Make"], - Cancel: ["취소", "Cancel"], - Remove: ["지움", "Remove"], - NeedName: ["이름을 적음", "Enter a name"], - SameName: ["같은 이름이 이미 있음", "That name already exists"], - NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], - BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], - OutBtn: ["출력 칸으로", "Make output cell"], - OutNow: ["출력", "Output"], - OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], - OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], - Frames: ["틀", "Templates"], - FrameMul: ["곱하기", "Multiply"], - FrameMore: ["할증", "Markup"], - FrameDiv: ["나누기", "Divide"], - FrameIf: ["만약", "If"], - FrameMin: ["최소", "Min"], - FrameMax: ["최대", "Max"], - FrameUp: ["올림", "Round up"], - FrameLook: ["표에서 찾기", "Look up in table"], - FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], - ReadOnly: ["읽기만", "Read only"], - Loading: ["시트를 불러오는 중", "Loading sheet"], - Problem: ["⚠ ", "⚠ "], - Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], - Err_DIV0: ["0으로 나눔", "divides by zero"], - Err_NAME: ["알 수 없는 이름", "unknown name"], - Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], - Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], - Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], - Err_NA: ["찾는 값이 없음", "value not found"], - Err_OTHER: ["수식이 틀림", "formula is wrong"], -} as const; - -const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ -const FRAMES: [keyof typeof TEXT, string][] = [ - ["FrameMul", "=B1*B2"], - ["FrameMore", "=B1*(1+B2/100)"], - ["FrameDiv", "=B1/B2"], - ["FrameIf", '=IF(B3="급",10,0)'], - ["FrameMin", "=MIN(B1,B2)"], - ["FrameMax", "=MAX(B1,B2)"], - ["FrameUp", "=ROUNDUP(B4,3)"], - ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], -]; - -const ERR_KEY: Record = { - "#DIV/0!": "Err_DIV0", - "#NAME?": "Err_NAME", - "#REF!": "Err_REF", - "#VALUE!": "Err_VALUE", - "#CYCLE!": "Err_CYCLE", - "#N/A": "Err_NA", -}; - type Handlers = { change(lg: CfLogic): void }; interface VarForm { @@ -126,6 +44,8 @@ interface State { json: string; cell: string; form: VarForm | null; + /** 단위 표준이 제안한 출력 소수자리 */ + suggest: number | null; parts: { head: HTMLElement; got: HTMLElement; @@ -317,20 +237,19 @@ function applyMarks(st: State): void { function drawHead(st: State): void { const box = boxOf(st); - const unit = el("input", { - className: "ui-input m02-dfpanel-unit", - attrs: { type: "text", "aria-label": tf("Unit") }, - }); - unit.value = box?.unit ?? "-"; - if (st.readOnly) unit.readOnly = true; - unit.addEventListener("change", () => { - const v = unit.value.trim() || "-"; - unit.value = v; - patchBox(st, (b) => ({ - ...b, - unit: v, - func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func, - })); + const unit = createUnitPick({ + value: box?.unit ?? "-", + readOnly: st.readOnly, + extra: st.lg.resultUnit ? [st.lg.resultUnit] : [], + ariaLabel: tf("Unit"), + onPick: (v) => { + patchBox(st, (x) => ({ + ...x, + unit: v, + func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func, + })); + void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st))); + }, }); st.parts.head.replaceChildren( el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }), @@ -372,8 +291,21 @@ function formField( cls = "", ): HTMLElement { const form = st.form!; + if (key === "unit") { + return el("label", { + className: "m02-dfpanel-fieldbox", + children: [ + el("span", { className: "m02-dfpanel-note", text: tf(label) }), + createUnitPick({ + value: form.unit || "-", + ariaLabel: tf(label), + onPick: (u) => ((form.unit = u), drawVars(st)), + }), + ], + }); + } const input = el("input", { - className: `ui-input m02-dfpanel-field ${cls}`, + className: `ui-input ui-input--sm m02-dfpanel-field ${cls}`, attrs: { type: "text", "aria-label": tf(label) }, }); input.value = form[key]; @@ -443,7 +375,7 @@ function drawVars(st: State): void { ); } else { const type = el("select", { - className: "ui-select m02-dfpanel-field", + className: "ui-select ui-select--sm m02-dfpanel-field", attrs: { "aria-label": tf("VarType") }, }); for (const [v, k] of [ @@ -520,6 +452,16 @@ function drawBar(st: State): void { el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }), ], }), + ...(f?.output.cell + ? [ + createRoundRow({ + round: f.output.round, + suggest: st.suggest, + readOnly: st.readOnly, + onApply: (round) => writeRound(st, round), + }), + ] + : []), el("div", { className: "m02-dfpanel-row", children: [ @@ -538,6 +480,14 @@ function drawBar(st: State): void { ); } +/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */ +function writeRound(st: State, round: CfRound | null): void { + patchFunc(st, (x) => { + const { round: _old, ...rest } = x.output; + return { ...x, output: round ? { ...rest, round } : rest }; + }); +} + /** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */ function setOutput(st: State, cell: string): void { const f = funcOf(st); @@ -681,6 +631,7 @@ export function renderFuncPanel( json: "", cell: "A1", form: null, + suggest: null, parts, observer: null, redraw: () => { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts new file mode 100644 index 000000000..b0d6e4643 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts @@ -0,0 +1,90 @@ +/* ============================================================================= + * M02_DF_FuncText.ts + * 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +export const TEXT = { + Unit: ["단위", "Unit"], + Cell: ["고른 칸", "Selected cell"], + Got: ["받은 알약", "Received pills"], + GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], + NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], + Placed: ["놓임", "placed"], + Free: ["아직 안 놓은 알약", "not placed yet"], + Busy: ["이미 쓰는 칸", "That cell is already in use"], + Locked: [ + "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", + "This cell is filled by the wire — cannot edit", + ], + Vars: ["변수", "Variables"], + MakeVar: ["변수 만들기", "Make variable"], + NoVars: ["아직 변수가 없음", "No variables yet"], + VarName: ["이름", "Name"], + VarType: ["갈래", "Type"], + Number: ["숫자", "Number"], + Choice: ["선택", "Choice"], + Text: ["글", "Text"], + VarUnit: ["단위", "Unit"], + VarMin: ["최소", "Min"], + VarMax: ["최대", "Max"], + VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], + VarExample: ["예시값", "Example value"], + VarCell: ["붙을 칸", "Attach to"], + Make: ["만들기", "Make"], + Cancel: ["취소", "Cancel"], + Remove: ["지움", "Remove"], + NeedName: ["이름을 적음", "Enter a name"], + SameName: ["같은 이름이 이미 있음", "That name already exists"], + NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], + BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], + OutBtn: ["출력 칸으로", "Make output cell"], + OutNow: ["출력", "Output"], + OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], + OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], + Frames: ["틀", "Templates"], + FrameMul: ["곱하기", "Multiply"], + FrameMore: ["할증", "Markup"], + FrameDiv: ["나누기", "Divide"], + FrameIf: ["만약", "If"], + FrameMin: ["최소", "Min"], + FrameMax: ["최대", "Max"], + FrameUp: ["올림", "Round up"], + FrameLook: ["표에서 찾기", "Look up in table"], + FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], + ReadOnly: ["읽기만", "Read only"], + Loading: ["시트를 불러오는 중", "Loading sheet"], + Problem: ["⚠ ", "⚠ "], + Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], + Err_DIV0: ["0으로 나눔", "divides by zero"], + Err_NAME: ["알 수 없는 이름", "unknown name"], + Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], + Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], + Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], + Err_NA: ["찾는 값이 없음", "value not found"], + Err_OTHER: ["수식이 틀림", "formula is wrong"], +} as const; + +export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ +export const FRAMES: [keyof typeof TEXT, string][] = [ + ["FrameMul", "=B1*B2"], + ["FrameMore", "=B1*(1+B2/100)"], + ["FrameDiv", "=B1/B2"], + ["FrameIf", '=IF(B3="급",10,0)'], + ["FrameMin", "=MIN(B1,B2)"], + ["FrameMax", "=MAX(B1,B2)"], + ["FrameUp", "=ROUNDUP(B4,3)"], + ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], +]; + +export const ERR_KEY: Record = { + "#DIV/0!": "Err_DIV0", + "#NAME?": "Err_NAME", + "#REF!": "Err_REF", + "#VALUE!": "Err_VALUE", + "#CYCLE!": "Err_CYCLE", + "#N/A": "Err_NA", +}; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css new file mode 100644 index 000000000..867c22e06 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css @@ -0,0 +1,82 @@ +.m02-dflink { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m02-dflink-head, +.m02-dflink-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dflink-label { + font-weight: var(--font-weight-bold); +} + +.m02-dflink-note { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-dflink-value { + padding: 2px var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m02-dflink-num { + width: 7em; +} + +.m02-dflink-warn { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +/* 알약 판 */ +.m02-dfpill { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m02-dfpill-title { + font-weight: var(--font-weight-bold); +} + +.m02-dfpill-list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0; + padding: 0; + list-style: none; +} + +.m02-dfpill-step { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} + +.m02-dfpill-rule { + padding: 0 var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-pills); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts new file mode 100644 index 000000000..32f1d9fb9 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts @@ -0,0 +1,388 @@ +/* ============================================================================= + * M02_DF_LinkPanel.ts + * 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) · + * 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfValues } from "./M02_DF_Eval"; +import { + portsOut, + reaches, + type CfGateOp, + type CfLogic, + type CfPort, + type CfRound, + type CfRule, +} from "./M02_DF_Model"; +import { + GATE_OPS, + SCALE_OPS, + evalSafe, + gateReport, + labelOf, + portText, + preview, + unitOf, + valueOf, +} from "./M02_DF_Detail"; +import { suggestPlaces } from "./M02_DF_UnitPick"; +import "./M02_DF_LinkPanel.css"; + +const TEXT = { + Title: ["선 규칙", "Line rule"], + Kinds: ["규칙 고르기", "Pick a rule"], + K_none: ["없음", "None"], + K_lookup: ["표에서 찾기", "Look up in table"], + K_gate: ["조건 통과", "Pass if"], + K_scale: ["바꾸기", "Change"], + K_round: ["끝수", "Rounding"], + Label: ["선 위 글", "Text on the line"], + Before: ["지나가기 전 값", "Value before"], + After: ["지난 뒤 값", "Value after"], + Col: ["조건 칸", "Condition column"], + Match: ["맞출 알약", "Pill to match"], + Take: ["꺼낼 칸", "Column to take"], + AddCond: ["+ 조건 칸", "+ Condition"], + Remove: ["지움", "Remove"], + NoHeader: [ + "보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름", + "The sender is not a table or it is not loaded yet", + ], + Pick: ["고르기", "pick"], + GateHint: [ + "알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음", + "Sent only when the pill passes", + ], + Value: ["값", "Value"], + ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"], + Places: ["소수 {n}자리", "{n} decimals"], + Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"], + Apply: ["적용", "Apply"], + WarnBoth: [ + "같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침", + "{n} conditional lines pass at once — overlap", + ], + WarnNone: [ + "같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음", + "None of the {n} conditional lines match", + ], + WarnLookup: [ + "조건 칸이 없음 — 표에서 줄을 못 고름", + "No condition column — a row can't be picked", + ], + WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"], + Count: ["선", "Line"], +} as const; + +const tk = (key: keyof typeof TEXT, n?: number | string): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? "")); + +type Kind = "none" | CfRule["kind"]; +const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"]; +const SEP = "\u0001"; + +interface Ctx { + lg: CfLogic; + linkId: string; + readOnly: boolean; + headers: string[]; + change(lg: CfLogic): void; +} + +const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`; +const fromKey = (k: string): CfPort => { + const [box, ...rest] = k.split(SEP); + return { box, name: rest.join(SEP) }; +}; + +function setRule(ctx: Ctx, rule: CfRule | undefined): void { + ctx.change({ + ...ctx.lg, + links: ctx.lg.links.map((l) => { + if (l.id !== ctx.linkId) return l; + const { rule: _old, ...rest } = l; + return rule ? { ...rest, rule } : rest; + }), + }); +} + +function select( + ctx: Ctx, + options: { value: string; text: string }[], + value: string, + onPick: (v: string) => void, + aria: string, +): HTMLElement { + const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } }); + for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text })); + if (!options.some((o) => o.value === value)) + s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") })); + s.value = value; + s.disabled = ctx.readOnly; + s.addEventListener("change", () => onPick(s.value)); + return s; +} + +function numberBox( + ctx: Ctx, + value: number | string, + onCommit: (v: number | string) => void, + aria: string, +): HTMLElement { + const i = el("input", { + className: "ui-input ui-input--sm m02-dflink-num", + attrs: { type: "text", "aria-label": aria }, + }); + i.value = String(value); + i.readOnly = ctx.readOnly; + i.addEventListener("change", () => { + const t = i.value.trim().replace(/,/g, ""); + onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t); + }); + return i; +} + +/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */ +const pillOptions = (ctx: Ctx): { value: string; text: string }[] => { + const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? ""; + return ctx.lg.boxes + .filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id)) + .flatMap((b) => + portsOut(b).map((p) => ({ + value: toKey({ box: b.id, name: p.name }), + text: portText(ctx.lg, { box: b.id, name: p.name }), + })), + ); +}; + +const row = (...children: (HTMLElement | string)[]): HTMLElement => + el("div", { className: "m02-dflink-row", children }); + +/* ── 규칙마다 ─────────────────────────────────────────────────────────── */ + +function lookupEditor(ctx: Ctx, rule: Extract): HTMLElement[] { + const cols = [ + ...new Set([...ctx.headers, ...rule.conds.map((c) => c.col), rule.col].filter(Boolean)), + ]; + const colOpts = cols.map((c) => ({ value: c, text: c })); + const pills = pillOptions(ctx); + const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); + const rows = rule.conds.map((c, i) => + row( + select( + ctx, + colOpts, + c.col, + (v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }), + tk("Col"), + ), + "=", + select( + ctx, + pills, + toKey(c.from), + (v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }), + tk("Match"), + ), + ctx.readOnly + ? "" + : createButton({ + label: tk("Remove"), + variant: "ghost", + size: "sm", + onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }), + }), + ), + ); + const out: HTMLElement[] = [...rows]; + if (!ctx.readOnly) { + out.push( + row( + createButton({ + label: tk("AddCond"), + variant: "ghost", + size: "sm", + disabled: !pills.length || !cols.length, + onClick: () => + put({ conds: [...rule.conds, { col: cols[0], from: fromKey(pills[0].value) }] }), + }), + ), + ); + } + out.push( + row( + el("span", { className: "m02-dflink-label", text: tk("Take") }), + select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")), + ), + ); + if (!cols.length) out.push(el("p", { className: "m02-dflink-note", text: tk("NoHeader") })); + return out; +} + +function gateEditor(ctx: Ctx, rule: Extract): HTMLElement[] { + const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); + return [ + el("p", { className: "m02-dflink-note", text: tk("GateHint") }), + row( + select( + ctx, + pillOptions(ctx), + toKey(rule.from), + (v) => put({ from: fromKey(v) }), + tk("Match"), + ), + select( + ctx, + GATE_OPS.map((o) => ({ value: o, text: o })), + rule.op, + (v) => put({ op: v as CfGateOp }), + tk("Kinds"), + ), + numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")), + ), + ]; +} + +function scaleEditor(ctx: Ctx, rule: Extract): HTMLElement[] { + return [ + el("p", { className: "m02-dflink-note", text: tk("ScaleHint") }), + row( + select( + ctx, + SCALE_OPS.map((o) => ({ value: o, text: o })), + rule.op, + (v) => setRule(ctx, { ...rule, op: v as typeof rule.op }), + tk("Kinds"), + ), + numberBox( + ctx, + rule.value, + (v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }), + tk("Value"), + ), + ), + ]; +} + +function roundEditor( + ctx: Ctx, + rule: Extract, + unit: string, +): HTMLElement[] { + const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); + const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) })); + const hint = el("span", { className: "m02-dflink-note" }); + void suggestPlaces(unit).then((n) => { + if (n === null || n === rule.places || ctx.readOnly) return; + hint.append( + tk("Suggest", n), + " ", + createButton({ + label: tk("Apply"), + variant: "ghost", + size: "sm", + onClick: () => put({ places: n }), + }), + ); + }); + return [ + row( + select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")), + select( + ctx, + (["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })), + rule.method, + (v) => put({ method: v as CfRound["method"] }), + tk("K_round"), + ), + hint, + ), + ]; +} + +const DEFAULT: Record, (from: CfPort) => CfRule> = { + lookup: () => ({ kind: "lookup", conds: [], col: "" }), + gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }), + scale: () => ({ kind: "scale", op: "×", value: 1 }), + round: () => ({ kind: "round", places: 0, method: "반올림" }), +}; + +/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */ +export function renderLinkPanel( + host: HTMLElement, + lg: CfLogic, + linkId: string, + on: { change(lg: CfLogic): void }, + opts: { values?: CfValues; readOnly?: boolean } = {}, +): void { + const link = lg.links.find((l) => l.id === linkId); + host.classList.add("m02-dflink"); + if (!link) return host.replaceChildren(); + const ev = evalSafe(lg, opts.values); + const table = valueOf(ev, link.from); + const headers = + Array.isArray(table) && Array.isArray(table[0]) + ? table[0].map((h) => String(h ?? "")).filter(Boolean) + : []; + const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change }; + const rule = link.rule; + const kind: Kind = rule?.kind ?? "none"; + const parts: HTMLElement[] = [ + el("div", { + className: "m02-dflink-head", + children: [ + el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }), + el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }), + ], + }), + row( + el("span", { className: "m02-dflink-label", text: tk("Kinds") }), + ...KINDS.map((k) => + createButton({ + label: tk(`K_${k}` as "K_none"), + variant: k === kind ? "filled" : "ghost", + size: "sm", + disabled: ctx.readOnly, + onClick: () => + k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)), + }), + ), + ), + ]; + if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule)); + if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule)); + if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule)); + if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from))); + + const pv = preview(link, rule, ev); + parts.push( + row( + el("span", { className: "m02-dflink-label", text: tk("Before") }), + el("span", { className: "m02-dflink-value", text: pv.before }), + "→", + el("span", { className: "m02-dflink-label", text: tk("After") }), + el("span", { className: "m02-dflink-value", text: pv.after }), + ), + row( + el("span", { className: "m02-dflink-label", text: tk("Label") }), + el("span", { text: labelOf(lg, rule) }), + ), + ); + + const warns: string[] = []; + if (rule?.kind === "gate") { + const rep = gateReport(lg, link, ev); + if (rep.pass.length > 1) warns.push(tk("WarnBoth", rep.pass.length)); + if (rep.total > 0 && rep.pass.length === 0) warns.push(tk("WarnNone", rep.total)); + } + if (rule?.kind === "lookup") { + if (!rule.conds.length) warns.push(tk("WarnLookup")); + if (!rule.col) warns.push(tk("WarnTake")); + } + parts.push(...warns.map((w) => el("p", { className: "m02-dflink-warn", text: `⚠ ${w}` }))); + host.replaceChildren(...parts); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_Material.ts new file mode 100644 index 000000000..01e1083b2 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Material.ts @@ -0,0 +1,121 @@ +/* ============================================================================= + * M02_DF_Material.ts + * 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials?q&sub&detail&page&size` + * → {total, page, size, items}(아직 없으면 M01 재료 찾기로 대신) · 단가는 `cfValues`(재료 ref = 키) + * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { searchElements } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { cfValues } from "./M02_DF_Api"; + +export interface CfMat { + ref: string; + name: string; + spec: string; + unit: string; +} + +export interface CfMatPage { + total: number; + page: number; + size: number; + items: CfMat[]; +} + +const text = (v: unknown): string => + typeof v === "string" || typeof v === "number" ? String(v) : ""; + +const norm = (it: Record): CfMat => ({ + ref: text(it.ref ?? it.키), + name: text(it.이름 ?? it.name), + spec: text(it.규격 ?? it.spec), + unit: text(it.단위 ?? it.unit), +}); + +/** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */ +export async function findMaterials(cond: { + q?: string; + sub?: string; + detail?: string; + page?: number; + size?: number; +}): Promise { + const page = cond.page ?? 1; + const size = cond.size ?? 10; + try { + const qs = new URLSearchParams({ + q: cond.q ?? "", + sub: cond.sub ?? "", + detail: cond.detail ?? "", + page: String(page), + size: String(size), + }); + const r = await fetch(`${API_BASE_URL}/m02/df/materials?${qs.toString()}`, { + credentials: "include", + }); + if (r.ok) { + const d = (await r.json()) as { + total: number; + page?: number; + size?: number; + items: Record[]; + }; + return { + total: d.total, + page: d.page ?? page, + size: d.size ?? size, + items: d.items.map(norm), + }; + } + } catch { + /* 서버 길이 아직 없으면 M01 재료 찾기로 */ + } + const got = await searchElements("재료", cond.q ?? ""); + const all = got.items.filter((it) => { + const x = it as unknown as Record; + return (!cond.sub || x.구분 === cond.sub) && (!cond.detail || x.상세구분 === cond.detail); + }); + const from = (page - 1) * size; + return { + total: all.length, + page, + size, + items: all.slice(from, from + size).map((it) => norm(it as unknown as Record)), + }; +} + +const names = new Map(); + +/** 재료 키 → 이름 · 규격 (한 번 물은 것은 다시 안 물음) */ +export async function materialOf(key: string): Promise { + if (!key) return null; + if (names.has(key)) return names.get(key) ?? null; + let hit: CfMat | null = null; + try { + const got = await searchElements("재료", key); + const it = got.items.find((x) => x.ref === key); + hit = it ? norm(it as unknown as Record) : null; + } catch { + hit = null; + } + names.set(key, hit); + return hit; +} + +const prices = new Map(); + +/** 재료 키 → 단가(원) — 못 구하면 null */ +export async function priceOf(key: string): Promise { + if (!key) return null; + if (prices.has(key)) return prices.get(key) ?? null; + let v: number | null = null; + try { + const a = (await cfValues([{ id: "p", group: "재료", ref: key }]))[0]; + v = a?.ok && typeof a.value === "number" ? a.value : null; + } catch { + v = null; + } + prices.set(key, v); + return v; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts index 6191c361d..73a18d287 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Model.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Model.ts @@ -193,6 +193,7 @@ const TEXT = { Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Taken: ["이미 선이 들어온 자리", "This spot already has a line"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], + CondPill: ["규칙의 조건 알약이 없음", "The rule's condition pill doesn't exist"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; @@ -309,57 +310,100 @@ export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic { /* ── 알약 ───────────────────────────────────────────────────────────────── */ -/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name - * 함수 = func.output.name 하나 · 출력 = 없음 */ +/** 요소 알약 이름 — 재료 선택 자리 · 조건 찾기는 자리 이름(slot) · 나머지는 element.name */ +export const elementPort = (e: CfElementRef): string => + e.mode && e.mode !== "fixed" && e.slot?.trim() ? e.slot.trim() : e.name; + +/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소(elementPort) · 표면 「표」 · 변수면 variable.name + * · 다른 로직이면 그 로직 출력 · 함수 = func.output.name 하나 · 출력 = 없음 */ export function portsOut(box: CfBox): CfPill[] { if (box.kind === "function" && box.func) return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }]; if (box.kind !== "input" || !box.input) return []; const inp = box.input; + if (inp.mode === "logic") return (inp.logic?.outputs ?? []).map((p) => ({ ...p })); return [ ...(inp.mode === "group" - ? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" })) + ? inp.elements.map((e) => ({ name: elementPort(e), unit: e.unit || "-" })) : []), ...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []), ...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })), ]; } -/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */ +/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 다른 로직 입력 = 그 로직의 설계 때 정할 것 · 그 밖 입력 = 없음 */ export function portsIn(box: CfBox): CfPill[] { if (box.kind === "function" && box.func) return box.func.inputs.map((i) => ({ name: i.port, unit: "-" })); if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }]; + if (box.input?.mode === "logic") return (box.input.logic?.designs ?? []).map((p) => ({ ...p })); return []; } +/** 알약 쪽 — 함수 출력 이름이면 out · 출력 상자는 in · 다른 로직 입력은 출력 이름이면 out */ +export function portDir(box: CfBox, name: string): "in" | "out" { + if (box.kind === "output") return "in"; + if (portsOut(box).some((p) => p.name === name)) return "out"; + return "in"; +} + /* ── 선 ─────────────────────────────────────────────────────────────────── */ -/** from 상자에서 선을 따라 to 상자에 닿나 */ +/** 선 규칙이 기대는 알약(조건 찾기 · 조건 통과) */ +export function ruleSources(rule: CfRule | undefined): CfPort[] { + if (!rule) return []; + if (rule.kind === "lookup") return rule.conds.map((c) => c.from); + if (rule.kind === "gate") return [rule.from]; + return []; +} + +/** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */ +export function depends(lg: CfLogic): { from: string; to: string }[] { + const out: { from: string; to: string }[] = []; + for (const l of lg.links) { + out.push({ from: l.from.box, to: l.to.box }); + for (const p of ruleSources(l.rule)) out.push({ from: p.box, to: l.to.box }); + } + for (const b of lg.boxes) + for (const e of b.input?.elements ?? []) + if (e.mode === "rule" && e.rule) out.push({ from: e.rule.from.box, to: b.id }); + return out; +} + +/** from 상자에서 기댐을 따라 to 상자에 닿나 */ export function reaches(lg: CfLogic, from: string, to: string): boolean { + const deps = depends(lg); const seen = new Set(); const walk = (id: string): boolean => { if (id === to) return true; if (seen.has(id)) return false; seen.add(id); - return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box)); + return deps.filter((d) => d.from === id).some((d) => walk(d.to)); }; return walk(from); } -/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 +/** 선 잇기 — 입력 → 함수 · 출력 · 다른 로직 입력 / 함수 → 함수 · 출력 · 다른 로직 입력 · 출력에서 나감 없음 · + * 순환 · 같은 받는 자리 두 번 막음 · 규칙(rule)은 함께 붙임(조건 알약이 없으면 막음) * 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 · * 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */ -export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } { +export function link( + lg: CfLogic, + from: CfPort, + to: CfPort, + rule?: CfRule, +): { lg: CfLogic; why: string | null } { const a = boxOf(lg, from.box); const b = boxOf(lg, to.box); if (!a || !b) return { lg, why: tl("NoPill") }; if (a.kind === "output") return { lg, why: tl("FromOutput") }; - if (b.kind === "input") return { lg, why: tl("ToInput") }; + if (b.kind === "input" && b.input?.mode !== "logic") return { lg, why: tl("ToInput") }; if (a.id === b.id) return { lg, why: tl("Self") }; if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") }; const has = portsIn(b).some((p) => p.name === to.name); if (!has && b.kind !== "function") return { lg, why: tl("NoPill") }; + const why = ruleWhy(lg, b.id, rule); + if (why) return { lg, why }; if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") }; if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name)) return { lg, why: tl("Taken") }; @@ -376,13 +420,37 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: "l", next.links.map((l) => l.id), ); - return { - lg: { - ...next, - links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }], - }, - why: null, - }; + const made: CfLink = { id, from: { ...from }, to: { box: to.box, name: port } }; + if (rule) made.rule = clone(rule); + return { lg: { ...next, links: [...next.links, made] }, why: null }; +} + +/** 규칙의 조건 알약 검사 — 없거나 돌고 돌면 까닭 */ +function ruleWhy(lg: CfLogic, target: string, rule: CfRule | undefined): string | null { + for (const p of ruleSources(rule)) { + const box = boxOf(lg, p.box); + if (!box || !portsOut(box).some((x) => x.name === p.name)) return tl("CondPill"); + if (box.id === target || reaches(lg, target, box.id)) return tl("Loop"); + } + return null; +} + +/** 선 규칙 바꾸기 · 빼기(null) — 조건 알약이 없거나 돌고 돌면 막고 까닭 */ +export function setRule( + lg: CfLogic, + id: string, + rule: CfRule | null, +): { lg: CfLogic; why: string | null } { + const l = lg.links.find((x) => x.id === id); + if (!l) return { lg, why: tl("NoPill") }; + const why = rule ? ruleWhy(lg, l.to.box, rule) : null; + if (why) return { lg, why }; + const links = lg.links.map((x) => { + if (x.id !== id) return x; + const { rule: _old, ...rest } = x; + return rule ? { ...rest, rule: clone(rule) } : rest; + }); + return { lg: { ...lg, links }, why: null }; } /** 같은 이름이면 「이름 (2)」 「이름 (3)」 */ @@ -403,23 +471,116 @@ export function unlink(lg: CfLogic, id: string): CfLogic { return updateBox(next, box.id, { func: { ...box.func, inputs } }); } -/* ── 흐름(넓힘 · 준비 중) ───────────────────────────────────────────────── */ +/* ── 흐름 따라가기 ──────────────────────────────────────────────────────── */ -const notReady = (): never => { - throw new Error("준비 중"); -}; - -/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터) */ -export function traceBack(_lg: CfLogic, _port: CfPort): CfStep[] { - return notReady(); +/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터 · 한 번씩) */ +export function traceBack(lg: CfLogic, port: CfPort): CfStep[] { + const steps: CfStep[] = []; + const seen = new Set(); + const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => { + const box = boxOf(lg, p.box); + const key = `${p.box}|${p.name}|${dir}`; + if (!box || seen.has(key)) return; + seen.add(key); + steps.push({ + box: p.box, + port: p.name, + dir, + ...(via ? { link: via.id } : {}), + ...(via?.rule ? { rule: clone(via.rule) } : {}), + }); + if (dir === "in") { + for (const l of lg.links.filter((x) => x.to.box === p.box && x.to.name === p.name)) { + visit(l.from, "out"); + for (const c of ruleSources(l.rule)) visit(c, "out"); + } + return; + } + // 내보내는 알약 — 함수 · 다른 로직은 받는 알약들에서 옴 · 재료 조건 찾기는 그 알약에서 옴 + for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsIn(box) : []) + visit( + { box: box.id, name: q.name }, + "in", + lg.links.find((l) => l.to.box === box.id && l.to.name === q.name), + ); + for (const e of box.input?.elements ?? []) + if (e.mode === "rule" && e.rule && elementPort(e) === p.name) visit(e.rule.from, "out"); + }; + const box = boxOf(lg, port.box); + if (!box) return []; + const dir = portDir(box, port.name); + visit( + port, + dir, + dir === "in" + ? lg.links.find((l) => l.to.box === port.box && l.to.name === port.name) + : undefined, + ); + return steps; } -/** 그 알약 값이 가는 길 */ -export function traceForward(_lg: CfLogic, _port: CfPort): CfStep[] { - return notReady(); +/** 그 알약 값이 가는 길 — 선 · 선 규칙의 조건으로 쓰이는 곳 · 재료 조건 찾기 · 함수 · 다른 로직을 지나 출력까지 */ +export function traceForward(lg: CfLogic, port: CfPort): CfStep[] { + const steps: CfStep[] = []; + const seen = new Set(); + const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => { + const box = boxOf(lg, p.box); + const key = `${p.box}|${p.name}|${dir}`; + if (!box || seen.has(key)) return; + seen.add(key); + steps.push({ + box: p.box, + port: p.name, + dir, + ...(via ? { link: via.id } : {}), + ...(via?.rule ? { rule: clone(via.rule) } : {}), + }); + if (dir === "in") { + // 받는 알약 → 그 상자의 내보내는 알약 + for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsOut(box) : []) + visit({ box: box.id, name: q.name }, "out"); + return; + } + for (const l of lg.links.filter((x) => x.from.box === p.box && x.from.name === p.name)) + visit(l.to, "in", l); + // 이 알약을 조건으로 쓰는 선 · 재료 조건 찾기 + for (const l of lg.links.filter((x) => + ruleSources(x.rule).some((c) => c.box === p.box && c.name === p.name), + )) + visit(l.to, "in", l); + for (const b of lg.boxes) + for (const e of b.input?.elements ?? []) + if (e.mode === "rule" && e.rule?.from.box === p.box && e.rule.from.name === p.name) + visit({ box: b.id, name: elementPort(e) }, "out"); + }; + const box = boxOf(lg, port.box); + if (!box) return []; + visit(port, portDir(box, port.name)); + return steps; } -/** 설계 때 정할 것 — 변수 · 재료 자리(선택) · 다른 로직의 받는 알약(선이 없을 때) */ -export function designInputs(_lg: CfLogic): CfDesign[] { - return notReady(); +/* ── 설계 때 정할 것 ─────────────────────────────────────────────────────── */ + +/** 설계 때 정할 것 — 변수(입력 · 함수 상자) · 재료 선택 자리 · 다른 로직의 받는 알약(선이 없을 때) */ +export function designInputs(lg: CfLogic): CfDesign[] { + const out: CfDesign[] = []; + for (const b of lg.boxes) { + const vars = b.input?.variables ?? b.func?.variables ?? []; + for (const v of vars) + out.push({ box: b.id, port: v.name, kind: "변수", unit: v.unit || "-", variable: clone(v) }); + for (const e of b.input?.mode === "group" ? b.input.elements : []) + if (e.mode === "choice") + out.push({ + box: b.id, + port: elementPort(e), + kind: "재료 자리", + unit: e.unit || "-", + element: clone(e), + }); + if (b.input?.mode === "logic") + for (const d of b.input.logic?.designs ?? []) + if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name)) + out.push({ box: b.id, port: d.name, kind: "다른 로직", unit: d.unit || "-" }); + } + return out; } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css index ab6b108ad..27bc226b2 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.css @@ -83,3 +83,8 @@ color: var(--color-danger); font-size: var(--text-caption); } + +.m02-dfout__round { + margin: var(--spacing-8) 0; + color: var(--color-text-body); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts index 9e182d999..70d4f4537 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_OutputPanel.ts @@ -6,10 +6,11 @@ * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval"; -import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_DF_Model"; +import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model"; +import { defaultItemRound, roundText } from "./M02_DF_Round"; import "./M02_DF_OutputPanel.css"; const TEXT = { @@ -32,6 +33,14 @@ const TEXT = { Table: ["표", "Table"], Calc: ["계산 중이 아니에요", "Not calculated"], Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + Round: ["끝수", "Rounding"], + RoundPlaces: ["자리", "Places"], + RoundMethod: ["방법", "Method"], + RoundDefault: ["끝수 기본", "Default rounding"], + RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"], + RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"], + RoundReset: ["기본으로", "Back to default"], + RoundUser: ["고른 끝수", "Chosen rounding"], PlainHint: [ "원가 합계에 안 넣고 값으로만 보여요", "Shown as a value, not added to the cost total", @@ -152,7 +161,7 @@ export function renderOutputPanel( emit(() => withBox(lg, box.id, (b) => ({ ...b, - output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost }, + output: { ...b.output, unit: b.output?.unit ?? b.unit, cost: v as CfCost }, })), ), }); @@ -167,7 +176,7 @@ export function renderOutputPanel( withBox(lg, box.id, (b) => ({ ...b, unit: next, - output: { cost: b.output?.cost ?? "노무비", unit: next }, + output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next }, })), ); }); @@ -177,6 +186,76 @@ export function renderOutputPanel( children: [el("span", { text: label }), control], }); + /* 끝수 — 기본 = 일위대가표 금액란 0.1원 미만 버림(표준 표를 읽음) · 값(비용 아님)은 정하지 않으면 그대로 */ + const round = box.output?.round; + const stepName = (n: number): string => (n === 0 ? "1" : String(Number((10 ** -n).toFixed(n)))); + let fallback: CfRound = { places: 1, method: "버림" }; + const roundNote = el("span", { className: "m02-dfout__muted" }); + const noteOf = (r: CfRound | undefined): string => + r + ? `${to("RoundUser")}: ${roundText(r)}` + : cost === "값" + ? to("RoundNone") + : `${to("RoundDefault")}: ${to("RoundStd")} ${roundText(fallback)}`; + roundNote.textContent = noteOf(round); + if (cost !== "값") + void defaultItemRound().then((r) => { + fallback = r; + roundNote.textContent = noteOf(round); + }); + const setRound = (next: CfRound | undefined): void => + emit(() => + withBox(lg, box.id, (b) => { + const output = { ...(b.output ?? { cost, unit: b.unit }) }; + if (next) output.round = next; + else delete output.round; + return { ...b, output }; + }), + ); + const placesField = createSelectField({ + options: [ + { value: "", text: cost === "값" ? to("RoundNone") : to("RoundDefault") }, + ...[0, 1, 2, 3, 4].map((n) => ({ value: String(n), text: `${stepName(n)}원 단위` })), + ], + value: round ? String(round.places) : "", + compact: true, + ariaLabel: to("RoundPlaces"), + onChange: (v) => + setRound(v === "" ? undefined : { places: Number(v), method: round?.method ?? "버림" }), + }); + const methodField = createSelectField({ + options: (["버림", "올림", "반올림"] as const).map((m) => ({ value: m, text: `미만 ${m}` })), + value: round?.method ?? fallback.method, + compact: true, + disabled: !round, + ariaLabel: to("RoundMethod"), + onChange: (v) => round && setRound({ places: round.places, method: v as CfRound["method"] }), + }); + const roundBlock = el("div", { + className: "m02-dfout__round", + children: [ + el("strong", { text: to("Round") }), + el("div", { + className: "m02-dfout__fields", + children: [ + field(to("RoundPlaces"), placesField.root), + field(to("RoundMethod"), methodField.root), + ...(round + ? [ + createButton({ + label: to("RoundReset"), + size: "sm", + variant: "ghost", + onClick: () => setRound(undefined), + }), + ] + : []), + ], + }), + roundNote, + ], + }); + host.replaceChildren( el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }), el("div", { @@ -189,6 +268,7 @@ export function renderOutputPanel( }), ], }), + roundBlock, ...(cost === "값" ? [el("p", { className: "m02-dfout__muted", text: to("PlainHint") })] : []), msg, ); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts new file mode 100644 index 000000000..3c92bea06 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts @@ -0,0 +1,139 @@ +/* ============================================================================= + * M02_DF_PillPanel.ts + * 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfValues } from "./M02_DF_Eval"; +import type { CfLogic, CfStep } from "./M02_DF_Model"; +import { + boxOf, + breakLines, + evalSafe, + isOut, + labelOf, + portText, + showValue, + trace, + unitOf, + valueOf, +} from "./M02_DF_Detail"; +import "./M02_DF_LinkPanel.css"; + +const TEXT = { + Name: ["이름", "Name"], + Value: ["값", "Value"], + Unit: ["단위", "Unit"], + Kind_out: ["내보내는 알약", "Sending pill"], + Kind_in: ["받는 알약", "Receiving pill"], + From: ["온 길", "Where it came from"], + To: ["가는 곳", "Where it goes"], + Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"], + End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"], + Breaks: ["끊긴 곳", "Breaks"], + NoBreak: ["끊긴 곳 없음", "No breaks"], + Open: ["이 상자 보기", "Open this box"], + Line: ["선", "Line"], +} as const; + +const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement => + el("div", { + className: "m02-dflink-row", + children: [el("span", { className: "m02-dflink-label", text: label }), ...value], + }); + +function steps( + lg: CfLogic, + list: CfStep[], + empty: string, + onBox?: (id: string) => void, +): HTMLElement { + if (!list.length) return el("p", { className: "m02-dflink-note", text: empty }); + return el("ol", { + className: "m02-dfpill-list", + children: list.map((s) => { + const parts: (HTMLElement | string)[] = [ + s.dir === "out" ? "◀" : "▶", + onBox + ? createButton({ + label: portText(lg, { box: s.box, name: s.port }), + variant: "ghost", + size: "sm", + onClick: () => onBox(s.box), + }) + : portText(lg, { box: s.box, name: s.port }), + ]; + if (s.rule) + parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(lg, s.rule) })); + return el("li", { className: "m02-dfpill-step", children: parts }); + }), + }); +} + +/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */ +export function renderPillPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + port: string, + on: { pick?(boxId: string): void } = {}, + opts: { values?: CfValues } = {}, +): void { + host.classList.add("m02-dfpill"); + const box = boxOf(lg, boxId); + if (!box) return host.replaceChildren(); + const ev = evalSafe(lg, opts.values); + const out = isOut(box, port); + // 받는 알약의 값 = 그 자리로 들어오는 선의 보낸 알약 값(규칙 지나기 전) + const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port); + const value = out + ? valueOf(ev, { box: boxId, name: port }) + : incoming + ? valueOf(ev, incoming.from) + : undefined; + const unit = out + ? unitOf(lg, { box: boxId, name: port }) + : incoming + ? unitOf(lg, incoming.from) + : box.unit; + const back = trace(lg, { box: boxId, name: port }, "in"); + const forward = trace(lg, { box: boxId, name: port }, "out"); + const breaks = breakLines(lg, boxId, port); + host.replaceChildren( + el("div", { + className: "m02-dflink-head", + children: [ + el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }), + el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }), + ], + }), + line(tp("Name"), port), + line(tp("Value"), el("span", { className: "m02-dflink-value", text: showValue(value) })), + line(tp("Unit"), unit || "-"), + el("div", { + children: [ + el("div", { className: "m02-dflink-label", text: tp("From") }), + steps(lg, back, tp("Start"), on.pick), + ], + }), + el("div", { + children: [ + el("div", { className: "m02-dflink-label", text: tp("To") }), + steps(lg, forward, tp("End"), on.pick), + ], + }), + el("div", { + children: [ + el("div", { className: "m02-dflink-label", text: tp("Breaks") }), + ...(breaks.length + ? breaks.map((b) => el("p", { className: "m02-dflink-warn", text: `⚠ ${b}` })) + : [el("p", { className: "m02-dflink-note", text: tp("NoBreak") })]), + ], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts new file mode 100644 index 000000000..b4976a9d7 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -0,0 +1,101 @@ +/* ============================================================================= + * M02_DF_Round.ts + * 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어 + * 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림) + * 표 읽기 = 서버 `GET /api/m02/df/standards` → {unit, money, saved} · 아직 없으면 `_standard/금액단위표준.json` + * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { CfRound } from "./M02_DF_Model"; + +/** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */ +export interface CfMoneyRow { + 종목: string; + 단위?: string; + 지위: number; + 비고?: string; +} + +const FILES = import.meta.glob<{ 줄?: CfMoneyRow[] }>("./_standard/금액단위표준.json", { + eager: true, + import: "default", +}); + +/** 표 모양 둘(`{줄: […]}` · 줄 목록)을 줄 목록으로 */ +const rowsOf = (x: unknown): CfMoneyRow[] => { + const list = Array.isArray(x) ? x : (x as { 줄?: unknown } | null)?.줄; + return Array.isArray(list) ? (list as CfMoneyRow[]) : []; +}; + +let loaded: Promise | null = null; + +/** 금액 단위 표준 줄 — 사용자가 고쳐 저장한 것이 있으면 그것 · 서버가 없으면 처음 값 */ +export function loadMoneyStandard(): Promise { + loaded ??= (async () => { + try { + const r = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" }); + if (r.ok) { + const d = (await r.json()) as { money?: unknown; saved?: { money?: unknown } | null }; + const rows = rowsOf(d.saved?.money ?? d.money); + if (rows.length) return rows; + } + } catch { + /* 서버가 없으면 처음 값 */ + } + return rowsOf(Object.values(FILES)[0]); + })(); + return loaded; +} + +/** 표를 다시 읽게(표를 고친 뒤) */ +export function resetStandard(): void { + loaded = null; +} + +/** 표 줄 → 끝수 — 지위 0.1 = 소수 1자리 · 1 이상 = 0자리 · 비고 「미만버림」 */ +export function roundOf(row: CfMoneyRow): CfRound { + const step = row.지위 > 0 ? row.지위 : 1; + const method = + /올림/.test(row.비고 ?? "") && !/버림/.test(row.비고 ?? "") + ? "올림" + : /반올림/.test(row.비고 ?? "") + ? "반올림" + : "버림"; + return { places: step >= 1 ? 0 : Math.round(-Math.log10(step)), method }; +} + +const find = (rows: CfMoneyRow[], word: string): CfMoneyRow | undefined => + rows.find((r) => r.종목.includes("일위대가표") && r.종목.includes(word)); + +/** 출력(비용) 기본 — 일위대가표 금액란 0.1원 미만 버림 */ +export async function defaultItemRound(): Promise { + const row = find(await loadMoneyStandard(), "금액란"); + return row ? roundOf(row) : { places: 1, method: "버림" }; +} + +/** 원가 합계 기본 — 일위대가표 계금 1원 미만 버림 */ +export async function defaultTotalRound(): Promise { + const row = find(await loadMoneyStandard(), "계금"); + return row ? roundOf(row) : { places: 0, method: "버림" }; +} + +/** 끝수 적용 — 소수 자리 · 방법(부동소수 오차는 아주 작은 값으로 눌러 줌) */ +export function roundBy(r: CfRound, v: number): number { + const k = 10 ** r.places; + const x = v * k; + const eps = Math.abs(x) * 1e-12 + 1e-9; + const f = + r.method === "올림" + ? Math.ceil(x - eps) + : r.method === "반올림" + ? Math.round(x + eps) + : Math.floor(x + eps); + return f / k; +} + +/** 「0.1원 미만 버림」 꼴 쉬운 말 */ +export function roundText(r: CfRound): string { + const step = r.places > 0 ? Number((10 ** -r.places).toFixed(r.places)) : 1; + return `${step}원 미만 ${r.method}`; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts b/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts new file mode 100644 index 000000000..c1bf3fad8 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts @@ -0,0 +1,78 @@ +/* ============================================================================= + * M02_DF_RoundRow.ts + * 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추) + * 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfRound } from "./M02_DF_Model"; + +const TEXT = { + Label: ["끝수", "Rounding"], + None: ["안 씀", "none"], + Places: ["소수 {n}자리", "{n} decimals"], + Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"], + Apply: ["적용", "Apply"], + PlacesAria: ["소수자리", "Decimal places"], + ModeAria: ["방법", "Method"], +} as const; + +const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"]; + +const tr = (key: keyof typeof TEXT, n?: number): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? "")); + +export interface RoundRowOptions { + /** 지금 끝수 — 없으면 undefined */ + round: CfRound | undefined; + /** 단위 표준이 제안한 소수자리 */ + suggest: number | null; + readOnly?: boolean; + /** null = 끝수 벗김 */ + onApply(round: CfRound | null): void; +} + +export function createRoundRow(opts: RoundRowOptions): HTMLElement { + const now = opts.round ?? null; + const places = el("select", { + className: "ui-select ui-select--sm", + attrs: { "aria-label": tr("PlacesAria") }, + }); + places.append(el("option", { attrs: { value: "" }, text: tr("None") })); + for (let n = 0; n <= 6; n++) { + places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) })); + } + places.value = now ? String(now.places) : ""; + const method = el("select", { + className: "ui-select ui-select--sm", + attrs: { "aria-label": tr("ModeAria") }, + }); + for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m })); + method.value = now?.method ?? "반올림"; + if (opts.readOnly) places.disabled = method.disabled = true; + const emit = (): void => + opts.onApply( + places.value === "" + ? null + : { places: Number(places.value), method: method.value as CfRound["method"] }, + ); + places.addEventListener("change", emit); + method.addEventListener("change", () => places.value !== "" && emit()); + const row = el("div", { + className: "m02-dfpanel-row", + children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method], + }); + if (opts.suggest !== null && !now && !opts.readOnly) { + row.append( + el("span", { className: "m02-dfpanel-note", text: tr("Suggest", opts.suggest) }), + createButton({ + label: tr("Apply"), + variant: "ghost", + size: "sm", + onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }), + }), + ); + } + return row; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts index e8f410de8..99b13b959 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts @@ -1,33 +1,210 @@ /* ============================================================================= * M02_DF_Rule.ts * 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음 + * + * 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식 + * lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림) → 맞은 줄 수 · INDEX/MATCH + * gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN * ========================================================================== */ -import type { CfRound, CfRule } from "./M02_DF_Model"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address"; +import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; +import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { CfGateOp, CfRound, CfRule } from "./M02_DF_Model"; + +type Scalar = number | string | boolean | null; /** 규칙을 지난 값 — 통과 안 됨 · 표에 없는 조건 · 둘 이상 맞음은 value null + why */ export interface CfRuleResult { - value: number | string | boolean | null; + value: Scalar; why: "통과 안 됨" | "표에 없는 조건" | "표에 여럿 맞음" | "오류" | null; /** 받는 칸에 들어갈 엑셀 수식(보이기 · 시험) */ formula: string; } +const TEXT = { + Lookup: ["표에서 찾기", "Look up in table"], + Gate: ["일 때만", "only when"], + Places: ["소수 {n}자리", "{n} decimals"], + Round: ["반올림", "round"], + Up: ["올림", "round up"], + Down: ["버림", "round down"], +} as const; + +const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 엑셀 비교 기호 */ +const OP: Record = { + "=": "=", + "≠": "<>", + "≥": ">=", + "≤": "<=", + ">": ">", + "<": "<", +}; +const ROUND_FN: Record = { + 반올림: "ROUND", + 올림: "ROUNDUP", + 버림: "ROUNDDOWN", +}; + /** 선 위에 늘 보일 규칙 글 */ -export function ruleLabel(_rule: CfRule): string { - throw new Error("준비 중"); +export function ruleLabel(rule: CfRule): string { + switch (rule.kind) { + case "lookup": { + const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · "); + return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`; + } + case "gate": + return `${rule.from.name} ${rule.op} ${rule.value} ${tl("Gate")}`; + case "scale": + return rule.op === "+%" || rule.op === "-%" + ? `${rule.op[0]}${rule.value}%` + : `${rule.op} ${rule.value}`; + case "round": + return `${tl("Places").replace("{n}", String(rule.places))} ${roundWord(rule.method)}`; + } } -/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 값(lookup · gate) */ +const roundWord = (m: CfRound["method"]): string => + m === "반올림" ? tl("Round") : m === "올림" ? tl("Up") : tl("Down"); + +/* ── 엔진 한 번 ─────────────────────────────────────────────────────────── */ + +const fromCalc = (v: CalcValue | undefined): Scalar => { + if (!v) return null; + if ("수" in v) return Number(v.수); + if ("글" in v) return v.글; + if ("참" in v) return v.참; + return v.오류; +}; + +const cellOf = (v: unknown): Cell | null => + v === null || v === undefined || v === "" + ? null + : typeof v === "number" || typeof v === "string" || typeof v === "boolean" + ? { 값: v } + : { 값: String(v) }; + +/** 칸들을 시트 하나로 셈 → 읽을 칸 값 */ +function run(칸: Record, read: string[]): Scalar[] { + const id = "rule"; + const values = recalcWorkbook({ + 종류: "통합문서", + 판: 1, + 열: "DF", + 서식: [{}], + 시트: [{ id, 이름: "규칙", 칸 }], + }); + return read.map((a) => + 칸[a]?.식 !== undefined ? fromCalc(values[id]?.[a]) : (칸[a]?.값 ?? null), + ); +} + +const isError = (v: Scalar): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); +const lit = (v: number | string): string => + typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`; + +/* ── 규칙 ───────────────────────────────────────────────────────────────── */ + +/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */ export function applyRule( - _rule: CfRule, - _value: unknown, - _conds: Record = {}, + rule: CfRule, + value: unknown, + conds: Record = {}, ): CfRuleResult { - throw new Error("준비 중"); + if (rule.kind === "lookup") return lookup(rule, value, conds); + const 칸: Record = {}; + const a1 = cellOf(Array.isArray(value) ? null : value); + if (a1) 칸.A1 = a1; + let formula: string; + if (rule.kind === "gate") { + const c1 = cellOf(conds[rule.from.name]); + if (c1) 칸.C1 = c1; + formula = `IF(C1${OP[rule.op]}${lit(rule.value)},A1,NA())`; + } else if (rule.kind === "scale") { + const v = rule.value; + formula = + rule.op === "×" + ? `A1*${v}` + : rule.op === "÷" + ? `A1/${v}` + : rule.op === "+%" + ? `A1*(1+${v}/100)` + : `A1*(1-${v}/100)`; + } else formula = `${ROUND_FN[rule.method]}(A1,${rule.places})`; + 칸.B1 = { 식: formula }; + const [got] = run(칸, ["B1"]); + if (rule.kind === "gate" && got === "#N/A") return { value: null, why: "통과 안 됨", formula }; + if (isError(got)) return { value: null, why: "오류", formula }; + return { value: got, why: null, formula }; +} + +/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) */ +function lookup( + rule: Extract, + value: unknown, + conds: Record, +): CfRuleResult { + const block = Array.isArray(value) ? (value as unknown[][]) : []; + const head = (block[0] ?? []).map((h) => String(h ?? "")); + const rows = block.length - 1; + const W = head.length; + const 칸: Record = {}; + block.forEach((line, r) => + line.forEach((x, c) => { + const cell = cellOf(x); + if (cell) 칸[`${colName(c)}${r + 1}`] = cell; + }), + ); + const at = (name: string): string | null => { + const i = head.indexOf(name); + return i < 0 ? null : colName(i); + }; + const out = at(rule.col); + // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 + const tests: ((r: number) => string)[] = []; + let bad = !out || rows < 1; + rule.conds.forEach((c, i) => { + const ref = `$${colName(W + 1 + i)}$1`; + const cv = cellOf(conds[c.from.name]); + if (cv) 칸[`${colName(W + 1 + i)}1`] = cv; + const same = at(c.col); + const low = at(`${c.col}·아래`); + const high = at(`${c.col}·위`); + if (same) tests.push((r) => `${same}${r}=${ref}`); + else if (low && high) + tests.push( + (r) => + `AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r}))`, + ); + else bad = true; + }); + const hc = colName(W + 1 + rule.conds.length); + const last = rows + 1; + for (let r = 2; r <= last; r++) + 칸[`${hc}${r}`] = { + 식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1", + }; + const range = `${hc}2:${hc}${Math.max(2, last)}`; + const count = `${colName(W + 2 + rule.conds.length)}1`; + const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()"; + 칸[count] = { 식: `SUM(${range})` }; + 칸.ZZ1 = { 식: formula }; + if (bad) return { value: null, why: "오류", formula }; + const [n, got] = run(칸, [count, "ZZ1"]); + if (n === 0) return { value: null, why: "표에 없는 조건", formula }; + if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula }; + if (isError(got)) return { value: null, why: "오류", formula }; + return { value: got, why: null, formula }; } /** 끝수 적용 — 엔진 ROUND · ROUNDUP · ROUNDDOWN */ -export function applyRound(_round: CfRound, _value: number): number { - throw new Error("준비 중"); +export function applyRound(round: CfRound, value: number): number { + const [got] = run( + { A1: { 값: value }, B1: { 식: `${ROUND_FN[round.method]}(A1,${round.places})` } }, + ["B1"], + ); + return typeof got === "number" ? got : value; } 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/M02_DF_Summary.ts b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts index ec87048f9..ed996f5cd 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Summary.ts @@ -7,11 +7,13 @@ * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createSelectField, el } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements"; import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { checkLogic, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; -import type { CfBox, CfLogic, CfVariable } from "./M02_DF_Model"; +import type { CfEvalResult, CfValues } from "./M02_DF_Eval"; +import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model"; import { showValue } from "./M02_DF_OutputPanel"; +import { defaultTotalRound, roundBy, roundText } from "./M02_DF_Round"; +import { renderVerify, type CfSel } from "./M02_DF_Verify"; import "./M02_DF_Summary.css"; const TEXT = { @@ -27,18 +29,7 @@ const TEXT = { HValue: ["값", "Value"], HUnit: ["단위", "Unit"], Plain: ["값(비용 아님)", "Values (not costs)"], - Sample: ["예시값", "Sample values"], - SampleHint: [ - "여기서 바꾸면 변수 상자의 예시값도 바뀌어요", - "Changing here changes the variable box too", - ], - Problems: ["고칠 것", "To fix"], - NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"], - NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], - Bad: ["숫자를 넣어 주세요", "Enter a number"], - Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], - Pick: ["고르세요", "Choose"], - Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + RoundTotal: ["합계 끝수", "Total rounding"], Plain3: ["값", "Value"], } as const; @@ -54,67 +45,16 @@ const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" const costText = (c: string): string => (c === "값" ? ts("Plain3") : c); -/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */ -function emptyPlaces(lg: CfLogic): string[] { - const outs = lg.boxes.filter((b) => b.kind === "output"); - if (!outs.length) return [ts("NoOutput")]; - return outs - .filter((o) => !lg.links.some((l) => l.to.box === o.id)) - .map((o) => ts("NoLink", { box: o.name })); -} - -/** 변수 상자의 예시값 칸 하나 */ -function sampleField( - v: CfVariable, - put: (value: number | string) => void, - msg: HTMLElement, -): HTMLElement { - const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); - let control: HTMLElement; - if (v.type === "choice" && v.choices?.length) { - control = createSelectField({ - options: [ - { value: "", text: ts("Pick") }, - ...v.choices.map((c) => ({ value: String(c), text: String(c) })), - ], - value: v.example === null ? "" : String(v.example), - compact: true, - ariaLabel: v.name, - onChange: (s) => { - const hit = v.choices?.find((c) => String(c) === s); - if (hit !== undefined) put(hit); - }, - }).root; - } else { - const box = el("input", { - className: "m02-dfsum__input", - attrs: { - type: "text", - inputmode: v.type === "number" ? "decimal" : "text", - "aria-label": v.name, - }, - }); - if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) }); - box.value = v.example === null ? "" : String(v.example); - box.addEventListener("change", () => { - const raw = box.value.trim(); - if (raw === "") return; - if (v.type !== "number") return put(raw); - const n = Number(raw); - if (Number.isNaN(n)) msg.textContent = ts("Bad"); - else put(n); - }); - control = box; - } - return el("label", { className: "m02-dfsum__field", children: [label, control] }); -} +/** 원가 합계 끝수 — 기본 = 일위대가표 계금 1원 미만 버림(표를 읽어 오면 그 값) */ +let totalRound: CfRound = { places: 0, method: "버림" }; +void defaultTotalRound().then((r) => (totalRound = r)); /** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */ export function renderSummary( host: HTMLElement, lg: CfLogic, result: CfEvalResult, - on?: { change(lg: CfLogic): void }, + on?: { change(lg: CfLogic): void; select?(sel: CfSel): void }, opts?: { values?: CfValues }, ): void { const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); @@ -127,14 +67,14 @@ export function renderSummary( className: "m02-dfsum__big", children: [ el("span", { text: money ? perText : ts("Total") }), - el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }), + el("strong", { text: `${formatMoney(roundBy(totalRound, cost.계))} ${ts("Won")}` }), ], }); const costs = el("dl", { className: "m02-dfsum__costs", children: COSTS.flatMap((c) => [ el("dt", { text: ts(COST_KEY[c]) }), - el("dd", { text: formatMoney(cost[c]) }), + el("dd", { text: formatMoney(roundBy(totalRound, cost[c])) }), ]), }); @@ -185,52 +125,14 @@ export function renderSummary( ], }); - /* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */ - let checked: string[] = []; - try { - checked = checkLogic(lg, opts?.values ?? {}); - } catch { - checked = []; - } - const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])]; - - /* 예시값 — 변수 상자의 변수마다 */ - const msg = el("p", { className: "m02-dfsum__msg" }); - const fields = lg.boxes - .filter((b) => b.kind === "input" && b.input?.mode === "variable") - .flatMap((b) => - (b.input?.variables ?? []).map((v, i) => - sampleField( - v, - (value) => { - msg.textContent = ""; - if (!on) return; - try { - on.change({ - ...lg, - boxes: lg.boxes.map((x) => - x.id === b.id && x.input - ? { - ...x, - input: { - ...x.input, - variables: x.input.variables.map((y, j) => - j === i ? { ...y, example: value } : y, - ), - }, - } - : x, - ), - }); - } catch { - msg.textContent = ts("Cant"); - } - }, - msg, - ), - ), - ); - + const verifyHost = el("div", { className: "m02-dfsum__verify" }); + renderVerify( + verifyHost, + lg, + result, + { change: (next) => on?.change(next), select: on?.select }, + { values: opts?.values }, + ); const plainValues = Object.entries(cost.값들); host.replaceChildren( big, @@ -251,29 +153,10 @@ export function renderSummary( }), ] : []), - ...(fields.length - ? [ - el("div", { - className: "m02-dfsum__strip", - children: [ - el("strong", { text: ts("Sample") }), - el("div", { className: "m02-dfsum__fields", children: fields }), - el("p", { className: "m02-dfsum__muted", text: ts("SampleHint") }), - msg, - ], - }), - ] - : []), - ...(problems.length - ? [ - el("div", { - className: "m02-dfsum__problems", - children: [ - el("strong", { text: ts("Problems") }), - el("ul", { children: problems.map((t) => el("li", { text: t })) }), - ], - }), - ] - : []), + el("p", { + className: "m02-dfsum__muted", + text: `${ts("RoundTotal")}: ${roundText(totalRound)}`, + }), + verifyHost, ); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.css b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.css new file mode 100644 index 000000000..cf2f43126 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.css @@ -0,0 +1,56 @@ +.m02-df-unitpick { + position: relative; + display: inline-block; +} + +.m02-df-unitpick__panel { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 14em; + max-height: 20em; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.m02-df-unitpick__list { + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; +} + +.m02-df-unitpick__item { + display: block; + width: 100%; + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-df-unitpick__item.is-on, +.m02-df-unitpick__item:hover { + background: var(--color-surface-raised); +} + +.m02-df-unitpick__item.is-now { + font-weight: var(--font-weight-bold); +} + +.m02-df-unitpick__empty { + padding: var(--spacing-8); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts new file mode 100644 index 000000000..aabe898b4 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts @@ -0,0 +1,181 @@ +/* ============================================================================= + * M02_DF_UnitPick.ts + * 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음) · 「-」 = 없음 + * 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원 + * 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 처음 값 `_standard/단위표준.json`) — 다른 판도 이것을 씀 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import "./M02_DF_UnitPick.css"; + +const TEXT = { + None: ["없음", "none"], + Filter: ["단위를 쳐서 찾음", "Type to filter units"], + Empty: ["맞는 단위가 없음 — 목록에서만 고름", "No match — pick from the list only"], + Pick: ["단위 고르기", "Pick a unit"], +} as const; + +const tu = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 「없음」 을 뜻하는 단위 글 */ +export const NO_UNIT = "-"; + +/** 표준 표에서 읽은 것 — 단위 목록 · 단위마다 소수자리 제안(단위수량 쪽에서 가장 많이 쓴 자리) */ +export interface UnitStandard { + units: string[]; + places: Record; +} + +type UnitSource = () => Promise; + +interface StandardRow { + "규격 단위"?: string | null; + "단위수량 단위"?: string | null; + "단위수량 소수자리"?: number | null; +} + +/** 처음 값 — 번들에 든 표준 표(단위표준.json) */ +const bundled: UnitSource = async () => { + const table = (await import("./_standard/단위표준.json")).default as { 줄: StandardRow[] }; + const units = table.줄 + .flatMap((r) => [r["규격 단위"], r["단위수량 단위"]]) + .filter((u): u is string => !!u); + const count: Record> = {}; + for (const r of table.줄) { + const u = r["단위수량 단위"]; + const p = r["단위수량 소수자리"]; + if (!u || u === "없음" || typeof p !== "number") continue; + count[u] = { ...count[u], [p]: (count[u]?.[p] ?? 0) + 1 }; + } + const places = Object.fromEntries( + Object.entries(count).map(([u, c]) => [ + u, + Number(Object.entries(c).sort((a, b) => b[1] - a[1])[0][0]), + ]), + ); + return { units, places }; +}; + +let source: UnitSource = bundled; +let cache: Promise | null = null; + +/** 표준 표 읽는 길 바꾸기(서버에서 고친 표를 읽을 때) — 목록을 다시 받음 */ +export function setUnitSource(next: UnitSource): void { + source = next; + cache = null; +} + +/** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 */ +export function loadStandard(): Promise { + cache ??= source().then((std) => { + const set = new Set([...std.units, "원"]); + set.delete("없음"); + set.delete(NO_UNIT); + return { units: [...set], places: std.places }; + }); + return cache; +} + +/** 단위를 고르면 표준 표에서 소수자리 제안 — 없으면 null */ +export async function suggestPlaces(unit: string): Promise { + return (await loadStandard()).places[unit] ?? null; +} + +export interface UnitPickOptions { + /** 지금 단위 — 「-」 = 없음 */ + value: string; + onPick(unit: string): void; + /** 더 보일 단위(마스터 결과단위 등) */ + extra?: string[]; + readOnly?: boolean; + ariaLabel?: string; +} + +/** 단위 고르기 — 단추를 누르면 걸러지는 목록 · 목록에서 고르기만 됨 */ +export function createUnitPick(opts: UnitPickOptions): HTMLElement { + const label = (u: string): string => (u === NO_UNIT ? `${NO_UNIT} (${tu("None")})` : u); + const root = el("span", { className: "m02-df-unitpick" }); + const open = createButton({ + label: opts.value || NO_UNIT, + variant: "ghost", + size: "sm", + disabled: opts.readOnly, + onClick: () => toggle(), + }); + open.setAttribute("aria-label", `${opts.ariaLabel ?? tu("Pick")}: ${opts.value || NO_UNIT}`); + open.setAttribute("aria-haspopup", "listbox"); + root.append(open); + if (opts.readOnly) return root; + + const input = el("input", { + className: "ui-input ui-input--sm m02-df-unitpick__filter", + attrs: { type: "text", placeholder: tu("Filter"), "aria-label": tu("Filter") }, + }); + const list = el("ul", { className: "m02-df-unitpick__list", attrs: { role: "listbox" } }); + const panel = el("div", { className: "m02-df-unitpick__panel", children: [input, list] }); + panel.hidden = true; + root.append(panel); + + let all: string[] = []; + let shown: string[] = []; + let at = 0; + const pick = (u: string): void => { + close(); + if (u !== (opts.value || NO_UNIT)) opts.onPick(u); + }; + const draw = (): void => { + const q = input.value.trim().toLowerCase(); + shown = all.filter((u) => u === NO_UNIT || !q || u.toLowerCase().includes(q)); + if (q && !NO_UNIT.includes(q)) shown = shown.filter((u) => u !== NO_UNIT); + at = Math.min(at, Math.max(0, shown.length - 1)); + list.replaceChildren( + ...(shown.length + ? shown.map((u, i) => { + const b = el("button", { + className: `m02-df-unitpick__item${i === at ? " is-on" : ""}${u === (opts.value || NO_UNIT) ? " is-now" : ""}`, + text: label(u), + attrs: { type: "button", role: "option" }, + }); + b.addEventListener("click", () => pick(u)); + return el("li", { children: [b] }); + }) + : [el("li", { className: "m02-df-unitpick__empty", text: tu("Empty") })]), + ); + }; + const close = (): void => { + panel.hidden = true; + document.removeEventListener("pointerdown", outside, true); + }; + const outside = (e: Event): void => { + if (!root.contains(e.target as Node)) close(); + }; + function toggle(): void { + if (!panel.hidden) return close(); + panel.hidden = false; + document.addEventListener("pointerdown", outside, true); + input.value = ""; + void loadStandard().then(({ units }) => { + const mine = [...(opts.extra ?? []), ...units]; + all = [NO_UNIT, ...new Set(mine.filter((u) => u && u !== NO_UNIT))]; + at = 0; + draw(); + input.focus(); + }); + } + input.addEventListener("input", () => ((at = 0), draw())); + input.addEventListener("keydown", (e) => { + if (e.key === "Escape") return (e.stopPropagation(), close(), open.focus()); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + at = Math.max(0, Math.min(shown.length - 1, at + (e.key === "ArrowDown" ? 1 : -1))); + return draw(); + } + if (e.key === "Enter" && shown[at] !== undefined) { + e.preventDefault(); + pick(shown[at]); + } + }); + return root; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Verify.css b/M02_MasterTemplete/DataFlow/M02_DF_Verify.css new file mode 100644 index 000000000..7843ee96f --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Verify.css @@ -0,0 +1,118 @@ +.m02-dfver__block { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m02-dfver__breaks { + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: var(--color-surface); +} + +.m02-dfver__title { + font-size: var(--text-body); +} + +.m02-dfver__group { + margin: var(--spacing-8) 0; +} + +.m02-dfver__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-4) 0; +} + +.m02-dfver__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-dfver__input { + box-sizing: border-box; + width: 160px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m02-dfver__item { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-4) 0; +} + +.m02-dfver__line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-dfver__now { + color: var(--color-text-body); +} + +.m02-dfver__price { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.m02-dfver__warn { + color: var(--color-danger); +} + +.m02-dfver__panel { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); +} + +.m02-dfver__search, +.m02-dfver__pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-4) 0; +} + +.m02-dfver__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-4) 0; + padding: 0; + list-style: none; +} + +.m02-dfver__list .ui-btn { + max-width: 100%; + text-align: left; + white-space: normal; +} + +.m02-dfver__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m02-dfver__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts b/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts new file mode 100644 index 000000000..9c0f850a7 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts @@ -0,0 +1,477 @@ +/* ============================================================================= + * M02_DF_Verify.ts + * 흐름도 검증 칸(오른쪽 아래) — 끊긴 곳 목록(누르면 그 상자 · 선을 고름) + 「설계 때 정할 것」 전부: + * 변수 예시값 · 재료 선택 자리(범위 안 자재 목록에서 고르기 · 찾기 · 쪽) · 재료 조건 자리(지금 잡힌 품목 · 0 · 여럿 까닭) + * · 이어지지 않은 다른 로직의 받는 알약. 바꾸면 on.change(고친 다이어그램) · 줄을 누르면 on.select. + * 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 4 · 5 · 6 · 8 · 13. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { breaks, type CfBreak, type CfEvalResult, type CfValues } from "./M02_DF_Eval"; +import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material"; +import { + portsIn, + type CfBox, + type CfElementRef, + type CfLogic, + type CfVariable, +} from "./M02_DF_Model"; +import "./M02_DF_Verify.css"; + +const TEXT = { + Breaks: ["끊긴 곳", "Broken spots"], + BreaksHint: ["누르면 그 상자 · 선을 골라요", "Click to select the box or line"], + Design: ["설계 때 정할 것", "To decide at design time"], + DesignHint: ["여기서 바꾸면 해당 상자도 바뀌어요", "Changing here changes the box too"], + Vars: ["변수 예시값", "Variable samples"], + Slots: ["재료 선택 자리", "Material choice"], + Rules: ["재료 조건 자리", "Material by condition"], + Logics: ["이어지지 않은 다른 로직 값", "Unlinked values of other logics"], + Choose: ["고르기", "Choose"], + Close: ["닫기", "Close"], + Find: ["찾기", "Find"], + Prev: ["이전", "Prev"], + Next: ["다음", "Next"], + Search: ["재료 이름 · 규격", "Material name or size"], + Page: ["쪽 {p} / {n} · 모두 {t}", "Page {p} / {n} · {t} in all"], + NoHit: ["찾은 재료가 없어요", "No material found"], + NoDefault: ["아직 고르지 않았어요", "Nothing chosen yet"], + Price: ["단가", "Unit price"], + Won: ["원", "KRW"], + Loading: ["찾는 중", "Searching"], + NeedValue: ["조건 값이 아직 없어요", "The condition value is not there yet"], + Caught: ["잡힌 품목", "Matched item"], + Zero: ["맞는 재료 없음(0)", "No matching material (0)"], + Many: ["여럿 맞음({n}) — 첫째를 씀", "{n} match — the first is used"], + Cond: ["{field}에 「{v}」 {op}", "{field} {op} “{v}”"], + Pick: ["고르세요", "Choose"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + OtherLogic: [ + "이어 주지 않아서 그 로직의 예시값을 써요", + "Not linked — the logic's own sample is used", + ], + NoLine: ["「{box}」 의 받는 자리 「{pill}」 에 선이 없어요", "“{pill}” of “{box}” has no line"], + NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], +} as const; + +type TextKey = keyof typeof TEXT; +const tv = (key: TextKey, fill: Record = {}): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(/\{(\w+)\}/g, (m, k: string) => + k in fill ? String(fill[k]) : m, + ); + +/** 고른 상자 · 알약 · 선 — 다이어그램이 이 모양으로 고름 */ +export interface CfSel { + box: string; + port?: string; + link?: string; +} + +export interface CfVerifyOn { + change(lg: CfLogic): void; + select?(sel: CfSel): void; +} + +const PAGE = 10; + +/** 끊긴 곳 — Model 쪽 `breaks` 가 차면 그것 · 아직이면 빈 받는 자리와 계산 오류만 */ +function brokenSpots(lg: CfLogic, result: CfEvalResult, values: CfValues): CfBreak[] { + try { + return breaks(lg, values); + } catch { + const out: CfBreak[] = []; + if (!lg.boxes.some((b) => b.kind === "output")) + out.push({ kind: "오류", box: "", text: tv("NoOutput") }); + for (const b of lg.boxes) + for (const p of portsIn(b)) + if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name)) + out.push({ + kind: "빈 받는 자리", + box: b.id, + port: p.name, + text: tv("NoLine", { box: b.name, pill: p.name }), + }); + for (const e of result.errors) out.push({ kind: "오류", box: "", text: e }); + return out; + } +} + +/** 재료 요소 하나 고치기 — 나머지는 그대로 */ +function withElement( + lg: CfLogic, + boxId: string, + name: string, + patch: Partial, +): CfLogic { + return { + ...lg, + boxes: lg.boxes.map((b) => + b.id === boxId && b.input + ? { + ...b, + input: { + ...b.input, + elements: b.input.elements.map((e) => (e.name === name ? { ...e, ...patch } : e)), + }, + } + : b, + ), + }; +} + +const matText = (m: CfMat): string => + `${m.name}${m.spec ? ` ${m.spec}` : ""}${m.unit ? ` · ${m.unit}` : ""}`; + +/** 변수 예시값 칸 */ +function sampleField( + v: CfVariable, + put: (value: number | string) => void, + msg: HTMLElement, +): HTMLElement { + const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); + let control: HTMLElement; + if (v.type === "choice" && v.choices?.length) { + control = createSelectField({ + options: [ + { value: "", text: tv("Pick") }, + ...v.choices.map((c) => ({ value: String(c), text: String(c) })), + ], + value: v.example === null ? "" : String(v.example), + compact: true, + ariaLabel: v.name, + onChange: (s) => { + const hit = v.choices?.find((c) => String(c) === s); + if (hit !== undefined) put(hit); + }, + }).root; + } else { + const box = el("input", { + className: "m02-dfver__input", + attrs: { + type: "text", + inputmode: v.type === "number" ? "decimal" : "text", + "aria-label": v.name, + }, + }); + if (v.range) box.placeholder = tv("Range", { a: v.range[0], b: v.range[1] }); + box.value = v.example === null ? "" : String(v.example); + box.addEventListener("change", () => { + const raw = box.value.trim(); + if (raw === "") return; + if (v.type !== "number") return put(raw); + const n = Number(raw); + if (Number.isNaN(n)) msg.textContent = tv("Bad"); + else put(n); + }); + control = box; + } + return el("label", { className: "m02-dfver__field", children: [label, control] }); +} + +/** 재료 선택 자리 한 줄 — 지금 품목 · 단가 · 「고르기」(찾기 · 쪽) */ +function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTMLElement { + const now = el("span", { className: "m02-dfver__now", text: e.default ? "…" : tv("NoDefault") }); + const price = el("span", { className: "m02-dfver__price" }); + if (e.default) { + void materialOf(e.default).then((m) => (now.textContent = m ? matText(m) : (e.default ?? ""))); + void priceOf(e.default).then((p) => { + price.textContent = p === null ? "" : `${tv("Price")} ${formatMoney(p)} ${tv("Won")}`; + }); + } + const panel = el("div", { className: "m02-dfver__panel" }); + panel.hidden = true; + const q = el("input", { + className: "m02-dfver__input", + attrs: { type: "text", placeholder: tv("Search"), "aria-label": tv("Search") }, + }); + q.value = e.scope?.찾는말 ?? ""; + const list = el("ul", { className: "m02-dfver__list" }); + const pager = el("div", { className: "m02-dfver__pager" }); + let page = 1; + const load = async (): Promise => { + list.replaceChildren(el("li", { className: "m02-dfver__muted", text: tv("Loading") })); + const got = await findMaterials({ + q: q.value.trim(), + sub: e.scope?.구분, + detail: e.scope?.상세구분, + page, + size: PAGE, + }); + const pages = Math.max(1, Math.ceil(got.total / PAGE)); + list.replaceChildren( + ...(got.items.length + ? got.items.map((m) => + el("li", { + children: [ + createButton({ + label: matText(m), + variant: "ghost", + size: "sm", + onClick: () => on.change(withElement(lg, box.id, e.name, { default: m.ref })), + }), + ], + }), + ) + : [el("li", { className: "m02-dfver__muted", text: tv("NoHit") })]), + ); + pager.replaceChildren( + createButton({ + label: tv("Prev"), + size: "sm", + variant: "ghost", + disabled: page <= 1, + onClick: () => { + page--; + void load(); + }, + }), + el("span", { + className: "m02-dfver__muted", + text: tv("Page", { p: got.page, n: pages, t: got.total }), + }), + createButton({ + label: tv("Next"), + size: "sm", + variant: "ghost", + disabled: page >= pages, + onClick: () => { + page++; + void load(); + }, + }), + ); + }; + q.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + page = 1; + void load(); + } + }); + const toggle = createButton({ + label: tv("Choose"), + size: "sm", + onClick: () => { + panel.hidden = !panel.hidden; + toggle.querySelector(".ui-btn__label")!.textContent = panel.hidden + ? tv("Choose") + : tv("Close"); + if (!panel.hidden) void load(); + }, + }); + panel.append( + el("div", { + className: "m02-dfver__search", + children: [ + q, + createButton({ + label: tv("Find"), + size: "sm", + onClick: () => { + page = 1; + void load(); + }, + }), + ], + }), + list, + pager, + ); + return el("div", { + className: "m02-dfver__item", + children: [ + el("div", { + className: "m02-dfver__line", + children: [el("strong", { text: e.slot ?? e.name }), now, price, toggle], + }), + panel, + ], + }); +} + +/** 재료 조건 자리 한 줄 — 지금 잡힌 품목 · 맞는 것 0 · 여럿이면 까닭 */ +function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElement { + const rule = e.rule!; + const raw = result.ports[rule.from.box]?.[rule.from.name]; + const value = Array.isArray(raw) || raw === null || raw === undefined ? "" : String(raw); + const state = el("span", { + className: "m02-dfver__now", + text: value ? tv("Loading") : tv("NeedValue"), + }); + const note = el("span", { className: "m02-dfver__muted" }); + if (value) { + void findMaterials({ + q: e.scope?.찾는말, + sub: e.scope?.구분, + detail: e.scope?.상세구분, + size: 100, + }).then((got) => { + const hit = got.items.filter((m) => { + const t = rule.field === "규격" ? m.spec : m.name; + return rule.op === "같음" ? t === value : t.includes(value); + }); + const first = hit[0]; + state.className = `m02-dfver__now${hit.length === 1 ? "" : " m02-dfver__warn"}`; + state.textContent = !first ? tv("Zero") : `${tv("Caught")}: ${matText(first)}`; + note.textContent = hit.length > 1 ? tv("Many", { n: hit.length }) : ""; + }); + } + const from = lg.boxes.find((b) => b.id === rule.from.box)?.name ?? ""; + return el("div", { + className: "m02-dfver__item", + children: [ + el("div", { + className: "m02-dfver__line", + children: [el("strong", { text: e.slot ?? e.name }), state, note], + }), + el("span", { + className: "m02-dfver__muted", + text: `${from ? `${from} · ` : ""}${tv("Cond", { field: rule.field, v: value || rule.from.name, op: rule.op })}`, + }), + ], + }); +} + +/** 검증 칸 — values = 받은 입력 값(끊긴 곳 검사가 씀) */ +export function renderVerify( + host: HTMLElement, + lg: CfLogic, + result: CfEvalResult, + on: CfVerifyOn, + opts: { values?: CfValues } = {}, +): void { + const msg = el("p", { className: "m02-dfver__msg" }); + const safe = (make: () => CfLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch { + msg.textContent = tv("Cant"); + } + }; + + /* 끊긴 곳 */ + const spots = brokenSpots(lg, result, opts.values ?? {}); + const spotBlock = spots.length + ? [ + el("div", { + className: "m02-dfver__block m02-dfver__breaks", + children: [ + el("strong", { text: tv("Breaks") }), + el("ul", { + className: "m02-dfver__list", + children: spots.map((s) => + el("li", { + children: [ + createButton({ + label: s.text, + variant: "ghost", + size: "sm", + disabled: !s.box || !on.select, + onClick: () => on.select?.({ box: s.box, port: s.port, link: s.link }), + }), + ], + }), + ), + }), + el("p", { className: "m02-dfver__muted", text: tv("BreaksHint") }), + ], + }), + ] + : []; + + /* 설계 때 정할 것 */ + const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input); + const vars = inputs.flatMap((b) => + (b.input?.variables ?? []).map((v, i) => + sampleField( + v, + (value) => + safe(() => ({ + ...lg, + boxes: lg.boxes.map((x) => + x.id === b.id && x.input + ? { + ...x, + input: { + ...x.input, + variables: x.input.variables.map((y, j) => + j === i ? { ...y, example: value } : y, + ), + }, + } + : x, + ), + })), + msg, + ), + ), + ); + const slots = inputs.flatMap((b) => + (b.input?.elements ?? []) + .filter((e) => e.mode === "choice") + .map((e) => slotRow(b, e, { ...on, change: (n) => safe(() => n) }, lg)), + ); + const rules = inputs.flatMap((b) => + (b.input?.elements ?? []) + .filter((e) => e.mode === "rule" && e.rule) + .map((e) => ruleRow(e, lg, result)), + ); + const logics = inputs.flatMap((b) => + (b.input?.logic?.designs ?? []) + .filter((d) => !lg.links.some((l) => l.to.box === b.id && l.to.name === d.name)) + .map((d) => + el("li", { + children: [ + createButton({ + label: `${b.name} · ${d.name}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`, + variant: "ghost", + size: "sm", + disabled: !on.select, + onClick: () => on.select?.({ box: b.id, port: d.name }), + }), + ], + }), + ), + ); + const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] => + body.length + ? [ + el("div", { + className: "m02-dfver__group", + children: [el("strong", { text: tv(title) }), ...body], + }), + ] + : []; + const design = [ + ...group( + "Vars", + [el("div", { className: "m02-dfver__fields", children: vars })].filter(() => vars.length), + ), + ...group("Slots", slots), + ...group("Rules", rules), + ...group( + "Logics", + logics.length ? [el("ul", { className: "m02-dfver__list", children: logics })] : [], + ), + ]; + host.replaceChildren( + ...spotBlock, + ...(design.length + ? [ + el("div", { + className: "m02-dfver__block", + children: [ + el("strong", { className: "m02-dfver__title", text: tv("Design") }), + ...design, + el("p", { className: "m02-dfver__muted", text: tv("DesignHint") }), + msg, + ], + }), + ] + : []), + ); +} 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_model.py b/resources/tester/test_m02_df_model.py index 6081bd45a..c8b0663bd 100644 --- a/resources/tester/test_m02_df_model.py +++ b/resources/tester/test_m02_df_model.py @@ -346,3 +346,249 @@ def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호 ["결과 4", "결과 4"], ] assert ports == ["결과 1", "결과 2", "결과 1 (2)"] + + +# ── 흐름도 넓힘(35-9) — 견본 셋 ───────────────────────────────────────────── +RULE = r""" +const R = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Rule.ts")); +const rjoin = (lg, a, ap, b, bp, rule) => { + const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }, rule); + if (r.why) throw new Error(r.why); + return r.lg; +}; +const vars = (lg, box, list) => M.updateBox(lg, box.id, { input: { mode: "variable", elements: [], variables: list } }); +const num = (name, example, unit = "-") => ({ name, type: "number", unit, example }); +""" + +# 견본 가 — 재료 조건 찾기: 관경(변수) → 흄관(규격에 관경 포함) 단가 · 수량 × 단가 → 재료비 · 거푸집 = 선택 자리 +PIPE = ( + RULE + + r""" +let lg = M.newLogic("흄관 조건 찾기"); +let r = add(lg, "input"); lg = r.lg; const dia = r.box; +lg = vars(lg, dia, [num("관경", 300, "㎜"), num("길이", 12, "m")]); +r = add(lg, "input"); lg = r.lg; const mat = r.box; +lg = M.updateBox(lg, mat.id, { input: { mode: "group", group: "재료", variables: [], elements: [ + { ref: "", name: "흄관", spec: "", unit: "m", mode: "rule", slot: "흄관 단가", scope: { 구분: "관·수로", 상세구분: "흄관" }, + rule: { field: "규격", op: "포함", from: { box: dia.id, name: "관경" } } }, + { ref: "", name: "거푸집", spec: "", unit: "㎡", mode: "choice", slot: "거푸집 자리", default: "MT000123" } ] } }); +r = add(lg, "function"); lg = r.lg; const f = r.box; +lg = func(lg, f, { B9: { 식: "B1*B2" } }, [{ port: "길이", cell: "B1" }, { port: "흄관 단가", cell: "B2" }], { cell: "B9", name: "재료값" }); +r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "재료비", unit: "원" } }); +lg = join(lg, dia, "길이", f, "길이"); +lg = join(lg, mat, "흄관 단가", f, "흄관 단가"); +lg = join(lg, f, "재료값", o, "값"); +""" +) + + +def test_가_재료_조건_찾기_흐름() -> None: + got = _run( + PIPE + "const ok = E.evalLogic(lg, { [mat.id]: { '흄관 단가': 52000 } });\n" + "const none = E.breaks(lg, {});\n" + "out([M.portsOut(lg.boxes[1]).map(p => p.name), ok.cost.재료비, ok.arrived[f.id], none," + " M.traceBack(lg, { box: o.id, name: '값' }), M.designInputs(lg).map(d => [d.kind, d.port])," + " M.reaches(lg, dia.id, f.id)]);" + ) + pills, cost, arrived, none, back, designs, dep = got + assert pills == ["흄관 단가", "거푸집 자리"] # 재료 자리 = 알약 이름 + assert cost == 12 * 52000 and arrived == {"길이": 12, "흄관 단가": 52000} + assert [(b["kind"], b["port"]) for b in none] == [("맞는 재료 없음", "흄관 단가")] + assert none[0]["text"] == "「입력 2」 의 「흄관 단가」 — 조건에 맞는 재료가 없음" + # 출력 값 ← 함수 재료값 ← 길이 · 흄관 단가 ← (조건) 관경 + assert [(s["box"], s["port"], s["dir"]) for s in back] == [ + ("b4", "값", "in"), + ("b3", "재료값", "out"), + ("b3", "길이", "in"), + ("b1", "길이", "out"), + ("b3", "흄관 단가", "in"), + ("b2", "흄관 단가", "out"), + ("b1", "관경", "out"), + ] + assert designs == [["변수", "관경"], ["변수", "길이"], ["재료 자리", "거푸집 자리"]] + assert dep is True # 관경 → (재료 조건) → 함수 + + +# 견본 나 — 조건 통과로 둘로 가르기(지반 토사 · 암) + 표에서 찾기 + 바꾸기 · 끝수 +SPLIT = ( + RULE + + r""" +let lg = M.newLogic("지반 가르기"); +let r = add(lg, "input"); lg = r.lg; const v = r.box; +lg = vars(lg, v, [{ name: "지반", type: "choice", unit: "-", choices: ["토사", "암"], example: "토사" }, + num("높이", 4.5, "m"), num("품", 0.5, "인")]); +r = add(lg, "input"); lg = r.lg; const t = r.box; +lg = M.updateBox(lg, t.id, { input: { mode: "group", group: "소요량", elements: [], variables: [], table: { key: "QF000422" } } }); +const fn = (out) => { r = add(lg, "function"); lg = r.lg; const b = r.box; + lg = func(lg, b, { B9: { 식: "B1*100000" } }, [{ port: "가", cell: "B1" }], { cell: "B9", name: out }); return b; }; +const soil = fn("토사 노임"), rock = fn("암 노임"); +r = add(lg, "function"); lg = r.lg; const rate = r.box; +lg = func(lg, rate, { B9: { 식: "B1" } }, [{ port: "증가율", cell: "B1" }], { cell: "B9", name: "증가율 값" }); +r = add(lg, "output"); lg = r.lg; const o1 = r.box; +r = add(lg, "output"); lg = r.lg; const o2 = r.box; +r = add(lg, "output"); lg = r.lg; const o3 = r.box; lg = M.updateBox(lg, o3.id, { output: { cost: "값", unit: "%" } }); +const gate = (value, op = "=") => ({ kind: "gate", from: { box: v.id, name: "지반" }, op, value }); +lg = rjoin(lg, v, "품", soil, "가", gate("토사")); +lg = rjoin(lg, v, "품", rock, "가", gate("암")); +lg = rjoin(lg, t, "표", rate, "증가율", { kind: "lookup", conds: [{ col: "높이", from: { box: v.id, name: "높이" } }], col: "증가율" }); +lg = rjoin(lg, soil, "토사 노임", o1, "값", { kind: "scale", op: "+%", value: 10 }); +lg = rjoin(lg, rock, "암 노임", o2, "값", { kind: "round", places: -3, method: "버림" }); +lg = join(lg, rate, "증가율 값", o3, "값"); +const TABLE = [["높이·아래", "높이·위", "증가율"], [3, 4, 30], [4, 5.5, 40], [5.5, 7.5, 60]]; +const V = (soilKind, h = 4.5) => ({ [v.id]: { 지반: soilKind, 높이: h }, [t.id]: { 표: TABLE } }); +""" +) + + +def test_나_조건_통과로_둘로_가르기_표에서_찾기() -> None: + got = _run( + SPLIT + "const a = E.evalLogic(lg, V('토사'));\n" + "const b = E.evalLogic(lg, V('암', 3.5));\n" + "out([a.outputs, a.breaks.map(x => [x.kind, x.link]), b.outputs, b.breaks.map(x => x.kind)," + " E.breaks(lg, V('토사', 9)).map(x => [x.kind, x.text])]);" + ) + a_out, a_breaks, b_out, b_breaks, far = got + # 토사: 0.5 × 100000 × 1.1 · 암 줄은 통과 안 됨 · 높이 4.5 → 증가율 40 + assert a_out == {"b6": 55000, "b7": None, "b8": 40} + assert a_breaks == [["통과 안 됨", "l2"]] + assert b_out == { + "b6": None, + "b7": 50000, + "b8": 30, + } # 암 50,000 → 천 원 버림 그대로 · 높이 3.5 → 30 + assert b_breaks == ["통과 안 됨"] + assert far == [ + ["통과 안 됨", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음"], + ["표에 없는 조건", "「입력 2」 → 「함수 3」 선 — 표에 맞는 줄이 없음"], + ] + + +def test_나_조건_선_겹침_안맞음() -> None: + got = _run( + SPLIT + "const l2 = lg.links[1].id;\n" + "const overlap = M.setRule(lg, l2, { kind: 'gate', from: { box: v.id, name: '지반' }, op: '≠', value: '암' }).lg;\n" + "out([E.breaks(overlap, V('토사')).map(x => [x.kind, x.text]), E.breaks(lg, V('자갈')).map(x => x.kind)]);" + ) + overlap, none = got + assert overlap == [["조건 겹침", "「입력」 의 「지반」 에서 나간 조건 선이 둘 이상 통과함"]] + assert none == ["통과 안 됨", "통과 안 됨", "조건 안 맞음"] + + +def test_나_규칙_글과_엑셀_수식() -> None: + got = _run( + SPLIT + "out([lg.links.map(l => l.rule ? R.ruleLabel(l.rule) : null)," + " lg.links.map(l => l.rule ? R.applyRule(l.rule, 1000, { 지반: '토사', 높이: 4.5 }).formula : null).slice(0, 2)," + " R.applyRule({ kind: 'scale', op: '+%', value: 10 }, 200).formula, R.applyRule({ kind: 'scale', op: '÷', value: 4 }, 10).value," + " R.applyRule({ kind: 'round', places: 1, method: '올림' }, 1.21).value, R.applyRound({ places: 0, method: '버림' }, 9.9)]);" + ) + labels, gates, scale_f, div, up, down = got + assert labels == [ + "지반 = 토사 일 때만", + "지반 = 암 일 때만", + "표에서 찾기 (높이 = 높이) → 증가율", + "+10%", + "소수 -3자리 버림", + None, + ] + assert gates == ['IF(C1="토사",A1,NA())', 'IF(C1="암",A1,NA())'] + assert scale_f == "A1*(1+10/100)" and div == 2.5 and up == 1.3 and down == 9 + + +def test_나_흐름_따라가기_앞으로() -> None: + got = _run( + SPLIT + + "out(M.traceForward(lg, { box: v.id, name: '지반' }).map(s => [s.box, s.port, s.dir, s.rule ? s.rule.kind : null]));" + ) + assert got == [ + ["b1", "지반", "out", None], + ["b3", "가", "in", "gate"], + ["b3", "토사 노임", "out", None], + ["b6", "값", "in", "scale"], + ["b4", "가", "in", "gate"], + ["b4", "암 노임", "out", None], + ["b7", "값", "in", "round"], + ] + + +def test_규칙_조건_알약_없으면_막음_순환() -> None: + got = _run( + SPLIT + + "const bad = M.link(lg, { box: v.id, name: '품' }, { box: rate.id, name: '' }, { kind: 'gate', from: { box: v.id, name: '없음' }, op: '=', value: 1 }).why;\n" + "const loop = M.setRule(lg, lg.links[0].id, { kind: 'gate', from: { box: soil.id, name: '토사 노임' }, op: '>', value: 0 }).why;\n" + "const off = M.setRule(lg, lg.links[3].id, null).lg.links[3];\n" + "out([bad, loop, off]);" + ) + bad, loop, off = got + assert bad == "규칙의 조건 알약이 없음" + assert loop == "이으면 돌고 도는 선이 됨" + assert "rule" not in off + + +# 견본 다 — 다른 로직 잇기: 안쪽 로직(면적 × 계수 = 작업량 값) · 바깥 로직이 길이를 면적으로 넘기고 작업량을 받아 경비 +LOGIC = ( + RULE + + r""" +let inner = M.newLogic("작업량"); +let r = add(inner, "input"); inner = r.lg; const ia = r.box; +inner = vars(inner, ia, [num("면적", 1, "㎡"), num("계수", 3)]); +r = add(inner, "function"); inner = r.lg; const ifn = r.box; +inner = func(inner, ifn, { B9: { 식: "B1*B2" } }, [{ port: "면적", cell: "B1" }, { port: "계수", cell: "B2" }], { cell: "B9", name: "작업량 값" }); +r = add(inner, "output"); inner = r.lg; const io = r.box; +inner = M.updateBox(inner, io.id, { name: "작업량", output: { cost: "값", unit: "㎥/시간" } }); +inner = join(inner, ia, "면적", ifn, "면적"); +inner = join(inner, ia, "계수", ifn, "계수"); +inner = join(inner, ifn, "작업량 값", inner.boxes.find(b => b.id === io.id), "값"); +let lg = M.newLogic("바깥"); +r = add(lg, "input"); lg = r.lg; const len = r.box; lg = vars(lg, len, [num("길이", 7, "m")]); +r = add(lg, "input"); lg = r.lg; const other = r.box; +lg = M.updateBox(lg, other.id, { input: { mode: "logic", elements: [], variables: [], + logic: { key: "DF-0001", name: "작업량", outputs: [{ name: "작업량", unit: "㎥/시간" }], + designs: M.designInputs(inner).map(d => ({ name: d.port, unit: d.unit })) } } }); +r = add(lg, "function"); lg = r.lg; const f = r.box; +lg = func(lg, f, { B9: { 식: "B1*1000" } }, [{ port: "작업량", cell: "B1" }], { cell: "B9", name: "경비 값" }); +r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "경비", unit: "원" } }); +lg = join(lg, len, "길이", other, "면적"); +lg = join(lg, other, "작업량", f, "작업량"); +lg = join(lg, f, "경비 값", o, "값"); +const LOGICS = { "DF-0001": inner }; +""" +) + + +def test_다_다른_로직_잇기() -> None: + got = _run( + LOGIC + "const a = E.evalLogic(lg, {}, { logics: LOGICS });\n" + "const b = E.evalLogic(lg, { [len.id]: { 길이: 2 }, [other.id]: { 계수: 10 } }, { logics: LOGICS });\n" + "const c = E.evalLogic(lg, { [other.id]: { 작업량: 5 } });\n" + "out([M.portsIn(lg.boxes[1]).map(p => p.name), M.portsOut(lg.boxes[1]).map(p => p.name), a.ports[other.id], a.cost.경비," + " b.cost.경비, c.cost.경비, M.designInputs(lg).map(d => [d.kind, d.port])," + " M.link(lg, { box: len.id, name: '길이' }, { box: other.id, name: '없음' }).why," + " M.traceBack(lg, { box: o.id, name: '값' }).map(s => [s.box, s.port]), E.evalLogic(lg, {}, { logics: {} }).errors]);" + ) + ins, outs, ports, a, b, c, designs, no_pill, back, missing = got + assert ins == ["면적", "계수"] and outs == ["작업량"] + assert ports == {"작업량": 21} # 길이 7 → 면적 · 계수 예시값 3 + assert (a, b, c) == (21000, 20000, 5000) + assert designs == [["변수", "길이"], ["다른 로직", "계수"]] # 면적은 선이 있어 빠짐 + assert no_pill == "알약이 없음" + assert back == [ + ["b4", "값"], + ["b3", "경비 값"], + ["b3", "작업량"], + ["b2", "작업량"], + ["b2", "면적"], + ["b1", "길이"], + ["b2", "계수"], + ] + assert missing == ["「입력 2」 이 부르는 로직을 찾을 수 없음"] + + +def test_끝수_함수_출력_출력_상자() -> None: + got = _run( + ONE + + "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, output: { ...lg.boxes[2].func.output, round: { places: -3, method: '버림' } } } });\n" + "const a = E.evalLogic(lg, V).cost.노무비;\n" + "lg = M.updateBox(lg, o.id, { output: { cost: '노무비', unit: '원', round: { places: -4, method: '올림' } } });\n" + "out([a, E.evalLogic(lg, V).cost.노무비]);" + ) + assert got == [537000, 540000] 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": "버림"} + )