feat(m02-df): 입력 판 재료 방식 셋 · 자재 찾기 쪽 넘기기 · 다른 로직 입력 초안

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
2026-10-01 19:45:30 +09:00
co-authored by Claude Sonnet 5.5
parent 5f993e4075
commit eb2661ab18
5 changed files with 1189 additions and 14 deletions
@@ -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<string, unknown> {
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<MatItem, "name" | "spec">,
rule: Pick<CfMaterialRule, "field" | "op">,
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<MatItem, "ref" | "spec" | "unit"> } = {},
): 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<string>();
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,