- 표에서 찾기 범위 칸: 아래 = 위면 값 == 그 수 · 다르면 아래 < 값 <= 위(빈 끝 열림) — 견치돌 높이 4 같은 줄 - 시험 24개 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
/* =============================================================================
|
||
* 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<CfGateOp, string> = {
|
||
"=": "=",
|
||
"≠": "<>",
|
||
"≥": ">=",
|
||
"≤": "<=",
|
||
">": ">",
|
||
"<": "<",
|
||
};
|
||
const ROUND_FN: Record<CfRound["method"], string> = {
|
||
반올림: "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<string, Cell>, 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<string, unknown> = {},
|
||
): CfRuleResult {
|
||
if (rule.kind === "lookup") return lookup(rule, value, conds);
|
||
const 칸: Record<string, Cell> = {};
|
||
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<CfRule, { kind: "lookup" }>,
|
||
value: unknown,
|
||
conds: Record<string, unknown>,
|
||
): 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<string, Cell> = {};
|
||
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) =>
|
||
`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",
|
||
};
|
||
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;
|
||
}
|