Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts
T
eomsangdonandClaude Opus 5.5 a774b4e97b feat(m02-df): 36-2 덧 — 재료 조건 찾기 「검색어」 틀 · 표 범위 경계 규칙 칸
- Model CfMaterialRule field 「검색어」 + template(「레미콘 {a}-{b}-{c}」) + slots(자리마다 알약) · materialSources · depends · 흐름 따라가기에 자리 알약도
- Compute 재료 조건 찾기 검색어 = 틀에 자리 알약 값을 끼운 글(옛 fill)을 dv.slot_price 값으로 넘김(서버 찾기는 sub4)
- 표 찾기: 표 블록 「·범위규칙」 칸(줄마다 초과·이하 · 이상·이하 · 이상·미만 · 이하)을 Rule lookup · Compute _lookup 이 따름 · 없으면 초과·이하
- 변환: 재료 검색어 자리 = 검색어 틀 · scope 구분 · 상세구분 · 조건 찾기 요소로 들어가는 선은 안 만듦(depends 로 이음)

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

256 lines
11 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 };
}
/** 표 블록 범위 경계 규칙 칸(줄마다 「초과·이하」 「이상·이하」 「이상·미만」 「이하」 · Values._table) */
export const RANGE_RULE = "·범위규칙";
/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · 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[] = [];
// 범위 경계 — 줄마다 「·범위규칙」 칸(이상 … = 아래 끝 포함 · … 미만 = 위 끝 뺌) · 없으면 초과 · 이하
const rc = at(RANGE_RULE);
const lowCmp = (c: string, r: number, x: string): string =>
rc ? `IF(LEFT(${rc}${r},2)="이상",${c}${r}<=${x},${c}${r}<${x})` : `${c}${r}<${x}`;
const highCmp = (c: string, r: number, x: string): string =>
rc ? `IF(RIGHT(${rc}${r},2)="미만",${x}<${c}${r},${x}<=${c}${r})` : `${x}<=${c}${r}`;
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}),${lowCmp(low, r, ref)}),OR(ISBLANK(${high}${r}),${highCmp(high, r, ref)})))`,
);
} 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;
}