Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts
T
eomsangdonandClaude Opus 5.5 8193f29ae9 fix(m02-df): 36-2 변환 대조 96.7% — 표 찾기 위 끝 최소 · 값 칸 범위 갈림 · 로직.계 · 조건 찾기 원문번호
- convert(row, ctx={key, file, master}) — sub4 /api/m01/logic/flow 부르는 꼴 · master 는 ctx 것
- {입력} 요소 찾는 값 = 원문번호(「MO:경유:{지역}」 → "경유:"&지역 글 잇는 함수) · 호표 줄 그룹 = 키 앞 두 글자
- 로직(…).계 = 다른 로직 비목 셋 합
- 표 찾기: 여럿이면 범위 위 끝이 가장 작은 줄(옛 find) · 값 칸이 「·아래」 「·위」 로 갈리면 아래 = 위일 때 그 값(Rule · Compute 같음)
- 표 찾기 실패로 넣은 NA() 칸은 칸 계산 오류로 알리지 않음(Eval · Compute)
- 전수 대조는 서버 길 그대로(sub4 조건 찾기 · 표 식 글) · 입력은 옛 엔진처럼 수로 · 까닭 분류
- 시험입력 1,423/1,472(96.7%) · 기본 입력 1,434/1,472(97.4%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 00:24:22 +09:00

247 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<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": {
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<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, '""')}"`;
/* ── 규칙 ───────────────────────────────────────────────────────────────── */
/** 조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름 */
const condOf = (conds: Record<string, unknown>, 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<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") {
// 조건 값 = 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<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 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;
}