- 상자 더하기(종류별 틀 시트 · 칸 A 이름 · B 값 · C 설명) · 지우기 · 같은 이름은 「 (2)」 - 선 잇기 = 받는 입력 알약 칸에 「'시트'!칸」 참조 수식(여러 선은 +) · 끊기 = 그 참조 뺌 · 순환 · 같은 상자 · 두 번 · 없는 알약은 막고 까닭 - 알약 정하기 · 빼기(이름 · 칸이 바뀌면 선이 따라감) - 바깥 값 칸 목록 · 넣기(표는 칸 덩어리) · 화면 계산 = 우리 스프레드시트 엔진 recalcWorkbook · 결과 노무비 · 재료비 · 경비 · 계 - 검사: 빈 입력 알약 · 안 받은 바깥 값 · 결과 상자 없음 / 선 없음 · 오류 칸 · 순환(#CYCLE!) - 시험 resources/tester/test_m01_sl_model.py 10개(엔진 그대로 Node) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
167 lines
7.1 KiB
TypeScript
167 lines
7.1 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 } 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"],
|
|
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";
|
|
return sl.nodes.flatMap((n) =>
|
|
[...n.pillsIn, ...n.pillsOut]
|
|
.filter((p) => p.outside)
|
|
.map((p) => ({ node: n.id, cell: p.cell, kind: kindOf(n), ref: n.ref ?? "" })),
|
|
);
|
|
}
|
|
|
|
/** 칸 하나에 값 — 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];
|
|
}
|