Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Rule.ts
T
eomsangdonandClaude Opus 5.5 52a085eb0c feat(m02-df): 36-2 옛 로직 → 흐름도 변환 · 표 찾기 옛 find 와 같은 뜻
- M02_DF_Convert.convert(row, ctx={key, file, master}) → {flow, notes} — 옛 파서 나무 → 함수 상자 시트(칸 주소 · 결과 하나) · 표 찾기 lookup · 로직 줄 비목 셋 · 덧줄 비목 합 · 줄.이름 · 결과 = 값 출력 · 자동 배치
- {입력} 낀 요소(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 재료 검색어) = 조건 찾기 field 「키」(Model CfMaterialRule · Api slot 타입) · 자리 여럿은 글 잇는 함수
- 표 찾기: 빈 조건 칸 = 무엇이든 · 꺼낼 칸 빈 줄 건너뜀(Rule · Compute) · 함수 칸으로 가는 실패는 #N/A 를 넣어 넘김 · 결과가 오류일 때만 끊긴 곳(Eval · Compute 같음)
- 전수 대조 test_m02_df_convert(사본 폴더 1,472 로직 · 시험입력 92.9% · 기본 입력 94.3%) → tmp/convert_report_36.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 23:57:58 +09:00

231 lines
9.3 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 out = at(rule.col);
// 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고
const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${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)
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;
}