Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Eval.ts
T
eomsangdonandClaude Opus 5.5 ef0b4ae385 feat(m01-sl): 시트 로직 상자 이름 바꾸기 · 바깥 값 약속
- renameNode — 엔진 시트 이름 명령으로 시트 이름 · 그 시트를 가리키던 선 수식이 따라감 · 1~31 글자 · 못 쓰는 글자 · 같은 이름은 막고 까닭
- 상자 더하기 이름도 시트 이름 규칙대로 고쳐 만듦(못 쓰는 글자 → · · 31 글자)
- SlSource(서버 바깥 값 물음 한 줄) · SlField · SlOutside.id(상자 id!칸) · source · SlNode.source
- 표 범위 칸 약속 RANGE_LOW · RANGE_HIGH · rangeHead(이름) = 「이름·아래」 「이름·위」
- answersToValues — 서버 답(id · ok · value · rows) → 넣을 값 · 못 받은 것은 쉬운 말
- 시험 13개

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

213 lines
8.4 KiB
TypeScript

/* =============================================================================
* M01_SL_Eval.ts
* 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사
* 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음
* 엔진을 정적으로 가져옴 — 화면(Entry)은 SheetLogic 을 import() 로 늦게 불러 M01 첫 묶음에 안 들어가게
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, toA1 } 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 {
nodeSheet,
type SheetLogic,
type SlNode,
type SlOutside,
type SlSource,
} from "./M01_SL_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */
export type SlValue = number | string | boolean | null;
/** 바깥 값 하나 — 표는 칸 덩어리(cell 이 왼위) */
export interface SlOutsideValue {
node: string;
cell: string;
value: SlValue | SlValue[][];
}
export interface SlEvalResult {
/** 상자 id → 출력 알약 이름 → 값 */
pills: Record<string, Record<string, SlValue>>;
/** 상자 id → 입력 알약 이름 → 값(화면 알약 위 숫자) */
pillsIn: Record<string, Record<string, SlValue>>;
result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null };
/** 쉬운 말 */
errors: string[];
}
const TEXT = {
/** {box} {cell} {err} */
CellError: [
"「{box}」 {cell} 칸 계산이 막힘 — {err}",
"「{box}」 cell {cell} can't be worked out — {err}",
],
Cycle: ["돌고 도는 참조", "circular reference"],
/** {box} {pill} */
EmptyIn: [
"「{box}」의 입력 알약 「{pill}」이 비어 있음",
"Input pill 「{pill}」 of 「{box}」 is empty",
],
NoOutside: [
"「{box}」의 바깥 값 「{pill}」을 아직 안 받음",
"Outside value 「{pill}」 of 「{box}」 not filled yet",
],
NoResult: ["결과 상자가 없음", "There is no result box"],
/** {cell} {why} */
NoAnswer: [
"{cell} 칸 바깥 값을 못 받음 {why}",
"Couldn't get the outside value for {cell} {why}",
],
NoLine: ["결과 상자에 이어진 선이 없음", "No line reaches the result box"],
} as const;
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, v),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
/* ── 바깥 값 ────────────────────────────────────────────────────────────── */
/** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */
export function outsideCells(sl: SheetLogic): SlOutside[] {
const kindOf = (n: SlNode): SlOutside["kind"] =>
n.kind === "table" ? "table" : n.kind === "logic" ? "logic" : "price";
const sourceOf = (n: SlNode): SlSource | undefined =>
n.source ??
(n.ref && n.kind === "table" ? { kind: "표", table: n.ref } : undefined) ??
(n.ref && n.kind === "logic" ? { kind: "로직", key: n.ref, inputs: {} } : undefined);
return sl.nodes.flatMap((n) =>
[...n.pillsIn, ...n.pillsOut]
.filter((p) => p.outside)
.map((p) => {
const source = sourceOf(n);
const at = { id: `${n.id}!${p.cell}`, node: n.id, cell: p.cell, kind: kindOf(n) };
return { ...at, ref: n.ref ?? "", ...(source ? { source } : {}) };
}),
);
}
/** 서버 바깥 값 답 한 줄(`/api/m01/sl/outside`) */
export interface SlOutsideAnswer {
id: string;
ok: boolean;
value?: number | null;
rows?: (string | number | null)[][];
reason?: string;
}
/** 서버 답 → 넣을 값 · 못 받은 것은 쉬운 말 */
export function answersToValues(
cells: SlOutside[],
answers: SlOutsideAnswer[],
): { values: SlOutsideValue[]; errors: string[] } {
const values: SlOutsideValue[] = [];
const errors: string[] = [];
for (const c of cells) {
const a = answers.find((x) => x.id === c.id);
if (!a || !a.ok) {
errors.push(tl("NoAnswer", { cell: c.cell, why: a?.reason ?? "" }));
continue;
}
values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null });
}
return { values, errors };
}
/** 칸 하나에 값 — null 이면 값을 뺌(서식은 둠) */
function putValue(칸: Record<string, Cell>, cell: string, value: SlValue): void {
const style = 칸[cell]?.서식;
if (value === null) {
if (style) 칸[cell] = { 서식: style };
else delete 칸[cell];
} else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) };
}
/** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */
export function applyOutside(sl: SheetLogic, values: SlOutsideValue[]): SheetLogic {
const out = structuredClone(sl);
for (const v of values) {
const sheet = nodeSheet(out, v.node);
const at = parseA1(v.cell);
if (!sheet || !at) continue;
if (!Array.isArray(v.value)) {
putValue(sheet.칸, toA1(at.r, at.c), v.value);
continue;
}
v.value.forEach((line, dr) =>
line.forEach((x, dc) => putValue(sheet.칸, toA1(at.r + dr, at.c + dc), x)),
);
}
return out;
}
/* ── 계산 ───────────────────────────────────────────────────────────────── */
const fromCalc = (v: CalcValue | undefined): SlValue => {
if (!v) return null;
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
};
const isNum = (v: SlValue): v is number => typeof v === "number" && Number.isFinite(v);
/** 화면 계산 */
export function evalLogic(sl: SheetLogic): SlEvalResult {
const values = recalcWorkbook(sl.workbook);
const read = (n: SlNode, cell: string): SlValue => {
const c = nodeSheet(sl, n.id)?.칸[cell.toUpperCase()];
if (!c) return null;
if (c.식 !== undefined) return fromCalc(values[n.sheetId]?.[cell.toUpperCase()]);
return c.값 ?? null;
};
const pills: SlEvalResult["pills"] = {};
const pillsIn: SlEvalResult["pillsIn"] = {};
for (const n of sl.nodes) {
pills[n.id] = Object.fromEntries(n.pillsOut.map((p) => [p.name, read(n, p.cell)]));
pillsIn[n.id] = Object.fromEntries(n.pillsIn.map((p) => [p.name, read(n, p.cell)]));
}
const errors: string[] = [];
for (const n of sl.nodes)
for (const [cell, v] of Object.entries(values[n.sheetId] ?? {}))
if ("오류" in v) {
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
errors.push(tl("CellError", { box: n.name, cell, err }));
}
const box = sl.nodes.find((n) => n.kind === "result");
const num = (v: SlValue | undefined): number | null => (isNum(v ?? null) ? (v as number) : null);
const result: SlEvalResult["result"] = box
? {
노무비: num(pillsIn[box.id]["노무비"]),
재료비: num(pillsIn[box.id]["재료비"]),
경비: num(pillsIn[box.id]["경비"]),
계: num(pills[box.id]["계"]),
}
: { 노무비: null, 재료비: null, 경비: null, 계: null };
return { pills, pillsIn, result, errors };
}
/** 검사 — 빈 입력 알약 · 받지 않은 바깥 값 · 순환 · 오류 칸 · 결과 상자 없음(쉬운 말 목록) */
export function checkLogic(sl: SheetLogic): string[] {
const out: string[] = [];
for (const n of sl.nodes) {
const sheet = nodeSheet(sl, n.id);
for (const p of [...n.pillsIn, ...n.pillsOut]) {
const c = sheet?.칸[p.cell.toUpperCase()];
const empty = !c || (c.식 === undefined && (c.값 === undefined || c.값 === ""));
if (!empty) continue;
if (p.outside) out.push(tl("NoOutside", { box: n.name, pill: p.name }));
// 결과 상자의 비목 알약은 비어도 0 — 안 쓰는 비목이 늘 있음
else if (n.pillsIn.includes(p) && n.kind !== "result")
out.push(tl("EmptyIn", { box: n.name, pill: p.name }));
}
}
const box = sl.nodes.find((n) => n.kind === "result");
if (!box) out.push(tl("NoResult"));
else if (!sl.edges.some((e) => e.to.node === box.id)) out.push(tl("NoLine"));
return [...out, ...evalLogic(sl).errors];
}