- 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
213 lines
8.4 KiB
TypeScript
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];
|
|
}
|