/* ============================================================================= * M02_CF_InputPanel_Build.ts * 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic) * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_CF_InputPanel.ts` * ========================================================================== */ import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_CF_Model"; /** 입력 상자가 고르는 그룹 — 화면 차례 */ export const GROUPS: CfGroup[] = [ "인력", "재료", "유가전력", "기계", "소요량", "계수", "환율", "요율", ]; /** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */ export const M01_OF: Record = { 인력: { group: "인력", part: "" }, 재료: { group: "재료", part: "자재품목" }, 유가전력: { group: "재료", part: "유가전력" }, 기계: { group: "기계", part: "" }, 소요량: { group: "소요량", part: "" }, 계수: { group: "계수", part: "" }, 환율: { group: "환율", part: "" }, 요율: { group: "요율", part: "" }, }; /** M01 줄 한 개 — 파일 그대로(한글 칸) */ export type M01Row = Record; /** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */ export const USER_PREFIX = "UE-"; /** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */ export const MACHINE_SEP = "#"; export const machineFields = (row: M01Row): string[] => row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : []; const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); const dictUnit = (row: M01Row, field: string): string => row["단위"] && typeof row["단위"] === "object" ? text((row["단위"] as Record)[field]) : ""; /** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */ export function lowest(row: M01Row): number | null { const found = ["물가자료", "유통물가", "물가정보", "거래가격"] .map((k) => row[k]) .filter((v): v is number => typeof v === "number"); return found.length ? Math.min(...found) : null; } /** 줄 옆에 보일 값 — 모르면 null */ export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null { if (group === "재료") return lowest(row); if (group === "기계") return typeof (row["값"] as Record | undefined)?.[field] === "number" ? (row["값"] as Record)[field] : null; if (group === "요율") { const v = row["값"] as Record | undefined; return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null; } return typeof row["값"] === "number" ? row["값"] : null; } /** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */ export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef { const key = text(row["키"]); const spec = text(row["규격"]); if (key.startsWith(USER_PREFIX)) return { ref: key, name: [text(row["이름"]), spec].filter(Boolean).join(" "), spec, unit: text(row["단위"]) || "-", }; const named = [text(row["이름"]), spec].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게) if (group === "기계") return { ref: `${key}${MACHINE_SEP}${field}`, name: `${named} · ${field}`, spec, unit: dictUnit(row, field) || "-", }; if (group === "요율") return { ref: key, name: text(row["이름"]), spec: "", unit: valueOf(group, row) === null ? "-" : "%", }; return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" }; } /** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */ export function pillNames(box: CfBox): string[] { const i = box.input; if (!i) return []; if (i.table) return ["표"]; return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name); } /** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */ export function queryOf(group: CfGroup, ref: string): Record { if (ref.startsWith(USER_PREFIX)) return { group: "사용자", key: ref }; if (group === "기계") { const [key, field] = ref.split(MACHINE_SEP); return { group, key, field: field || "가격" }; } return { group, key: ref }; } /** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */ export function unitOfInput(input: CfInput): string { if (input.table) return "-"; const units = ( input.mode === "variable" ? input.variables.map((v) => v.unit) : input.elements.map((e) => e.unit) ).map((u) => u || "-"); return units.length && units.every((u) => u === units[0]) ? units[0] : "-"; } /** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */ export function checkVariable(v: CfVariable, others: CfVariable[]): string | null { const name = v.name.trim(); if (!name) return "이름을 적어 주세요"; if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음"; if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상"; if (v.type === "number") { if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함"; if ( typeof v.example === "number" && v.range && (v.example < v.range[0] || v.example > v.range[1]) ) return "예시값이 범위 밖"; if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자"; } if ( v.type === "choice" && v.example !== null && !(v.choices ?? []).map(String).includes(String(v.example)) ) return "예시값이 선택 목록에 없음"; return null; } /* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */ type Pills = (box: CfBox) => string[]; const defaultPills: Pills = pillNames; const touch = ( lg: CfLogic, id: string, fn: (b: CfBox) => CfBox, pills: Pills = defaultPills, ): CfLogic => { const old = lg.boxes.find((b) => b.id === id); if (!old) return lg; const next = fn(old); const boxes = lg.boxes.map((b) => b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b, ); // 알약이 없어진 선은 지움 const keep = new Set(pills(next)); const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name)); return { ...lg, boxes, links }; }; const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] }; /** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */ 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: "variable", elements: [], variables: cur.variables } }; const same = cur.mode === "group" && cur.group === kind; return { ...b, input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] }, }; }, pills, ); } /** 요소 넣기 — 같은 요소는 한 번만 */ export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic { return touch( lg, id, (b) => { const cur = inputOf(b); return cur.elements.some((e) => e.ref === el.ref || e.name === el.name) ? b : { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } }; }, pills, ); } export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic { return touch( lg, id, (b) => ({ ...b, input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) }, }), pills, ); } /** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */ export function setTable( lg: CfLogic, id: string, table: { key: string; row?: number } | null, name?: string, pills?: Pills, ): CfLogic { const next = touch( lg, id, (b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }), pills, ); return name ? { ...next, boxes: next.boxes.map((b) => b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b, ), } : next; } /** 변수 넣기 · 고치기(이름이 같으면 바꿈) */ export function putVariable( lg: CfLogic, id: string, v: CfVariable, oldName?: string, pills?: Pills, ): CfLogic { return touch( lg, id, (b) => { const cur = inputOf(b); const vars = oldName === undefined ? [...cur.variables, v] : cur.variables.map((x) => (x.name === oldName ? v : x)); return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } }; }, pills, ); } export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic { return touch( lg, id, (b) => ({ ...b, input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) }, }), pills, ); }