/* ============================================================================= * M02_DF_Rule.ts * 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음 * * 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식 * lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림) → 맞은 줄 수 · INDEX/MATCH * gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { CfGateOp, CfRound, CfRule } from "./M02_DF_Model"; type Scalar = number | string | boolean | null; /** 규칙을 지난 값 — 통과 안 됨 · 표에 없는 조건 · 둘 이상 맞음은 value null + why */ export interface CfRuleResult { value: Scalar; why: "통과 안 됨" | "표에 없는 조건" | "표에 여럿 맞음" | "오류" | null; /** 받는 칸에 들어갈 엑셀 수식(보이기 · 시험) */ formula: string; } const TEXT = { Lookup: ["표에서 찾기", "Look up in table"], Gate: ["일 때만", "only when"], Places: ["소수 {n}자리", "{n} decimals"], Round: ["반올림", "round"], Up: ["올림", "round up"], Down: ["버림", "round down"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; /** 엑셀 비교 기호 */ const OP: Record = { "=": "=", "≠": "<>", "≥": ">=", "≤": "<=", ">": ">", "<": "<", }; const ROUND_FN: Record = { 반올림: "ROUND", 올림: "ROUNDUP", 버림: "ROUNDDOWN", }; /** 선 위에 늘 보일 규칙 글 */ export function ruleLabel(rule: CfRule): string { switch (rule.kind) { case "lookup": { const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · "); return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`; } case "gate": return `${rule.from.name} ${rule.op} ${rule.value} ${tl("Gate")}`; case "scale": return rule.op === "+%" || rule.op === "-%" ? `${rule.op[0]}${rule.value}%` : `${rule.op} ${rule.value}`; case "round": return `${tl("Places").replace("{n}", String(rule.places))} ${roundWord(rule.method)}`; } } const roundWord = (m: CfRound["method"]): string => m === "반올림" ? tl("Round") : m === "올림" ? tl("Up") : tl("Down"); /* ── 엔진 한 번 ─────────────────────────────────────────────────────────── */ const fromCalc = (v: CalcValue | undefined): Scalar => { if (!v) return null; if ("수" in v) return Number(v.수); if ("글" in v) return v.글; if ("참" in v) return v.참; return v.오류; }; const cellOf = (v: unknown): Cell | null => v === null || v === undefined || v === "" ? null : typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? { 값: v } : { 값: String(v) }; /** 칸들을 시트 하나로 셈 → 읽을 칸 값 */ function run(칸: Record, read: string[]): Scalar[] { const id = "rule"; const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [{ id, 이름: "규칙", 칸 }], }); return read.map((a) => 칸[a]?.식 !== undefined ? fromCalc(values[id]?.[a]) : (칸[a]?.값 ?? null), ); } const isError = (v: Scalar): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); const lit = (v: number | string): string => typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`; /* ── 규칙 ───────────────────────────────────────────────────────────────── */ /** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */ export function applyRule( rule: CfRule, value: unknown, conds: Record = {}, ): CfRuleResult { if (rule.kind === "lookup") return lookup(rule, value, conds); const 칸: Record = {}; const a1 = cellOf(Array.isArray(value) ? null : value); if (a1) 칸.A1 = a1; let formula: string; if (rule.kind === "gate") { const c1 = cellOf(conds[rule.from.name]); if (c1) 칸.C1 = c1; formula = `IF(C1${OP[rule.op]}${lit(rule.value)},A1,NA())`; } else if (rule.kind === "scale") { const v = rule.value; formula = rule.op === "×" ? `A1*${v}` : rule.op === "÷" ? `A1/${v}` : rule.op === "+%" ? `A1*(1+${v}/100)` : `A1*(1-${v}/100)`; } else formula = `${ROUND_FN[rule.method]}(A1,${rule.places})`; 칸.B1 = { 식: formula }; const [got] = run(칸, ["B1"]); if (rule.kind === "gate" && got === "#N/A") return { value: null, why: "통과 안 됨", formula }; if (isError(got)) return { value: null, why: "오류", formula }; return { value: got, why: null, formula }; } /** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) */ function lookup( rule: Extract, value: unknown, conds: Record, ): CfRuleResult { const block = Array.isArray(value) ? (value as unknown[][]) : []; const head = (block[0] ?? []).map((h) => String(h ?? "")); const rows = block.length - 1; const W = head.length; const 칸: Record = {}; block.forEach((line, r) => line.forEach((x, c) => { const cell = cellOf(x); if (cell) 칸[`${colName(c)}${r + 1}`] = cell; }), ); const at = (name: string): string | null => { const i = head.indexOf(name); return i < 0 ? null : colName(i); }; const out = at(rule.col); // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 const tests: ((r: number) => string)[] = []; let bad = !out || rows < 1; rule.conds.forEach((c, i) => { const ref = `$${colName(W + 1 + i)}$1`; const cv = cellOf(conds[c.from.name]); if (cv) 칸[`${colName(W + 1 + i)}1`] = cv; const same = at(c.col); const low = at(`${c.col}·아래`); const high = at(`${c.col}·위`); if (same) tests.push((r) => `${same}${r}=${ref}`); else if (low && high) tests.push( (r) => `AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r}))`, ); else bad = true; }); const hc = colName(W + 1 + rule.conds.length); const last = rows + 1; for (let r = 2; r <= last; r++) 칸[`${hc}${r}`] = { 식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1", }; const range = `${hc}2:${hc}${Math.max(2, last)}`; const count = `${colName(W + 2 + rule.conds.length)}1`; const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()"; 칸[count] = { 식: `SUM(${range})` }; 칸.ZZ1 = { 식: formula }; if (bad) return { value: null, why: "오류", formula }; const [n, got] = run(칸, [count, "ZZ1"]); if (n === 0) return { value: null, why: "표에 없는 조건", formula }; if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula }; if (isError(got)) return { value: null, why: "오류", formula }; return { value: got, why: null, formula }; } /** 끝수 적용 — 엔진 ROUND · ROUNDUP · ROUNDDOWN */ export function applyRound(round: CfRound, value: number): number { const [got] = run( { A1: { 값: value }, B1: { 식: `${ROUND_FN[round.method]}(A1,${round.places})` } }, ["B1"], ); return typeof got === "number" ? got : value; }