/* ============================================================================= * 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 { gateConds, type CfGateOp, type CfRound, type 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"], And: ["그리고", "and"], Or: ["또는", "or"], 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": { const g = gateConds(rule); const join = g.join === "또는" ? tl("Or") : tl("And"); return `${g.conds.map((c) => `${c.from.name} ${c.op} ${c.value}`).join(` ${join} `)} ${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, '""')}"`; /* ── 규칙 ───────────────────────────────────────────────────────────────── */ /** 조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름 */ const condOf = (conds: Record, p: { box: string; name: string }): unknown => `${p.box}|${p.name}` in conds ? conds[`${p.box}|${p.name}`] : conds[p.name]; /** 규칙 적용 — 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") { // 조건 값 = C1 · D1 · E1 … · 여러 줄은 AND / OR const g = gateConds(rule); const tests = g.conds.map((c, i) => { const at = `${colName(2 + i)}1`; const cv = cellOf(condOf(conds, c.from)); if (cv) 칸[at] = cv; return `${at}${OP[c.op]}${lit(c.value)}`; }); const test = tests.length > 1 ? `${g.join === "또는" ? "OR" : "AND"}(${tests.join(",")})` : tests[0]; formula = `IF(${test},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(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) * 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find · 서버 Compute `_lookup` 과 같음) */ 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 two = !at(rule.col) && !!at(`${rule.col}·아래`) && !!at(`${rule.col}·위`); const out = two ? at(`${rule.col}·아래`) : at(rule.col); // 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고 const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : []; const ups: string[] = []; let bad = !out || rows < 1; rule.conds.forEach((c, i) => { const ref = `$${colName(W + 1 + i)}$1`; const cv = cellOf(condOf(conds, c.from)); 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) => `OR(ISBLANK(${same}${r}),${same}${r}=${ref})`); else if (low && high) { ups.push(high); tests.push( (r) => `IF(AND(NOT(ISBLANK(${low}${r})),${low}${r}=${high}${r}),${ref}=${low}${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", }; // 여럿이면 범위 위 끝이 가장 작은 줄(옛 find · 「20 이하」 「40 이하」 …) — 둘째 맞음 칸 const hc2 = colName(W + 3 + rule.conds.length); const span = (c: string): string => `${c}2:${c}${Math.max(2, last)}`; for (let r = 2; r <= last; r++) { const top = ups.map((h) => `OR(ISBLANK(${h}${r}),${h}${r}=MINIFS(${span(h)},${span(hc)},1))`); 칸[`${hc2}${r}`] = { 식: top.length ? `IF(AND(${hc}${r}=1,${top.join(",")}),1,0)` : `${hc}${r}`, }; } const range = span(hc2); 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 }; const up = at(`${rule.col}·위`); if (two && up) 칸.ZZ2 = { 식: formula.replace(`INDEX(${out}2:${out}`, `INDEX(${up}2:${up}`) }; if (bad) return { value: null, why: "오류", formula }; const [n, got, top] = run(칸, [count, "ZZ1", "ZZ2"]); if (two && n === 1 && got !== top) return { value: null, why: "오류", formula }; 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; }