/* ============================================================================= * M01_SL_Model.ts * 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸 * 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식(여러 선이면 「+」 로 이음) · DOM 없음 * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로 * * 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; import type { Cell, Sheet, Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; /** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */ export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result"; /** 알약 = 이름 붙은 칸 */ export interface SlPill { name: string; /** 「B3」 */ cell: string; unit?: string; /** 바깥 값 칸 — 열 때 · 저장 때 마스터에서 넣음 */ outside?: boolean; } /** 상자 = 시트 하나 */ export interface SlNode { id: string; kind: SlKind; name: string; sheetId: string; pillsIn: SlPill[]; pillsOut: SlPill[]; /** 표 키 · 요소 · 기존 로직 키 · 내 표 id */ ref?: string; /** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */ stage: number; } /** 선 — 출력 알약 → 입력 알약 */ export interface SlEdge { id: string; from: { node: string; pill: string }; to: { node: string; pill: string }; } /** 바깥 값 칸 */ export interface SlOutside { node: string; cell: string; kind: "table" | "price" | "logic"; ref: string; } /** 내 표 — 품셈에 없는 표(사용자가 만듦) */ export interface SlUserTable { id: string; name: string; cols: string[]; rows: (number | string | null)[][]; } export interface SheetLogic { key: string | null; name: string; resultUnit: string; workbook: Workbook; nodes: SlNode[]; edges: SlEdge[]; userTables?: SlUserTable[]; } /** 알약 한쪽 끝 */ export interface SlEnd { node: string; pill: string; } const TEXT = { Name: ["이름", "Name"], Value: ["값", "Value"], Note: ["설명", "Note"], Sample: ["예시값", "Sample value"], Fixed: ["늘 이 값", "Always this value"], Cond: ["조건", "Condition"], Found: ["결과", "Result"], FromTable: ["표에서 찾은 값", "Value found in the table"], ValueA: ["값1", "Value 1"], ValueB: ["값2", "Value 2"], Times: ["값1 × 값2", "Value 1 × Value 2"], Qty: ["수량", "Quantity"], Price: ["단가", "Unit price"], Amount: ["금액", "Amount"], QtyNote: ["선으로 받음", "Comes in by a line"], PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"], LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"], Sum: ["계", "Total"], NoPill: ["알약이 없음", "No such pill"], Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"], Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"], Twice: ["이미 이어져 있음", "Already linked"], } as const; const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; /** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */ const COSTS = ["노무비", "재료비", "경비"] as const; /* ── 작은 도움 ──────────────────────────────────────────────────────────── */ const STAGE: Record = { input: 0, const: 0, table: 1, logic: 1, calc: 2, item: 3, result: 4, }; const clone = (v: T): T => structuredClone(v); /** 다음 번호 — 있는 id 들(「n3」)보다 하나 큰 것 */ function nextId(prefix: string, ids: string[]): string { const nums = ids .filter((x) => x.startsWith(prefix)) .map((x) => Number(x.slice(prefix.length))) .filter((n) => Number.isInteger(n)); return `${prefix}${Math.max(0, ...nums) + 1}`; } /** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」) */ function uniqueName(book: Workbook, name: string): string { const base = name.trim() || "상자"; const used = new Set(book.시트.map((s) => s.이름)); if (!used.has(base)) return base; for (let n = 2; ; n++) if (!used.has(`${base} (${n})`)) return `${base} (${n})`; } const sheetOf = (sl: SheetLogic, node: SlNode): Sheet => { const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); if (!sheet) throw new Error(`없는 시트 ${node.sheetId}`); return sheet; }; const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined => sl.nodes.find((n) => n.id === id); const pillOf = (node: SlNode, dir: "in" | "out", name: string): SlPill | undefined => (dir === "in" ? node.pillsIn : node.pillsOut).find((p) => p.name === name); /* ── 상자 틀 ─────────────────────────────────────────────────────────────── */ type Row = [string, Cell | null, string]; /** 종류별 틀 — 1행 머리(이름 · 값 · 설명) · 2행부터 알약 줄 */ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; outs: SlPill[] } { const v = (값: number | string): Cell => ({ 값 }); const f = (식: string): Cell => ({ 식 }); switch (kind) { case "input": return { rows: [[name, v(0), tl("Sample")]], ins: [], outs: [{ name, cell: "B2" }] }; case "const": return { rows: [[name, v(0), tl("Fixed")]], ins: [], outs: [{ name, cell: "B2" }] }; case "table": return { rows: [ [tl("Cond"), null, tl("QtyNote")], [tl("Found"), null, tl("FromTable")], ], ins: [{ name: tl("Cond"), cell: "B2" }], outs: [{ name: tl("Found"), cell: "B3" }], }; case "calc": return { rows: [ [tl("ValueA"), null, tl("QtyNote")], [tl("ValueB"), null, tl("QtyNote")], [tl("Found"), f("B2*B3"), tl("Times")], ], ins: [ { name: tl("ValueA"), cell: "B2" }, { name: tl("ValueB"), cell: "B3" }, ], outs: [{ name: tl("Found"), cell: "B4" }], }; case "item": return { rows: [ [tl("Qty"), null, tl("QtyNote")], [tl("Price"), null, tl("PriceNote")], [tl("Amount"), f("B2*B3"), ""], ["노무비", f("B4"), ""], ["재료비", v(0), ""], ["경비", v(0), ""], ], ins: [ { name: tl("Qty"), cell: "B2" }, { name: tl("Price"), cell: "B3", outside: true }, ], outs: [ { name: tl("Amount"), cell: "B4" }, ...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })), ], }; case "logic": return { rows: [[tl("Found"), null, tl("LogicNote")]], ins: [], outs: [{ name: tl("Found"), cell: "B2", outside: true }], }; case "result": return { rows: [ ["노무비", null, tl("QtyNote")], ["재료비", null, tl("QtyNote")], ["경비", null, tl("QtyNote")], [tl("Sum"), f("SUM(B2:B4)"), ""], ], ins: COSTS.map((c, i) => ({ name: c, cell: `B${2 + i}` })), outs: [{ name: "계", cell: "B5" }], }; } } /* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */ /** 빈 시트 로직 */ export function newLogic(name = "", resultUnit = "원/㎡"): SheetLogic { return { key: null, name, resultUnit, workbook: { 종류: "통합문서", 판: 1, 열: "SL", 서식: [{}], 시트: [] }, nodes: [], edges: [], }; } /** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */ export function addNode( sl: SheetLogic, kind: SlKind, name: string, ): { sl: SheetLogic; node: SlNode } { const out = clone(sl); const book = out.workbook; const sheetId = nextId( "s", book.시트.map((s) => s.id), ); const title = uniqueName(book, name); const t = template(kind, title); const 칸: Record = { A1: { 값: tl("Name") }, B1: { 값: tl("Value") }, C1: { 값: tl("Note") }, }; t.rows.forEach(([label, cell, note], i) => { 칸[`A${i + 2}`] = { 값: label }; if (cell) 칸[`B${i + 2}`] = cell; if (note) 칸[`C${i + 2}`] = { 값: note }; }); book.시트.push({ id: sheetId, 이름: title, 칸 }); book.활성 ??= sheetId; const node: SlNode = { id: nextId( "n", out.nodes.map((n) => n.id), ), kind, name: title, sheetId, pillsIn: t.ins, pillsOut: t.outs, stage: STAGE[kind], }; out.nodes.push(node); return { sl: out, node }; } /** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */ export function removeNode(sl: SheetLogic, id: string): SheetLogic { const node = nodeOf(sl, id); if (!node) return sl; let out = sl; for (const e of sl.edges.filter((x) => x.from.node === id || x.to.node === id)) out = disconnect(out, e.id); out = clone(out); out.nodes = out.nodes.filter((n) => n.id !== id); out.workbook.시트 = out.workbook.시트.filter((s) => s.id !== node.sheetId); if (out.workbook.활성 === node.sheetId) out.workbook.활성 = out.workbook.시트[0]?.id; return out; } /* ── 선 ─────────────────────────────────────────────────────────────────── */ /** 받는 알약 칸 수식 = 그 알약으로 오는 선들의 참조를 「+」 로 · 선이 없으면 칸을 비움 */ function rewriteTarget(sl: SheetLogic, to: SlEnd): void { const node = nodeOf(sl, to.node); const pill = node && pillOf(node, "in", to.pill); if (!node || !pill) return; const refs = sl.edges .filter((e) => e.to.node === to.node && e.to.pill === to.pill) .map((e) => { const src = nodeOf(sl, e.from.node); const out = src && pillOf(src, "out", e.from.pill); return src && out ? `${quoteSheet(sheetOf(sl, src).이름)}!${out.cell}` : null; }) .filter((x): x is string => x !== null); const sheet = sheetOf(sl, node); const keepStyle = sheet.칸[pill.cell]?.서식; if (refs.length) sheet.칸[pill.cell] = { 식: refs.join("+"), ...(keepStyle ? { 서식: keepStyle } : {}) }; else if (keepStyle) sheet.칸[pill.cell] = { 서식: keepStyle }; else delete sheet.칸[pill.cell]; } /** from 상자에서 선을 따라 to 상자에 닿나 */ function reaches(sl: SheetLogic, from: string, to: string): boolean { const seen = new Set(); const walk = (id: string): boolean => { if (id === to) return true; if (seen.has(id)) return false; seen.add(id); return sl.edges.filter((e) => e.from.node === id).some((e) => walk(e.to.node)); }; return walk(from); } /** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */ export function connect( sl: SheetLogic, from: SlEnd, to: SlEnd, ): { sl: SheetLogic; why: string | null } { const a = nodeOf(sl, from.node); const b = nodeOf(sl, to.node); if (!a || !b || !pillOf(a, "out", from.pill) || !pillOf(b, "in", to.pill)) return { sl, why: tl("NoPill") }; if (a.id === b.id) return { sl, why: tl("Self") }; const same = (e: SlEdge): boolean => e.from.node === from.node && e.from.pill === from.pill && e.to.node === to.node && e.to.pill === to.pill; if (sl.edges.some(same)) return { sl, why: tl("Twice") }; if (reaches(sl, b.id, a.id)) return { sl, why: tl("Loop") }; const out = clone(sl); out.edges.push({ id: nextId( "e", out.edges.map((e) => e.id), ), from: { ...from }, to: { ...to }, }); rewriteTarget(out, to); return { sl: out, why: null }; } /** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */ export function disconnect(sl: SheetLogic, edgeId: string): SheetLogic { const edge = sl.edges.find((e) => e.id === edgeId); if (!edge) return sl; const out = clone(sl); out.edges = out.edges.filter((e) => e.id !== edgeId); rewriteTarget(out, edge.to); return out; } /* ── 알약 ───────────────────────────────────────────────────────────────── */ /** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈(이름이 바뀌면 이어진 선도 따라감) */ export function setPill( sl: SheetLogic, nodeId: string, cell: string, dir: "in" | "out", name: string, extra: Pick = {}, ): SheetLogic { const out = clone(sl); const node = nodeOf(out, nodeId); if (!node) return sl; const list = dir === "in" ? node.pillsIn : node.pillsOut; const at = list.findIndex((p) => p.cell === cell || p.name === name); const pill: SlPill = { name, cell: cell.toUpperCase(), ...extra }; if (at < 0) { list.push(pill); return out; } const old = list[at]; list[at] = pill; for (const e of out.edges) { const end = dir === "in" ? e.to : e.from; if (end.node === nodeId && end.pill === old.name) end.pill = name; } // 칸이 바뀌면 선이 가리키던 칸도 새로 씀 if (old.cell !== pill.cell) { const linked = out.edges.some((e) => e.to.node === nodeId && e.to.pill === name); if (dir === "in" && linked) { delete sheetOf(out, node).칸[old.cell]; rewriteTarget(out, { node: nodeId, pill: name }); } else if (dir === "out") for (const e of out.edges.filter((x) => x.from.node === nodeId && x.from.pill === name)) rewriteTarget(out, e.to); } return out; } /** 알약 빼기 — 이어진 선도 끊음 */ export function removePill( sl: SheetLogic, nodeId: string, dir: "in" | "out", name: string, ): SheetLogic { let out = sl; for (const e of sl.edges) { const end = dir === "in" ? e.to : e.from; if (end.node === nodeId && end.pill === name) out = disconnect(out, e.id); } out = clone(out); const node = nodeOf(out, nodeId); if (!node) return out; if (dir === "in") node.pillsIn = node.pillsIn.filter((p) => p.name !== name); else node.pillsOut = node.pillsOut.filter((p) => p.name !== name); return out; } /** 상자 · 시트 찾기(Eval · 화면이 씀) */ export function nodeSheet(sl: SheetLogic, nodeId: string): Sheet | null { const node = nodeOf(sl, nodeId); return node ? sheetOf(sl, node) : null; }