From bad349783be7a68918b4aecb92f15392c9094f08 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 23:42:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-df):=20=EC=9E=85=EB=A0=A5=20=ED=8C=90?= =?UTF-8?q?=20=EC=9D=B8=EB=A0=A5=20=C2=B7=20=EA=B8=B0=EA=B3=84=20=C2=B7=20?= =?UTF-8?q?=EC=9C=A0=EA=B0=80=EC=A0=84=EB=A0=A5=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0(=EC=9D=B4=EB=A6=84=20=EB=98=90=EB=8A=94=20?= =?UTF-8?q?=ED=82=A4=20=EA=B0=99=EC=9D=8C)=20=E2=80=94=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../DataFlow/M02_DF_InputPanel.ts | 88 ++++++++++- .../DataFlow/M02_DF_InputPanel_Build.ts | 46 +++++- .../DataFlow/M02_DF_InputPanel_Rule.ts | 138 ++++++++++++++++++ resources/tester/test_m02_df_input_logic.py | 24 +++ 4 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts index a6a67a002..cce05141a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel.ts @@ -24,6 +24,9 @@ import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Ap import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model"; import { GROUPS, + RULE_GROUPS, + matchesRow, + removeElementNamed, M01_OF, addElement, elementOf, @@ -36,6 +39,7 @@ import { type M01Row, } from "./M02_DF_InputPanel_Build"; import { renderLogicInput } from "./M02_DF_InputPanel_Logic"; +import { renderGroupRule } from "./M02_DF_InputPanel_Rule"; import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick"; import { renderMaterial } from "./M02_DF_InputPanel_Material"; import { renderTablePick } from "./M02_DF_InputPanel_Table"; @@ -63,6 +67,10 @@ const apiAdd = cfUserElementAdd as unknown as (e: Omit) => Promise const TEXT = { Kind: ["무엇을 넣을까요?", "What goes in?"], Variable: ["변수", "Variable"], + ModeFixed: ["고정", "Fixed"], + ModeRule: ["조건 찾기", "Find by rule"], + RuleTag: ["조건 찾기", "Rule"], + RuleLine: ["{f} 이(가) 알약 「{n}」 값과 같음", "{f} equals pill “{n}”"], Logic: ["다른 로직", "Another logic"], 인력: ["인력", "Labor"], 재료: ["재료", "Material"], @@ -193,10 +201,52 @@ function renderGroup( on: { change(lg: CfLogic): void }, ): void { const chosen = el("div", { className: "m02-dfip__chosen" }); - const find = el("div", { className: "m02-dfip__find" }); - host.replaceChildren(chosen, find, newForm(lg, box, group, on)); + const body = el("div", { className: "m02-dfip__stack" }); + let mode: "fixed" | "rule" = "fixed"; + const draw = (): void => { + if (mode === "rule") + renderGroupRule(body, lg, box.id, group, (l, e) => addElement(l, box.id, e), rulePreview, on); + else { + const find = el("div", { className: "m02-dfip__find" }); + body.replaceChildren(find, newForm(lg, box, group, on)); + void drawFind(find, lg, box, group, on); + } + }; + // 인력 · 기계 · 유가전력은 방식(고정 / 조건 찾기)을 고름 — 재료는 재료 판 · 그 밖은 고정만 + const chips = el("div", { className: "m02-dfip__chips" }); + const drawChips = (): void => + chips.replaceChildren( + ...(["fixed", "rule"] as const).map((m) => + createButton({ + label: m === "fixed" ? t("ModeFixed") : t("ModeRule"), + variant: mode === m ? "filled" : "pill", + size: "sm", + onClick: () => ((mode = m), drawChips(), draw()), + }), + ), + ); + host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body); drawChosen(chosen, lg, box, group, on); - void drawFind(find, lg, box, group, on); + if (RULE_GROUPS.includes(group)) drawChips(); + draw(); +} + +/** 조건 찾기 미리 보기 — 알약 값과 이름(키)이 같은 마스터 줄 */ +async function rulePreview( + group: CfGroup, + field: "이름" | "키", + value: unknown, +): Promise<{ found: number; names: string[]; unit: string }> { + if (value === null || value === undefined || value === "") + return { found: 0, names: [], unit: "" }; + const page = await fetchRows(await fileOf(group), 1, 50, String(value), false, "", ""); + const hit = (page.rows as M01Row[]).filter((r) => matchesRow(r, field, value)); + const units = new Set(hit.map((r) => elementOf(group, r).unit)); + return { + found: hit.length, + names: hit.slice(0, 3).map((r) => String(r["이름"] ?? "")), + unit: units.size === 1 ? [...units][0] : "", + }; } /** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */ @@ -214,26 +264,48 @@ function drawChosen( ...(els.length ? els.map((e) => { const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") }); - values.set(e.ref, val); + const rule = e.mode === "rule"; + if (!rule) values.set(e.ref, val); return el("div", { className: "m02-dfip__item", children: [ - el("span", { className: "m02-dfip__name", text: e.name }), + el("span", { + className: "m02-dfip__name", + children: [ + ...(rule ? [el("span", { className: "m02-dfip__tag", text: t("RuleTag") })] : []), + el("strong", { text: ` ${e.name}` }), + ...(rule && e.rule + ? [ + el("div", { + className: "m02-dfip__muted", + text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }), + }), + ] + : []), + ], + }), val, el("span", { className: "m02-dfip__muted", text: e.unit || "-" }), createButton({ label: t("Remove"), variant: "ghost", size: "sm", - onClick: () => on.change(removeElement(lg, box.id, e.ref)), + onClick: () => + on.change( + rule + ? removeElementNamed(lg, box.id, e.name) + : removeElement(lg, box.id, e.ref), + ), }), ], }); }) : [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]), ); - if (!els.length) return; - const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); + const items = els + .filter((e) => e.mode !== "rule") + .map((e) => ({ id: e.ref, ...queryOf(group, e.ref) })); + if (!items.length) return; void apiValues(items, lg.userElements).then( (answers) => answers.forEach((a) => { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts index 3125278d4..b98570887 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts @@ -162,16 +162,21 @@ export function matches( return rule.op === "같음" ? target === v : target.includes(v); } -/** 지금 잡히는 품목 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림 */ +/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */ export function catchNote( found: number, firstName = "", + noun = "재료", ): { kind: "none" | "one" | "many"; text: string } { - if (found === 0) return { kind: "none", text: "맞는 재료가 없음 — 범위나 규칙을 넓혀 봄" }; - if (found === 1) return { kind: "one", text: `재료 하나가 잡힘 — ${firstName}` }; + if (found === 0) + return { + kind: "none", + text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`, + }; + if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` }; return { kind: "many", - text: `재료 ${found} 개가 잡힘 — 둘 이상이면 가장 싼 값을 씀 · 규칙을 더 좁히면 하나로 잡힘`, + text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`, }; } @@ -566,3 +571,36 @@ export function reconcileUnit(lg: CfLogic, id: string): CfLogic { ? lg : { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) }; } + +/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(옛 로직 LB:{직종} · EQ:{기계} 꼴) ── */ + +/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */ +export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"]; + +export type GroupRuleField = "이름" | "키"; + +/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */ +export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean { + const v = squash(value); + if (!v) return false; + const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]]; + return targets.some((x) => squash(x) === v); +} + +/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */ +export function groupRuleElement( + name: string, + field: GroupRuleField, + from: CfMaterialRule["from"], +): CfElementRef { + const rule = { field, op: "같음", from } as unknown as CfMaterialRule; + return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule }; +} + +/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ +export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null { + if (!el.name) return "자리 이름을 적어 주세요"; + if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음"; + if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요"; + return null; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts new file mode 100644 index 000000000..98d23ec15 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Rule.ts @@ -0,0 +1,138 @@ +/* ============================================================================= + * M02_DF_InputPanel_Rule.ts + * 입력 판 — 인력 · 기계 · 유가전력의 「조건 찾기」: 이름 또는 키가 이 로직 변수 알약 값과 같은 줄을 잡음 + * (옛 로직의 LB:{직종} · EQ:{기계} 꼴) · 지금 잡히는 줄 미리 보기(0 · 여럿이면 까닭) · 글 [한, 영] 짝은 아래 + * 선택 자리(choice)는 재료만 — 이 판에 없음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import type { CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model"; +import { + catchNote, + checkGroupRule, + groupRuleElement, + variablePills, + type GroupRuleField, +} from "./M02_DF_InputPanel_Build"; + +const TEXT = { + Help: [ + "변수 알약 값과 이름(또는 키)이 같은 줄을 잡음 — 예: 직종 알약 값이 「석공」이면 석공 줄", + "Catches the row whose name (or key) equals a variable pill's value", + ], + SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"], + Field: ["같아야 하는 칸", "Column"], + FieldName: ["이름", "Name"], + FieldKey: ["키(원문번호)", "Key (source no.)"], + Pill: ["변수 알약", "Variable pill"], + NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"], + Preview: ["지금 잡히는 줄", "Rows caught now"], + Put: ["넣기", "Add"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +type TKey = keyof typeof TEXT; +const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string; + +/** 미리 보기 길 — 그룹 · 칸 · 알약 값으로 마스터 줄을 읽어 잡힌 수 · 이름 · 단위(부르는 쪽이 줌) */ +export type RulePreview = ( + group: CfGroup, + field: GroupRuleField, + value: unknown, +) => Promise<{ found: number; names: string[]; unit: string }>; + +/** 조건 찾기 요소 추가 판 */ +export function renderGroupRule( + host: HTMLElement, + lg: CfLogic, + boxId: string, + group: CfGroup, + addEl: (lg: CfLogic, el: CfElementRef) => CfLogic, + preview: RulePreview, + on: { change(lg: CfLogic): void }, +): void { + const els = lg.boxes.find((b) => b.id === boxId)?.input?.elements ?? []; + const pills = variablePills(lg); + const name = createInputField({ + type: "text", + label: t("SlotName"), + compact: true, + placeholder: group === "인력" ? "예: 직종" : "예: 기종", + }); + const field = createSelectField({ + label: t("Field"), + options: [ + { value: "이름", text: t("FieldName") }, + { value: "키", text: t("FieldKey") }, + ], + value: "이름", + compact: true, + onChange: () => void refresh(), + }); + const pill = createSelectField({ + label: t("Pill"), + 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 note = el("p", { className: "m02-dfip__note" }); + const build = (): CfElementRef => + groupRuleElement( + name.input.value, + field.select.value as GroupRuleField, + pills[Number(pill.select.value)]?.port ?? { box: "", name: "" }, + ); + let seq = 0; + const refresh = async (): Promise => { + const mine = ++seq; + const value = pills[Number(pill.select.value)]?.example; + note.textContent = `${t("Preview")}: …`; + try { + const p = await preview(group, field.select.value as GroupRuleField, value); + if (mine !== seq) return; + const n = catchNote(p.found, p.names[0], "줄"); + note.textContent = `${t("Preview")}: ${n.text}`; + note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`; + } catch { + if (mine === seq) note.textContent = t("Failed"); + } + }; + const add = createButton({ + label: t("Put"), + size: "sm", + onClick: () => { + const cand = build(); + const why = checkGroupRule(cand, els); + name.setError(why ?? undefined); + if (why) return void showToast(why, "error"); + // 잡히는 줄이 하나면 그 단위를 알약 단위로 + void preview( + group, + field.select.value as GroupRuleField, + pills[Number(pill.select.value)]?.example, + ).then( + (p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)), + () => on.change(addEl(lg, cand)), + ); + }, + }); + host.replaceChildren( + el("p", { className: "m02-dfip__muted", text: t("Help") }), + name.root, + ...(pills.length + ? [field.root, pill.root, note, add] + : [el("p", { className: "m02-dfip__muted is-bad", text: t("NoPill") })]), + ); + if (pills.length) void refresh(); +} diff --git a/resources/tester/test_m02_df_input_logic.py b/resources/tester/test_m02_df_input_logic.py index cc91ee13d..1b5dd1765 100644 --- a/resources/tester/test_m02_df_input_logic.py +++ b/resources/tester/test_m02_df_input_logic.py @@ -228,3 +228,27 @@ def test_자기_자신과_돌고_도는_로직은_막음(): assert call("wouldCycle", "SELF", "C", graph) is False assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음 assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음 + + +def test_조건_찾기는_인력_기계_유가전력에_쓰고_이름_또는_키가_같은_줄(): + labor = {"키": "LB000033", "원문번호": "1033", "이름": "석공"} + assert call("matchesRow", labor, "이름", "석공") is True + assert call("matchesRow", labor, "이름", "석 공") is True # 띄어쓰기는 가리지 않음 + assert call("matchesRow", labor, "이름", "할석공") is False # 같음이라 일부만 맞아선 안 잡음 + assert call("matchesRow", labor, "키", "lb000033") is True + assert call("matchesRow", labor, "키", "1033") is True # 원문번호도 키로 침 + assert call("matchesRow", labor, "이름", "") is False # 알약 값이 비면 안 잡음 + + +def test_조건_찾기_요소와_검사(): + port = {"box": "b2", "name": "직종"} + el = call("groupRuleElement", "노임", "이름", port) + assert ( + el["mode"] == "rule" and el["ref"] == "" and el["name"] == "노임" and el["slot"] == "노임" + ) + assert el["rule"] == {"field": "이름", "op": "같음", "from": port} + assert call("checkGroupRule", el, []) is None + assert call("checkGroupRule", {**el, "name": ""}, []) == "자리 이름을 적어 주세요" + assert call("checkGroupRule", el, [el]) == "같은 이름의 알약이 이미 있음" + no_pill = call("groupRuleElement", "노임", "이름", {"box": "", "name": ""}) + assert "변수 알약" in call("checkGroupRule", no_pill, [])