From d24851d1a4a85c29dbc9caaea237b79d5c576fb6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 19:47:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-df):=20=ED=9D=90=EB=A6=84=EB=8F=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B9=B8(=EB=81=8A=EA=B8=B4=20=EA=B3=B3?= =?UTF-8?q?=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20=EC=9E=90=EB=A6=AC=20=C2=B7=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=9E=90=EB=A6=AC)=20=C2=B7=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EB=81=9D=EC=88=98=20=EA=B8=B0=EB=B3=B8=20=C2=B7=20?= =?UTF-8?q?=ED=95=A9=EA=B3=84=20=EB=81=9D=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Verify: 끊긴 곳 목록(on.select) · 변수 예시값 · 재료 선택 자리(찾기 · 쪽 · 고르기) · 재료 조건 자리(잡힌 품목 · 0 · 여럿) · 이어지지 않은 다른 로직 값 - 새 Round · Material: 금액 단위 표준에서 끝수 기본(금액란 0.1원 · 계금 1원 미만 버림) · 재료 찾기 · 단가 - OutputPanel 끝수 고르기 · Summary 합계 끝수 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../DataFlow/M02_DF_Material.ts | 121 +++++ .../DataFlow/M02_DF_OutputPanel.css | 5 + .../DataFlow/M02_DF_OutputPanel.ts | 88 +++- M02_MasterTemplete/DataFlow/M02_DF_Round.ts | 101 ++++ M02_MasterTemplete/DataFlow/M02_DF_Summary.ts | 167 +----- M02_MasterTemplete/DataFlow/M02_DF_Verify.css | 118 +++++ M02_MasterTemplete/DataFlow/M02_DF_Verify.ts | 477 ++++++++++++++++++ 7 files changed, 931 insertions(+), 146 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Material.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Round.ts create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Verify.css create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_Verify.ts 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_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_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_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_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, + ], + }), + ] + : []), + ); +}