diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index 34b1a7b72..e268be002 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -37,6 +37,8 @@ import { valueOf, type M01Row, } from "./M02_DF_InputPanel_Build"; +import { renderLogicInput } from "./M02_DF_InputPanel_Logic"; +import { renderMaterial } from "./M02_DF_InputPanel_Material"; import { renderTablePick } from "./M02_DF_InputPanel_Table"; import { renderVariables } from "./M02_DF_InputPanel_Vars"; import "./M02_DF_InputPanel.css"; @@ -62,6 +64,7 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise const TEXT = { Kind: ["무엇을 넣을까요?", "What goes in?"], Variable: ["변수", "Variable"], + Logic: ["다른 로직", "Another logic"], 인력: ["인력", "Labor"], 재료: ["재료", "Material"], 유가전력: ["유가·전력", "Fuel · power"], @@ -148,22 +151,26 @@ export function renderInputPanel( const fixed = reconcileUnit(lg, boxId); if (fixed !== lg) return on.change(fixed); const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] }; - const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력"); + const kind: CfGroup | "변수" | "다른 로직" = + input.mode === "variable" + ? "변수" + : input.mode === "logic" + ? "다른 로직" + : (input.group ?? "인력"); const body = el("div", { className: "m02-dfip__body" }); - // ① 갈래 — 그룹 여덟 + 변수 + // ① 갈래 — 그룹 여덟 + 다른 로직 + 변수 const kinds = el("div", { className: "m02-dfip__chips", attrs: { role: "tablist", "aria-label": t("Kind") }, - children: [...GROUPS, "변수" as const].map((k) => { - const b = el("button", { - className: `m02-dfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`, - attrs: { type: "button", role: "tab" }, - text: k === "변수" ? t("Variable") : t(k), - }); - b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k))); - return b; - }), + children: [...GROUPS, "다른 로직" as const, "변수" as const].map((k) => + createButton({ + label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k), + variant: k === kind ? "filled" : "pill", + size: "sm", + onClick: () => k !== kind && on.change(setKind(lg, boxId, k)), + }), + ), }); const unit = el("p", { className: "m02-dfip__unit", @@ -172,7 +179,9 @@ export function renderInputPanel( host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body); if (kind === "변수") renderVariables(body, lg, boxId, on); + else if (kind === "다른 로직") renderLogicInput(body, lg, boxId, on); else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on); + else if (kind === "재료") renderMaterial(body, lg, boxId, on, (l, e) => addElement(l, boxId, e)); else renderGroup(body, lg, box, kind, on); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index c55f0fd3e..f6263d2ce 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -4,7 +4,19 @@ * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts` * ========================================================================== */ -import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_DF_Model"; +import type { + CfBox, + CfElementRef, + CfGroup, + CfInput, + CfLogic, + CfLogicRef, + CfMaterialMode, + CfMaterialRule, + CfPort, + CfScope, + CfVariable, +} from "./M02_DF_Model"; /** 입력 상자가 고르는 그룹 — 화면 차례 */ export const GROUPS: CfGroup[] = [ @@ -105,6 +117,7 @@ export function pillNames(box: CfBox): string[] { const i = box.input; if (!i) return []; if (i.table) return ["표"]; + if (i.mode === "logic") return (i.logic?.outputs ?? []).map((o) => o.name); return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name); } @@ -117,9 +130,153 @@ export function queryOf(group: CfGroup, ref: string): Record { return { group, ref }; } +/* ── 재료 요소 방식 셋 · 다른 로직 입력(브레인 정함 — sub7 Model 타입이 오면 그 타입으로 바꿈) ── */ + +/* ── 재료 요소 방식 셋 · 다른 로직 입력 — 타입은 Model(`CfMaterialMode` · `CfScope` · `CfMaterialRule` · `CfLogicRef`) ── */ + +/** 자재 한 품목 — 서버 `/api/m02/df/materials` 한 줄 */ +export interface MatItem { + ref: string; + name: string; + spec: string; + unit: string; + price: number | null; + 구분?: string; + 상세구분?: string; +} + +const squash = (v: unknown): string => + String(v ?? "") + .replace(/\s+/g, "") + .toLowerCase(); + +/** 규칙이 이 품목을 잡나 — value = 변수 알약 값 */ +export function matches( + item: Pick, + rule: Pick, + value: unknown, +): boolean { + const v = squash(value); + if (!v) return false; + const target = squash(rule.field === "규격" ? item.spec : item.name); + return rule.op === "같음" ? target === v : target.includes(v); +} + +/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */ +export function catchNote( + found: number, + firstName = "", +): { kind: "none" | "one" | "many"; text: string } { + if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" }; + if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` }; + return { + kind: "many", + text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`, + }; +} + +/** 쪽 범위 — total 개를 size 씩 · page 는 1 부터 · 앞 번호 · 뒤 번호(1 부터) */ +export function pageRange( + total: number, + page: number, + size: number, +): { from: number; to: number; pages: number } { + const pages = Math.max(1, Math.ceil(total / size)); + const p = Math.min(Math.max(1, page), pages); + return { from: total ? (p - 1) * size + 1 : 0, to: Math.min(total, p * size), pages }; +} + +/** 이 로직의 변수 알약 — 입력 상자 변수마다(규칙의 「알약」 고르기 목록) */ +export function variablePills( + lg: CfLogic, +): { port: CfPort; unit: string; example: CfVariable["example"] }[] { + return lg.boxes.flatMap((b) => + b.input?.mode === "variable" + ? b.input.variables.map((v) => ({ + port: { box: b.id, name: v.name }, + unit: v.unit, + example: v.example, + })) + : [], + ); +} + +/** 선택 자리 · 조건 찾기 요소 — ref 는 기본 품목(없으면 빈 글) */ +export function materialElement( + mode: CfMaterialMode, + name: string, + scope: CfScope, + extra: { rule?: CfMaterialRule; item?: Pick } = {}, +): CfElementRef { + const item = extra.item; + return { + ref: mode === "rule" ? "" : (item?.ref ?? ""), + name: name.trim(), + slot: name.trim(), + spec: item?.spec ?? "", + unit: item?.unit || "-", + mode, + scope: Object.fromEntries(Object.entries(scope).filter(([, v]) => v)) as CfScope, + ...(mode === "rule" && extra.rule ? { rule: extra.rule } : {}), + ...(mode === "choice" && item?.ref ? { default: item.ref } : {}), + }; +} + +/** 재료 요소를 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ +export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null { + if (!el.name) return "자리 이름을 적어 주세요"; + if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; + if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; + if (el.mode !== "fixed" && !Object.keys(el.scope ?? {}).length) + return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요"; + return null; +} + +/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리) */ +export function logicRefOf(key: string, other: CfLogic): CfLogicRef { + const outputs = other.boxes + .filter((b) => b.kind === "output") + .map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" })); + const designs: { name: string; unit: string }[] = []; + for (const b of other.boxes) { + if (b.kind !== "input" || !b.input) continue; + if (b.input.mode === "variable") + for (const v of b.input.variables) designs.push({ name: v.name, unit: v.unit || "-" }); + for (const e of b.input.elements) + if (e.mode === "choice" || e.mode === "rule") + designs.push({ name: e.slot || e.name, unit: e.unit || "-" }); + } + const seen = new Set(); + return { + key, + name: other.name, + outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)), + designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))), + }; +} + +/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 그 알약에서 나간 선도 지움 */ +export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic { + return { + ...lg, + boxes: lg.boxes.map((b) => { + if (b.id !== id || !b.input) return b; + const input: CfInput = ref + ? { mode: "logic", elements: [], variables: [], logic: ref } + : { mode: "variable", elements: [], variables: [] }; + return { ...b, input, unit: unitOfInput(input) }; + }), + links: lg.links.filter((l) => l.from.box !== id), + }; +} + /** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */ export function unitOfInput(input: CfInput): string { if (input.table) return "-"; + if (input.mode === "logic") { + const u = (input.logic?.outputs ?? []).map((o) => o.unit || "-"); + return u.length && u.every((x) => x === u[0]) ? u[0] : "-"; + } const units = ( input.mode === "variable" ? input.variables.map((v) => v.unit) @@ -179,12 +336,19 @@ const touch = ( const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] }; /** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */ -export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic { +export function setKind( + lg: CfLogic, + id: string, + kind: CfGroup | "변수" | "다른 로직", + pills?: Pills, +): CfLogic { return touch( lg, id, (b) => { const cur = inputOf(b); + if (kind === "다른 로직") + return { ...b, input: { mode: "logic", elements: [], variables: [] }, unit: "-" }; if (kind === "변수") return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } }; const same = cur.mode === "group" && cur.group === kind; @@ -204,7 +368,7 @@ export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pi id, (b) => { const cur = inputOf(b); - return cur.elements.some((e) => e.ref === el.ref || e.name === el.name) + return cur.elements.some((e) => (el.ref !== "" && e.ref === el.ref) || e.name === el.name) ? b : { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } }; }, @@ -212,6 +376,19 @@ export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pi ); } +/** 이름으로 빼기 — 재료 조건 찾기 요소는 품목 키가 없어 이름(알약 이름)으로 가름 */ +export function removeElementNamed(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { + return touch( + lg, + id, + (b) => ({ + ...b, + input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.name !== name) }, + }), + pills, + ); +} + export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic { return touch( lg, diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts new file mode 100644 index 000000000..a59e4e3d4 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Logic.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * M02_DF_InputPanel_Logic.ts + * 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면 + * 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) + * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api"; +import type { CfLogic } from "./M02_DF_Model"; +import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Help: [ + "저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀", + "Use a saved logic or sample as an input", + ], + Search: ["로직 이름으로 찾기", "Search by name"], + Loading: ["찾는 중…", "Searching…"], + None: ["찾는 로직 없음", "No logic found"], + Sample: ["견본", "Sample"], + Mine: ["내가 만든 것", "Mine"], + Chosen: ["고른 로직", "Chosen logic"], + Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"], + Designs: [ + "받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)", + "Pills it takes — its design inputs", + ], + NoOut: ["출력 상자가 없음", "No output box"], + NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"], + Change: ["다른 로직 고르기", "Pick another"], + Remove: ["빼기", "Remove"], + Self: ["이 로직 자신은 못 씀", "Can't use itself"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; + +const plain = (e: unknown): string => { + try { + return cfPlain(e); + } catch { + return e instanceof Error ? e.message : t("Failed"); + } +}; + +/** 다른 로직 입력 판 */ +export function renderLogicInput( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, +): void { + const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic; + if (ref) return void showChosen(host, lg, boxId, ref, on); + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); + const list = el("div", { className: "m02-dfip__list" }); + let all: CfListItem[] = []; + const draw = (): void => { + const word = q.input.value.trim().toLowerCase(); + const rows = all.filter( + (i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)), + ); + list.replaceChildren( + ...(rows.length + ? rows.map((i) => { + const b = el("button", { + className: "m02-dfip__row", + attrs: { type: "button" }, + children: [ + el("span", { + className: "m02-dfip__name", + children: [ + el("strong", { text: i.name }), + el("span", { + className: "m02-dfip__tag", + text: i.sample ? t("Sample") : t("Mine"), + }), + ], + }), + el("span", { className: "m02-dfip__muted", text: i.resultUnit }), + ], + }); + b.addEventListener("click", () => void pick(i)); + return b; + }) + : [el("p", { className: "m02-dfip__muted", text: t("None") })]), + ); + }; + const pick = async (i: CfListItem): Promise => { + try { + const other = await cfGet(i.key); + const next = setLogicInput(lg, boxId, logicRefOf(i.key, other)); + // 기본 이름이면 고른 로직 이름으로 + on.change({ + ...next, + boxes: next.boxes.map((b) => + b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b, + ), + }); + } catch (e) { + showToast(plain(e), "error"); + } + }; + q.input.addEventListener("input", draw); + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); + host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list); + void cfList().then( + (items) => ((all = items), draw()), + (e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })), + ); +} + +/** 고른 로직 — 내보내는 알약 · 받는 알약 */ +function showChosen( + host: HTMLElement, + lg: CfLogic, + boxId: string, + ref: NonNullable["logic"], + on: { change(lg: CfLogic): void }, +): void { + if (!ref) return; + const pillLine = (p: { name: string; unit: string }): HTMLElement => + el("div", { + className: "m02-dfip__item", + children: [ + el("span", { className: "m02-dfip__name", text: p.name }), + el("span", { className: "m02-dfip__muted", text: p.unit || "-" }), + ], + }); + host.replaceChildren( + el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }), + el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }), + ...(ref.outputs.length + ? ref.outputs.map(pillLine) + : [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]), + el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }), + ...(ref.designs.length + ? ref.designs.map(pillLine) + : [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]), + el("div", { + className: "m02-dfip__chips", + children: [ + createButton({ + label: t("Change"), + variant: "ghost", + size: "sm", + onClick: () => on.change(setKind(lg, boxId, "다른 로직")), + }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(setLogicInput(lg, boxId, null)), + }), + ], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts new file mode 100644 index 000000000..d24043210 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Material.ts @@ -0,0 +1,582 @@ +/* ============================================================================= + * M02_DF_InputPanel_Material.ts + * 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수) + * 서버 `/api/m02/df/materials` 를 먼저 부르고 아직 없으면 M01 `/pick?kind=price` 로 같은 모양을 만듦 + * 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch"; +import { cfValues } from "./M02_DF_Api"; +import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model"; +import { + catchNote, + checkMaterial, + elementOf, + matches, + materialElement, + pageRange, + removeElementNamed, + variablePills, + type MatItem, +} from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"], + None: ["아직 고른 재료가 없음", "Nothing chosen yet"], + Remove: ["빼기", "Remove"], + Fixed: ["고정", "Fixed"], + Choice: ["선택 자리", "Choice slot"], + Rule: ["조건 찾기", "Find by rule"], + FixedHelp: ["품목 하나를 못 박음", "One fixed item"], + ChoiceHelp: [ + "자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)", + "A slot — pick the item per project", + ], + RuleHelp: [ + "변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관", + "Find by a variable pill", + ], + Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"], + AllSub: ["전체", "All"], + SubLabel: ["구분", "Group"], + DetailLabel: ["상세구분", "Detail"], + Loading: ["찾는 중…", "Searching…"], + NoRow: ["찾는 재료 없음", "Nothing found"], + Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"], + Prev: ["이전", "Prev"], + Next: ["다음", "Next"], + SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], + Scope: ["범위", "Scope"], + Word: ["찾는 말(범위 안에서)", "Word in scope"], + Default: ["기본 품목", "Default item"], + DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"], + DefaultClear: ["기본 품목 비움", "Clear"], + RuleField: ["찾는 칸", "Field"], + FieldSpec: ["규격", "Spec"], + FieldName: ["이름", "Name"], + RuleOp: ["조건", "Condition"], + OpIn: ["에 알약 값이 들어 있음", "contains the pill value"], + OpEq: ["이 알약 값과 같음", "equals the pill value"], + RulePill: ["변수 알약", "Variable pill"], + NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], + Put: ["넣기", "Add"], + Pick: ["넣기", "Add"], + Preview: ["지금 잡히는 재료", "Items caught now"], + Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"], + RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"], + ScopeLine: ["범위 {s}", "scope {s}"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey, v: Record = {}): string => + Object.entries(v).reduce( + (s, [k, x]) => s.replace(`{${k}}`, String(x)), + TEXT[key][currentLanguageIndex] as string, + ); + +const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR"); + +/* ── 자재 찾기 ── */ +export interface MatQuery { + q: string; + sub?: string; + detail?: string; + page: number; + size: number; +} +export interface MatPage { + total: number; + page: number; + size: number; + items: MatItem[]; +} + +async function getJson(path: string): Promise { + const res = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + if (!res.ok) throw new Error(String(res.status)); + return (await res.json()) as T; +} + +/** 서버가 없으면 M01 값 줄에서 같은 모양으로 — 띄어쓰기 AND(이름 + 규격) · 쪽은 앞에서 잘라 줌 */ +async function fallback(q: MatQuery): Promise { + const r = await getJson<{ items: Record[] }>( + `/m01/pick?${new URLSearchParams({ kind: "price", q: q.q, limit: "200" })}`, + ); + const rows = r.items.filter((i) => !q.sub || i["구분"] === q.sub); + const items = rows.map((i): MatItem => ({ + ref: String(i["ref"]), + name: String(i["이름"] ?? ""), + spec: String(i["규격"] ?? ""), + unit: String(i["단위"] ?? ""), + price: typeof i["값"] === "number" ? i["값"] : null, + 구분: String(i["구분"] ?? ""), + })); + const from = (q.page - 1) * q.size; + return { + total: items.length, + page: q.page, + size: q.size, + items: items.slice(from, from + q.size), + }; +} + +export async function searchMaterials(q: MatQuery): Promise { + const params = new URLSearchParams({ + q: q.q, + page: String(q.page), + size: String(q.size), + sub: q.sub ?? "", + detail: q.detail ?? "", + }); + try { + return await getJson(`/m02/df/materials?${params}`); + } catch { + return fallback(q); + } +} + +let subsCache: SubInfo[] | null = null; +async function materialSubs(): Promise { + if (subsCache) return subsCache; + const files = await fetchFiles("재료"); + const f = files.find((x) => x.file.includes("자재품목")) ?? files[0]; + subsCache = await fetchSubs({ file: f.file }); + return subsCache; +} + +/** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */ +function scopeBoxes( + init: { sub: string; detail: string }, + onChange: () => void, +): { root: HTMLElement; get(): { sub: string; detail: string } } { + const state = { ...init }; + const all = { value: "", text: t("AllSub") }; + const subBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("SubLabel"), + onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()), + }); + const detailBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: t("DetailLabel"), + onChange: (v) => ((state.detail = v), onChange()), + }); + let subs: SubInfo[] = []; + const fill = (): void => { + const d = subs.find((s) => s.name === state.sub)?.details ?? []; + detailBox.root.hidden = !d.length; + detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail); + }; + fill(); + void materialSubs().then( + (s) => { + subs = s; + subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub); + fill(); + }, + () => undefined, + ); + return { + root: el("div", { className: "m02-dfip__bar", children: [subBox.root, detailBox.root] }), + get: () => ({ ...state }), + }; +} + +/** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */ +function itemList( + onPick: (item: MatItem) => void, + base?: () => { sub: string; detail: string }, +): HTMLElement { + const q = createInputField({ type: "search", placeholder: t("Search"), compact: true }); + const list = el("div", { className: "m02-dfip__list" }); + const info = el("span", { className: "m02-dfip__muted" }); + let page = 1; + let seq = 0; + const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load())); + const scope = (): { sub: string; detail: string } => (base ? base() : own!.get()); + const prev = createButton({ + label: t("Prev"), + variant: "ghost", + size: "sm", + onClick: () => ((page -= 1), void load()), + }); + const next = createButton({ + label: t("Next"), + variant: "ghost", + size: "sm", + onClick: () => ((page += 1), void load()), + }); + const load = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") })); + try { + const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 }); + if (mine !== seq) return; + const range = pageRange(r.total, page, 12); + info.textContent = t("Total", { + n: r.total, + a: range.from, + b: range.to, + p: Math.min(page, range.pages), + m: range.pages, + }); + prev.disabled = page <= 1; + next.disabled = page >= range.pages; + list.replaceChildren( + ...(r.items.length + ? r.items.map((it) => { + const b = el("button", { + className: "m02-dfip__row", + attrs: { type: "button" }, + children: [ + el("span", { + className: "m02-dfip__name", + children: [ + el("strong", { text: it.name }), + el("span", { className: "m02-dfip__muted", text: ` ${it.spec}` }), + ], + }), + el("span", { + className: "m02-dfip__muted", + text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(), + }), + ], + }); + b.addEventListener("click", () => onPick(it)); + return b; + }) + : [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]), + ); + } catch { + if (mine === seq) + list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Failed") })); + } + }; + let timer = 0; + q.input.addEventListener( + "input", + () => ( + window.clearTimeout(timer), + (timer = window.setTimeout(() => ((page = 1), void load()), 250)) + ), + ); + void load(); + return el("div", { + className: "m02-dfip__find", + children: [ + ...(own ? [own.root] : []), + q.root, + info, + list, + el("div", { className: "m02-dfip__chips", children: [prev, next] }), + ], + }); +} + +/* ── 잡히는 재료 미리 보기 ── */ +export async function previewOf( + el1: CfElementRef, + lg: CfLogic, +): Promise<{ found: number; names: string[]; cheapest: MatItem | null }> { + const scope = el1.scope ?? {}; + const word = scope.찾는말 ?? ""; + if (el1.mode === "rule" && el1.rule) { + const pill = variablePills(lg).find( + (p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name, + ); + const value = pill?.example; + if (value === null || value === undefined || value === "") + return { found: 0, names: [], cheapest: null }; + const r = await searchMaterials({ + q: `${word} ${value}`.trim(), + sub: scope.구분, + detail: scope.상세구분, + page: 1, + size: 200, + }); + const hit = r.items.filter((i) => matches(i, el1.rule!, value)); + return { + found: hit.length, + names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), + cheapest: cheapestOf(hit), + }; + } + const r = await searchMaterials({ + q: word, + sub: scope.구분, + detail: scope.상세구분, + page: 1, + size: 200, + }); + return { + found: r.total, + names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()), + cheapest: cheapestOf(r.items), + }; +} + +const cheapestOf = (items: MatItem[]): MatItem | null => + items + .filter((i) => i.price !== null) + .sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null; + +/** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */ +function summaryOf(e: CfElementRef, lg: CfLogic): string { + const parts: string[] = []; + const scope = Object.entries(e.scope ?? {}) + .map(([, v]) => v) + .join(" / "); + if (scope) parts.push(t("ScopeLine", { s: scope })); + if (e.rule) { + const p = variablePills(lg).find( + (x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name, + ); + parts.push( + t("RuleLine", { + f: e.rule.field, + o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"), + n: e.rule.from.name, + v: p?.example ?? "—", + }), + ); + } + return parts.join(" · "); +} + +/** 재료 판 — 넣은 재료 목록 + 방식 셋 */ +export function renderMaterial( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, + addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + const els = (box?.input?.elements ?? []) as CfElementRef[]; + const chosen = el("div", { className: "m02-dfip__chosen" }); + const body = el("div", { className: "m02-dfip__stack" }); + let mode: CfMaterialMode = "fixed"; + + const drawChosen = (): void => { + chosen.replaceChildren( + el("h5", { text: `${t("Chosen")} · ${els.length}` }), + ...(els.length + ? els.map((e) => { + const val = el("strong", { className: "m02-dfip__value", text: "—" }); + const note = el("span", { className: "m02-dfip__muted" }); + const kind = + e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed"); + const row = el("div", { + className: "m02-dfip__item", + children: [ + el("span", { + className: "m02-dfip__name", + children: [ + el("span", { className: "m02-dfip__tag", text: kind }), + el("strong", { text: ` ${e.name}` }), + el("div", { className: "m02-dfip__muted", text: summaryOf(e, lg) }), + note, + ], + }), + val, + el("span", { className: "m02-dfip__muted", text: e.unit || "-" }), + createButton({ + label: t("Remove"), + variant: "ghost", + size: "sm", + onClick: () => on.change(removeElementNamed(lg, boxId, e.name)), + }), + ], + }); + const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref; + if (ref) + void cfValues([{ id: ref, group: "재료", ref }] as never) + .then((a) => + a.forEach( + (x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)), + ), + ) + .catch(() => undefined); + if (e.mode && e.mode !== "fixed") + void previewOf(e, lg).then( + (p) => { + const n = catchNote(p.found, p.names[0]); + note.textContent = `${t("Preview")}: ${n.text}`; + note.classList.toggle("is-bad", n.kind !== "one"); + if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price); + }, + () => undefined, + ); + return row; + }) + : [el("p", { className: "m02-dfip__muted", text: t("None") })]), + ); + }; + + const chips = el("div", { className: "m02-dfip__chips" }); + const drawChips = (): void => + chips.replaceChildren( + ...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) => + createButton({ + label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"), + variant: mode === m ? "filled" : "pill", + size: "sm", + onClick: () => ((mode = m), drawChips(), drawBody()), + }), + ), + ); + + const drawBody = (): void => { + if (mode === "fixed") { + body.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("FixedHelp") }), + itemList((it) => + on.change( + addEl(lg, { + ...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }), + mode: "fixed", + }), + ), + ), + ); + return; + } + // 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기 + const name = createInputField({ + type: "text", + label: t("SlotName"), + compact: true, + placeholder: "예: 흄관", + }); + const word = createInputField({ type: "text", label: t("Word"), compact: true }); + let def: MatItem | null = null; + const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh()); + const note = el("p", { className: "m02-dfip__note" }); + const pills = variablePills(lg); + const fieldBox = createSelectField({ + label: t("RuleField"), + options: [ + { value: "규격", text: t("FieldSpec") }, + { value: "이름", text: t("FieldName") }, + ], + value: "규격", + compact: true, + onChange: () => void refresh(), + }); + const opBox = createSelectField({ + label: t("RuleOp"), + options: [ + { value: "포함", text: t("OpIn") }, + { value: "같음", text: t("OpEq") }, + ], + value: "포함", + compact: true, + onChange: () => void refresh(), + }); + const pillBox = createSelectField({ + label: t("RulePill"), + options: pills.map((p, i) => ({ + value: String(i), + text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`, + })), + value: "0", + compact: true, + onChange: () => void refresh(), + }); + const defNote = el("p", { className: "m02-dfip__muted" }); + const build = (): CfElementRef => { + const sc = scope.get(); + const rule: CfMaterialRule | undefined = + mode === "rule" && pills[Number(pillBox.select.value)] + ? { + field: fieldBox.select.value as CfMaterialRule["field"], + op: opBox.select.value as CfMaterialRule["op"], + from: pills[Number(pillBox.select.value)].port, + } + : undefined; + return materialElement( + mode, + name.input.value, + { 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() }, + { rule, item: def ?? undefined }, + ); + }; + let seq = 0; + const refresh = async (): Promise => { + const mine = ++seq; + defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone"); + const cand = build(); + note.textContent = `${t("Preview")}: …`; + try { + const p = await previewOf(cand, lg); + if (mine !== seq) return; + const n = catchNote(p.found, p.names[0]); + note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`; + note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; + } catch { + if (mine === seq) note.textContent = t("Failed"); + } + }; + name.input.addEventListener("input", () => name.setError(undefined)); + word.input.addEventListener("input", () => void refresh()); + const add = createButton({ + label: t("Put"), + size: "sm", + onClick: () => { + const cand = build(); + const why = checkMaterial(cand, els); + name.setError(why ?? undefined); + if (why) return void showToast(why, "error"); + on.change(addEl(lg, cand)); + }, + }); + const clear = createButton({ + label: t("DefaultClear"), + variant: "ghost", + size: "sm", + onClick: () => ((def = null), void refresh()), + }); + body.replaceChildren( + el("p", { + className: "m02-dfip__muted", + text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"), + }), + name.root, + el("h5", { text: t("Scope") }), + scope.root, + word.root, + ...(mode === "rule" + ? pills.length + ? [fieldBox.root, opBox.root, pillBox.root] + : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })] + : [ + el("h5", { text: t("Default") }), + defNote, + clear, + itemList((it) => ((def = it), void refresh())), + ]), + note, + add, + ); + void refresh(); + }; + + host.replaceChildren(chosen, chips, body); + drawChosen(); + drawChips(); + drawBody(); +} diff --git a/resources/tester/test_m02_df_inputpanel_material.py b/resources/tester/test_m02_df_inputpanel_material.py new file mode 100644 index 000000000..43355562d --- /dev/null +++ b/resources/tester/test_m02_df_inputpanel_material.py @@ -0,0 +1,246 @@ +"""단가산출 흐름도 입력 판 — 재료 요소 방식 셋 · 쪽 넘기기 · 다른 로직 입력(`M02_DF_InputPanel_Build.ts`). + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름. 견본 상자 · 로직은 이 시험 안에 둠. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_InputPanel_Build.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(jobs.map((j) => m[j.name](...j.args)))); +""" + + +def call(name: str, *args): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout)[0] + + +PLAIN = {"color": "#fff", "unit": "-", "x": 0, "y": 0} + + +def logic() -> dict: + return { + "key": None, + "name": "견본", + "resultUnit": "원/㎡", + "userElements": [], + "boxes": [ + { + "id": "b1", + "kind": "input", + "name": "입력", + **PLAIN, + "input": {"mode": "variable", "elements": [], "variables": []}, + }, + { + "id": "b2", + "kind": "input", + "name": "관경", + **PLAIN, + "input": { + "mode": "variable", + "elements": [], + "variables": [{"name": "관경", "type": "text", "unit": "", "example": "300"}], + }, + }, + ], + "links": [], + } + + +HUME = {"name": "각형흄관", "spec": "D=300mm"} + + +def test_규칙이_품목을_잡음_규격_이름_포함_같음(): + in_spec = {"field": "규격", "op": "포함"} + assert call("matches", HUME, in_spec, "300") is True + assert call("matches", HUME, in_spec, "400") is False + assert call("matches", HUME, {"field": "이름", "op": "포함"}, "흄관") is True + assert call("matches", HUME, {"field": "이름", "op": "같음"}, "흄관") is False + assert ( + call("matches", HUME, {"field": "이름", "op": "같음"}, "각형 흄관") is True + ) # 띄어쓰기는 가리지 않음 + assert call("matches", HUME, in_spec, "") is False # 알약 값이 비면 안 잡음 + + +def test_잡힌_수에_따른_알림(): + assert call("catchNote", 0)["kind"] == "none" + one = call("catchNote", 1, "각형흄관 D=300mm") + assert one["kind"] == "one" and "각형흄관" in one["text"] + many = call("catchNote", 3) + assert many["kind"] == "many" and "3 개" in many["text"] + + +def test_쪽_넘기기_범위(): + assert call("pageRange", 25, 1, 12) == {"from": 1, "to": 12, "pages": 3} + assert call("pageRange", 25, 3, 12) == {"from": 25, "to": 25, "pages": 3} + assert call("pageRange", 25, 9, 12) == { + "from": 25, + "to": 25, + "pages": 3, + } # 쪽이 넘치면 마지막 쪽 + assert call("pageRange", 0, 1, 12) == {"from": 0, "to": 0, "pages": 1} + + +def test_변수_알약_목록(): + pills = call("variablePills", logic()) + assert pills == [{"port": {"box": "b2", "name": "관경"}, "unit": "", "example": "300"}] + + +def test_선택_자리와_조건_찾기_요소(): + item = {"ref": "MT009686", "spec": "D=300mm", "unit": "M"} + slot = call( + "materialElement", + "choice", + "흄관", + {"구분": "관·이음관", "상세구분": "", "찾는말": "흄관"}, + {"item": item}, + ) + assert slot["mode"] == "choice" and slot["name"] == "흄관" and slot["slot"] == "흄관" + assert slot["default"] == "MT009686" and slot["scope"] == { + "구분": "관·이음관", + "찾는말": "흄관", + } + rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}} + found = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule}) + assert found["mode"] == "rule" and found["ref"] == "" and found["rule"] == rule + assert "default" not in found + + +def test_재료_요소_검사(): + ok = call("materialElement", "choice", "흄관", {"찾는말": "흄관"}) + assert call("checkMaterial", ok, []) is None + assert call("checkMaterial", {**ok, "name": ""}, []) == "자리 이름을 적어 주세요" + assert call("checkMaterial", ok, [ok]) == "같은 이름의 알약이 이미 있음" + assert "범위" in call("checkMaterial", call("materialElement", "choice", "흄관", {}), []) + rule_no_pill = call( + "materialElement", + "rule", + "흄관", + {"찾는말": "흄관"}, + {"rule": {"field": "규격", "op": "포함", "from": {"box": "", "name": ""}}}, + ) + assert "변수 알약" in call("checkMaterial", rule_no_pill, []) + + +def test_조건_찾기_요소는_품목_키가_없어도_따로_넣고_이름으로_뺌(): + rule = {"field": "규격", "op": "포함", "from": {"box": "b2", "name": "관경"}} + a = call("materialElement", "rule", "흄관", {"찾는말": "흄관"}, {"rule": rule}) + b = call("materialElement", "rule", "받침", {"찾는말": "받침"}, {"rule": rule}) + lg = call("addElement", logic(), "b1", a) + lg = call("addElement", lg, "b1", b) + assert [e["name"] for e in lg["boxes"][0]["input"]["elements"]] == ["흄관", "받침"] + assert call("addElement", lg, "b1", a) == lg # 같은 이름은 한 번만 + gone = call("removeElementNamed", lg, "b1", "흄관") + assert [e["name"] for e in gone["boxes"][0]["input"]["elements"]] == ["받침"] + + +def other_logic() -> dict: + return { + "key": "DF-0009", + "name": "시간당 작업량", + "resultUnit": "㎥/시간", + "userElements": [], + "links": [], + "boxes": [ + { + "id": "i1", + "kind": "input", + "name": "조건", + **PLAIN, + "input": { + "mode": "variable", + "elements": [], + "variables": [{"name": "q", "type": "number", "unit": "㎥", "example": 6}], + }, + }, + { + "id": "i2", + "kind": "input", + "name": "재료", + **PLAIN, + "input": { + "mode": "group", + "group": "재료", + "elements": [ + { + "ref": "", + "name": "흄관", + "spec": "", + "unit": "M", + "mode": "rule", + "slot": "흄관", + } + ], + "variables": [], + }, + }, + { + "id": "o1", + "kind": "output", + "name": "작업량", + **PLAIN, + "unit": "㎥/시간", + "output": {"cost": "값", "unit": "㎥/시간"}, + }, + ], + } + + +def test_다른_로직_입력_알약(): + ref = call("logicRefOf", "DF-0009", other_logic()) + assert ref["key"] == "DF-0009" and ref["name"] == "시간당 작업량" + assert ref["outputs"] == [{"name": "작업량", "unit": "㎥/시간"}] + assert ref["designs"] == [{"name": "q", "unit": "㎥"}, {"name": "흄관", "unit": "M"}] + + +def test_다른_로직_상자는_출력_단위를_따르고_빼면_변수로_돌아감(): + ref = call("logicRefOf", "DF-0009", other_logic()) + lg = call("setLogicInput", logic(), "b1", ref) + box = lg["boxes"][0] + assert box["input"]["mode"] == "logic" and box["input"]["logic"]["key"] == "DF-0009" + assert box["unit"] == "㎥/시간" + assert call("pillNames", box) == ["작업량"] + lg["links"] = [ + {"id": "l1", "from": {"box": "b1", "name": "작업량"}, "to": {"box": "b2", "name": "x"}} + ] + back = call("setLogicInput", lg, "b1", None) + assert back["boxes"][0]["input"]["mode"] == "variable" and back["links"] == [] + assert call("reconcileUnit", lg, "b1") == lg # 다른 로직 상자 단위는 안 건드림 + + +def test_갈래를_다른_로직으로_바꾸면_고른_것을_비움(): + el = {"ref": "LB1", "name": "석공", "spec": "", "unit": "원/일"} + lg = call("addElement", call("setKind", logic(), "b1", "인력"), "b1", el) + out = call("setKind", lg, "b1", "다른 로직") + assert ( + out["boxes"][0]["input"]["mode"] == "logic" and out["boxes"][0]["input"]["elements"] == [] + )