Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Model.ts
T
eomsangdonandClaude Opus 5.5 74d2ba25f8 chore(m01-sl): Model 머리 주석에 표 블록 · 항목 단가 약속 두 줄
① 표 블록 칸 차례 = 서버 _table 하나(범위는 조건 · 값 모두 이름·아래/위 · 숫자 하나면 같은 수 · 왼위 = 표 알약 칸)
② 항목 단가 바깥 값 주인 = Item itemOutsides · 같은 칸이면 그쪽이 이김

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

539 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M01_SL_Model.ts
* 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸
* 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식(여러 선이면 「+」 로 이음) · DOM 없음
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로
*
* 모든 함수는 새 시트 로직을 돌려줌(받은 것은 안 바꿈) · 엔진(A00_Common/spreadsheet)은 가져다 쓰기만
*
* 약속 ① 표 블록 = 서버(SL_Router `_table`) 칸 차례 하나 — 머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 조건이든 값이든
* 「이름·아래」 「이름·위」 두 칸(숫자 하나면 둘 다 그 수) · 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸
* 약속 ② 항목 상자 단가 = Item 의 칸별 목록(`itemOutsides`)이 바깥 값 주인 · 서버는 저장 때 그 목록도 받고 같은 칸이면 그쪽이 이김
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { applyCommand } from "../../A00_Common/spreadsheet/spreadsheet_commands";
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;
/** 바깥 값을 어디서 받나(서버 `/api/m01/sl/outside` 물음 모양) — 없으면 kind · ref 로 짐작 */
source?: SlSource;
/** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */
stage: number;
}
/** 선 — 출력 알약 → 입력 알약 */
export interface SlEdge {
id: string;
from: { node: string; pill: string };
to: { node: string; pill: string };
}
/** 돈 로직 결과에서 꺼낼 칸 */
export type SlField = "노무비" | "재료비" | "경비" | "계";
/** 바깥 값 출처 — 서버 `/api/m01/sl/outside` 의 물음 한 줄(id 뺌) 그대로
* · 표 = 품셈 표 통째(머리 줄 + 줄 · 열 차례 조건 칸 → 값 칸 · 범위 칸은 「이름·아래」 「이름·위」 두 칸 — `rangeHead`)
* 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(pillsOut · outside) — 고정 A1 아님 · Eval 이 그 칸부터 채움
* · 노임 · 자재 = 그 줄 단가 · 장비 = 기계경비 로직 부름 글 + 넘길 값 · 로직 = 기존 로직 결과 */
export type SlSource =
| { kind: "표"; table: string }
| { kind: "노임" | "자재"; key: string }
| { kind: "장비"; element: string; inputs: Record<string, unknown>; field?: SlField }
| { kind: "로직"; key: string; inputs: Record<string, unknown>; field?: SlField };
/** 바깥 값 칸 — id = 「상자 id!칸」(서버 답 id 와 짝) */
export interface SlOutside {
id: string;
node: string;
cell: string;
kind: "table" | "price" | "logic";
ref: string;
source?: SlSource;
}
/** 표 범위 조건 · 범위 값 — 한 칸을 두 칸(아래 · 위)으로 펼친 머리 이름 */
export const RANGE_LOW = "·아래";
export const RANGE_HIGH = "·위";
export const rangeHead = (name: string): [string, string] => [name + RANGE_LOW, name + RANGE_HIGH];
/** 내 표 — 품셈에 없는 표(사용자가 만듦) */
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"],
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"],
PriceLabor: ["노무 단가", "Labor price"],
PriceMat: ["재료 단가", "Material price"],
PriceExp: ["경비 단가", "Expense price"],
Labor3: ["노무비", "Labor cost"],
Mat3: ["재료비", "Material cost"],
Exp3: ["경비", "Expense"],
Won: ["원", "KRW"],
NoPill: ["알약이 없음", "No such pill"],
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
Twice: ["이미 이어져 있음", "Already linked"],
NameLen: ["이름은 1~31 글자", "Name must be 1-31 characters"],
NameChar: [
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
"Name can't use [ ] : * ? / \\ or a leading/trailing '",
],
NameSame: ["같은 이름 상자가 있음", "A box with this name exists"],
NoBox: ["상자가 없음", "No such box"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */
const COSTS = ["노무비", "재료비", "경비"] as const;
/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */
const USED = "사용 수량";
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
const STAGE: Record<SlKind, number> = {
input: 0,
const: 0,
table: 1,
logic: 1,
calc: 2,
item: 3,
result: 4,
};
const clone = <T>(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}`;
}
/** 시트 이름 규칙(엔진 `checkSheetName`) — 1~31 글자 · [ ] : * ? / \\ 와 앞뒤 ' 못 씀 */
const BAD_CHARS = /[[\]:*?/\\]/g;
const cleanName = (name: string): string =>
name
.replace(BAD_CHARS, "·")
.replace(/^'+|'+$/g, "")
.trim()
.slice(0, 31) || "상자";
/** 시트 이름 — 통합문서 안에서 하나(같으면 「 (2)」 · 대소문자 안 가림) */
function uniqueName(book: Workbook, name: string, self?: string): string {
const base = cleanName(name);
const used = new Set(book.시트.filter((s) => s.id !== self).map((s) => s.이름.toLowerCase()));
if (!used.has(base.toLowerCase())) return base;
for (let n = 2; ; n++) {
const tail = ` (${n})`;
const next = base.slice(0, 31 - tail.length) + tail;
if (!used.has(next.toLowerCase())) return next;
}
}
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":
// 빈 시트 — 표를 고르면 Table(sub3)이 A1 블록 · 찾기 판 · 알약을 채움(머리 줄이 표 블록과 겹치지 않게)
return { rows: [], ins: [], outs: [] };
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":
// 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`)
return { rows: [], ins: [], outs: [] };
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" }],
};
}
}
/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움)
* B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */
function itemSheet(): { cells: Record<string, Cell>; rows: Row[]; ins: SlPill[]; outs: SlPill[] } {
const won = tl("Won");
const labels: [string, string][] = [
["A1", tl("Qty")],
["A2", tl("PriceLabor")],
["A3", tl("PriceMat")],
["A4", tl("PriceExp")],
["A5", tl("Price")],
["A6", tl("Amount")],
["A7", tl("Labor3")],
["A8", tl("Mat3")],
["A9", tl("Exp3")],
["A10", USED],
];
const cells: Record<string, Cell> = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }]));
Object.assign(cells, {
B3: { 값: 0 },
B4: { 값: 0 },
B5: { 식: "B2+B3+B4" },
B6: { 식: "ROUND(B1*B5,1)" },
B7: { 식: "ROUND(B1*B2,1)" },
B8: { 식: "ROUND(B1*B3,1)" },
B9: { 식: "ROUND(B1*B4,1)" },
B10: { 식: "B1" },
});
const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit });
return {
cells,
rows: [],
ins: [
{ name: tl("Qty"), cell: "B1", unit: "" },
{ name: tl("PriceLabor"), cell: "B2", unit: won, outside: true },
],
outs: [
out(tl("Price"), "B5"),
out(tl("Amount"), "B6"),
out(tl("Labor3"), "B7"),
out(tl("Mat3"), "B8"),
out(tl("Exp3"), "B9"),
out(USED, "B10", ""),
],
};
}
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
/** 빈 시트 로직 */
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 item = kind === "item" ? itemSheet() : null;
const t = item ?? template(kind, title);
const 칸: Record<string, Cell> =
item?.cells ??
(kind === "table"
? {}
: { 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,
...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}),
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<string>();
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<SlPill, "unit" | "outside"> = {},
): 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;
}
/** 상자 이름 바꾸기 — 시트 이름도(엔진 시트 이름 명령) · 그 시트를 가리키던 선 수식 · 다른 수식도 따라감 */
export function renameNode(
sl: SheetLogic,
nodeId: string,
name: string,
): { sl: SheetLogic; why: string | null } {
const node = nodeOf(sl, nodeId);
if (!node) return { sl, why: tl("NoBox") };
const next = name.trim();
if (!next || next.length > 31) return { sl, why: tl("NameLen") };
if (/[[\]:*?/\\]/.test(next) || next.startsWith("'") || next.endsWith("'"))
return { sl, why: tl("NameChar") };
if (uniqueName(sl.workbook, next, node.sheetId) !== next) return { sl, why: tl("NameSame") };
const out = clone(sl);
applyCommand(out.workbook, { 종류: "시트이름", 시트: node.sheetId, 이름: next });
nodeOf(out, nodeId)!.name = next;
return { sl: out, why: null };
}