- 표 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(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
246 lines
9.9 KiB
TypeScript
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];
|
|
}
|