Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Eval.ts
T
eomsangdonandClaude Opus 5.5 ebf51d8b35 feat(m01-sl): 표 상자 이음매 — 블록은 표 알약 칸부터 · 줄면 옛 줄 지움 · 머리 줄 바뀜 알림
- 표 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(A1 · E1 무엇이든) · applyOutside 가 그 칸부터 머리 줄 포함 채움
- 표가 줄었으면 블록 열 아래 남은 옛 값 칸을 지움(찾기 판 수식은 블록 열 밖이라 그대로)
- answersToValues(cells, 답, sl) — 시트 머리 줄과 받은 머리 줄이 다르면 쉬운 말
- addNode(table) = 빈 시트(Table 이 블록 · 찾기 판 · 알약을 채움 — 틀 칸이 블록과 겹치지 않게)
- 시험 15개(Table_Build 와 잇는 시험 더함)

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

246 lines
9.9 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"],
/** {box} */
HeadChanged: [
"「{box}」 표 머리 줄이 바뀜 — 표를 다시 골라야 함",
"Table headers of 「{box}」 changed — choose the table again",
],
/** {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[],
sl?: SheetLogic,
): { 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;
}
const head = sl && a.rows ? headChanged(sl, c, a.rows[0] ?? []) : null;
if (head) errors.push(head);
values.push({ node: c.node, cell: c.cell, value: a.rows ?? a.value ?? null });
}
return { values, errors };
}
/** 표 블록 머리 줄이 시트에 있던 것과 다르면 쉬운 말(열 차례가 바뀌면 찾기 판이 엉뚱한 열을 봄) */
function headChanged(
sl: SheetLogic,
c: SlOutside,
head: (string | number | null)[],
): string | null {
const sheet = nodeSheet(sl, c.node);
const at = parseA1(c.cell);
if (!sheet || !at) return null;
const now = head.map((_, dc) => sheet.칸[toA1(at.r, at.c + dc)]?.값 ?? null);
if (now.every((x) => x === null)) return null; // 처음 채움
const same = head.length === now.length && head.every((h, i) => h === now[i]);
const box = sl.nodes.find((n) => n.id === c.node)?.name ?? "";
return same ? null : tl("HeadChanged", { box });
}
/** 칸 하나에 값 — 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)),
);
// 표가 줄었으면 블록 열 아래 남은 옛 줄(값 칸)을 지움 — 수식 칸(찾기 판 · 열쇠)은 블록 열 밖이라 안 건드림
const width = Math.max(0, ...v.value.map((line) => line.length));
const last = at.r + v.value.length - 1;
for (const a of Object.keys(sheet.칸)) {
const p = parseA1(a);
const c = sheet.칸[a];
if (p && p.r > last && p.c >= at.c && p.c < at.c + width && c.식 === undefined)
putValue(sheet.칸, a, null);
}
}
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];
}