feat(m01-sl): 시트 로직 표 상자 손잡이 Table 초안 — 품셈 표 가져오기 · 내 표 · 찾기 수식 짜기

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
2026-10-01 13:08:02 +09:00
co-authored by Claude Sonnet 5.5
parent 62fa53e927
commit 7bd6e8d7d1
4 changed files with 1371 additions and 0 deletions
@@ -0,0 +1,446 @@
/* =============================================================================
* M01_SL_Table_Build.ts
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts`
*
* 시트 모양(표 상자 시트 하나):
* A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움
* 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로)
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
* 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A
* ========================================================================== */
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
type Rec = Record<string, string | number | [number | null, number | null] | null | undefined>;
/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */
export interface TblInfo {
key: string;
name: string;
/** 기준 「1㎡」 */
base: string;
source: string;
/** 원문번호 「13-4-1」 */
no: string;
/** 조건 칸 → 「수」 「고르기」 「범위」 */
conds: Record<string, string>;
/** 값 칸 → 단위 */
vals: Record<string, string>;
rule: string | null;
rows: Rec[];
notes: string[];
}
/** 칸 역할 — 조건 · 결과(나머지는 쓰지 않음) */
export interface TblRoles {
cond: string[];
out: string[];
}
/** 블록 열 하나 — 범위 칸은 아래 · 위 두 열 */
export interface TblCol {
header: string;
of: string;
part: "값" | "아래" | "위";
}
export interface TblPlan {
cols: TblCol[];
/** 블록 안 줄 수(머리 줄 빼고) */
rowCount: number;
/** 블록 밖에 쓸 칸 — A1 → 칸 */
cells: Record<string, Cell>;
/** 조건 칸 이름 → 입력 칸 */
inCells: Record<string, string>;
/** 결과 칸 이름 → 출력 칸 */
outCells: Record<string, string>;
idxCell: string;
statusCell: string;
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */
helperFrom: number;
warnings: string[];
}
/** 열 글자 0 → A */
export const colName = (c: number): string => {
let s = "";
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
};
const colIndex = (letters: string): number =>
[...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1;
const at = (r: number, c: number): string => `${colName(c)}${r}`;
const MARGIN = 20;
const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`;
/** 블록 열 차례 — 조건 칸 · 값 칸 차례 그대로 · 범위 칸은 둘로 */
export function layoutOf(info: TblInfo): TblCol[] {
const out: TblCol[] = [];
for (const c of Object.keys(info.conds))
if (info.conds[c] === "범위")
out.push(
{ header: `${c}·아래`, of: c, part: "아래" },
{ header: `${c}·위`, of: c, part: "위" },
);
else out.push({ header: c, of: c, part: "값" });
for (const c of Object.keys(info.vals)) out.push({ header: c, of: c, part: "값" });
return out;
}
const cellOf = (v: Rec[string]): Cell | null =>
v === null || v === undefined || v === ""
? null
: typeof v === "number"
? { 값: v }
: { 값: String(v) };
/** 블록 칸 — 머리 줄 + 줄(내 표 · 시험이 씀 · 품셈 표는 바깥 값이 채움) */
export function blockCells(info: TblInfo): Record<string, Cell> {
const cells: Record<string, Cell> = {};
const cols = layoutOf(info);
cols.forEach((col, c) => (cells[at(1, c)] = { 값: col.header }));
info.rows.forEach((row, i) =>
cols.forEach((col, c) => {
const v = row[col.of];
const x = Array.isArray(v)
? col.part === "아래"
? v[0]
: v[1]
: col.part === "값"
? v
: null;
const cell = cellOf(x);
if (cell) cells[at(i + 2, c)] = cell;
}),
);
return cells;
}
/** 조건 칸 하나의 입력 칸 처음 값 — 첫 줄 */
const sampleOf = (info: TblInfo, c: string): string | number => {
const v = info.rows[0]?.[c];
if (Array.isArray(v))
return v[0] !== null && v[1] !== null
? Number(((v[0] + v[1]) / 2).toFixed(6))
: (v[1] ?? v[0] ?? 0);
return v === null || v === undefined ? "" : v;
};
/** 표 상자 시트에 쓸 찾기 칸 · 알약 계획 */
export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
const cols = layoutOf(info);
const warnings: string[] = [];
const W = cols.length;
const n = info.rows.length;
const letter = (header: string): string => colName(cols.findIndex((c) => c.header === header));
const conds = roles.cond.filter((c) => c in info.conds);
const rangeCols = conds.filter((c) => info.conds[c] === "범위");
const exact = conds.filter((c) => info.conds[c] !== "범위");
const range = rangeCols[0];
if (rangeCols.length > 1) warnings.push("범위 조건은 한 칸까지만 찾음 — 나머지는 뺌");
const blankable = exact.filter((c) =>
info.rows.some((r) => r[c] === null || r[c] === undefined || r[c] === ""),
);
if (blankable.length > 2) warnings.push("빈칸(무관) 조건 칸은 둘까지만 지원");
if (range && blankable.length)
warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음");
const rule = info.rule ?? "초과·이하";
const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule));
const R = (range ? n : n + MARGIN) + 1;
const rng = (L: string): string => `${L}$2:${L}$${R}`;
// 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들
const variants: string[][] = [[]];
if (!range) {
const bl = blankable.slice(0, 2);
const subsets =
bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : [];
variants.push(...subsets);
}
const keyCols = exact.length ? variants : [];
const kStart = W + 1;
const kLetters = keyCols.map((_, i) => colName(kStart + i));
const L = kStart + keyCols.length + 1; // 찾기 판 왼쪽 열
const V = L + 1;
const cells: Record<string, Cell> = {};
keyCols.forEach((skip, v) => {
cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" };
const rest = exact.filter((c) => !skip.includes(c));
for (let r = 2; r <= R; r++) {
const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&');
if (!skip.length) cells[at(r, kStart + v)] = { 식: key };
else {
const guard = [
...skip.map((c) => `${letter(c)}${r}=""`),
...rest.map((c) => `${letter(c)}${r}<>""`),
].join(",");
cells[at(r, kStart + v)] = { 식: `IF(AND(${guard}),${key},"")` };
}
}
});
// 찾기 판
let row = 1;
cells[at(row++, L)] = { 값: "표에서 찾기" };
const inCells: Record<string, string> = {};
const inputOf: Record<string, string> = {};
for (const c of conds) {
cells[at(row, L)] = { 값: c };
const sample = sampleOf(info, c);
if (sample !== "") cells[at(row, V)] = { 값: sample };
inCells[c] = at(row, V);
inputOf[c] = at(row, V);
row++;
}
const keyOf = (skip: string[]): string =>
exact
.filter((c) => !skip.includes(c))
.map((c) => inputOf[c])
.join('&"|"&');
const idxRow = (label: string, f: string): string => {
cells[at(row, L)] = { 값: label };
cells[at(row, V)] = { 식: f };
return at(row++, V);
};
const up = range ? rng(letter(`${range}·위`)) : "";
const lo = range ? rng(letter(`${range}·아래`)) : "";
const x = range ? inputOf[range] : "";
let idx = "";
let count = "";
if (exact.length) {
const keyCell = idxRow("열쇠", keyOf([]));
const ks = keyCols.map((skip, v) => {
if (v === 0) return keyCell;
cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` };
cells[at(row, V)] = { 식: keyOf(skip) };
return at(row++, V);
});
if (range) {
idx = idxRow(
"걸린 줄",
`MATCH(${ks[0]},${rng(kLetters[0])},0)+COUNTIFS(${rng(kLetters[0])},${ks[0]},${up},"<"&${x})`,
);
count = idxRow(
"일치 수",
`IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`,
);
} else {
const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`);
count = idxRow(
"일치 수",
found.reduceRight((acc, f) => `IF(${f}>0,${f},${acc})`, "0"),
);
const chain = ks
.map((k, v) => `MATCH(${k},${rng(kLetters[v])},0)`)
.reduceRight((acc, m) => `IFERROR(${m},${acc})`, "NA()");
idx = idxRow("걸린 줄", chain);
}
} else if (range) {
idx = idxRow("걸린 줄", lowBased ? `COUNTIF(${lo},"<="&${x})` : `COUNTIF(${up},"<"&${x})+1`);
const inside = lowBased
? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<INDEX(${up},${idx})))`
: `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`;
count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`);
} else {
warnings.push("조건 칸이 없음 — 찾을 줄이 없음");
count = idxRow("일치 수", "0");
idx = idxRow("걸린 줄", "NA()");
}
if (lowBased && range && exact.length)
warnings.push("조건이 섞이면 범위는 「초과·이하」 로만 찾음");
const statusCell = idxRow(
"상태",
`IF(${count}=0,${quote("맞는 줄 없음")},IF(${count}>1,${quote("맞는 줄 둘 이상")},${quote("찾음")}))`,
);
const outCells: Record<string, string> = {};
for (const c of roles.out.filter((o) => o in info.vals)) {
outCells[c] = idxRow(
c,
`IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(c))},${idx}),NA())`,
);
}
return {
cols,
rowCount: n,
cells,
inCells,
outCells,
idxCell: idx,
statusCell,
helperFrom: W,
warnings,
};
}
/** 알약 — 조건 칸마다 입력 알약 · 결과 칸마다 출력 알약 · 바깥 값 표 알약(품셈 표만) */
export function pillsOf(
info: TblInfo,
plan: TblPlan,
outside: boolean,
): { pillsIn: SlPill[]; pillsOut: SlPill[] } {
const pillsIn = Object.entries(plan.inCells).map(([name, cell]): SlPill => ({ name, cell }));
const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({
name,
cell,
unit: info.vals[name] || undefined,
}));
if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true });
return { pillsIn, pillsOut };
}
const sheetIndexOf = (sl: SheetLogic, node: SlNode): number =>
sl.workbook.시트.findIndex((s) => s.id === node.sheetId);
/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */
export function applyTable(
sl: SheetLogic,
nodeId: string,
info: TblInfo,
roles: TblRoles,
opts: { ref: string; fillBlock: boolean; outside: boolean },
): { sl: SheetLogic; plan: TblPlan } {
const next = structuredClone(sl);
const node = next.nodes.find((n) => n.id === nodeId);
if (!node) throw new Error("없는 상자");
const si = sheetIndexOf(next, node);
if (si < 0) throw new Error("상자의 시트가 없음");
const plan = planLookup(info, roles);
const sheet: Sheet = next.workbook.시트[si];
const keep = Object.entries(sheet.칸).filter(
([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom,
);
sheet.칸 = Object.fromEntries(keep);
if (opts.fillBlock) {
for (const a of Object.keys(sheet.칸)) delete sheet.칸[a];
Object.assign(sheet.칸, blockCells(info));
}
Object.assign(sheet.칸, plan.cells);
const pills = pillsOf(info, plan, opts.outside);
const gone = (dir: "in" | "out", name: string): boolean =>
!(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name);
next.edges = next.edges.filter(
(e) =>
!(e.from.node === nodeId && gone("out", e.from.pill)) &&
!(e.to.node === nodeId && gone("in", e.to.pill)),
);
node.name = `표 · ${info.name}`;
node.ref = opts.ref;
node.pillsIn = pills.pillsIn;
node.pillsOut = pills.pillsOut;
return { sl: next, plan };
}
/** 엑셀에서 붙여넣은 글(탭 · 줄바꿈) → 머리 · 줄 — 「3~4」 꼴 칸 열은 아래 · 위 범위 칸으로 */
export function parsePaste(text: string): { info: TblInfo; error: string | null } {
const lines = text
.replace(/\r/g, "")
.split("\n")
.filter((l) => l.trim() !== "");
const empty: TblInfo = {
key: "",
name: "",
base: "",
source: "",
no: "",
conds: {},
vals: {},
rule: null,
rows: [],
notes: [],
};
if (lines.length < 2) return { info: empty, error: "머리 한 줄과 줄 하나는 있어야 함" };
const grid = lines.map((l) => l.split("\t").map((c) => c.trim()));
const head = grid[0];
if (head.some((h) => h === "") || new Set(head).size !== head.length)
return { info: empty, error: "머리 칸 이름이 비었거나 겹침" };
const num = (s: string): number | null =>
s !== "" && Number.isFinite(Number(s.replace(/,/g, ""))) ? Number(s.replace(/,/g, "")) : null;
const rangeRe = /^(-?\d+(?:\.\d+)?)?\s*[~∼]\s*(-?\d+(?:\.\d+)?)?$/;
const body = grid.slice(1);
const isRange = (c: number): boolean =>
body.some((r) => (r[c] ?? "").includes("~") || (r[c] ?? "").includes("∼")) &&
body.every((r) => !r[c] || rangeRe.test(r[c]));
const rows: Rec[] = body.map((r) => {
const rec: Rec = {};
head.forEach((h, c) => {
const s = r[c] ?? "";
if (isRange(c)) {
const m = rangeRe.exec(s);
rec[h] = m
? [m[1] === undefined ? null : Number(m[1]), m[2] === undefined ? null : Number(m[2])]
: null;
} else rec[h] = s === "" ? null : (num(s) ?? s);
});
return rec;
});
// 첫 칸부터 글 · 수 가운데 숫자가 아닌 칸 · 범위 칸이 조건 · 나머지(수만 든 칸)는 값
const conds: Record<string, string> = {};
const vals: Record<string, string> = {};
head.forEach((h, c) => {
const numeric = body.every((r) => !r[c] || num(r[c]) !== null);
if (isRange(c)) conds[h] = "범위";
else if (!numeric) conds[h] = "고르기";
else if (c === 0 && head.length > 1) conds[h] = "수";
else vals[h] = "";
});
if (!Object.keys(vals).length) return { info: empty, error: "결과로 쓸 숫자 칸이 없음" };
return { info: { ...empty, conds, vals, rows }, error: null };
}
/** 내 표 — 시트 블록(A1 머리 줄 · 아래로 줄)에서 표 한 벌을 읽음 · 「이름·아래」 「이름·위」 짝은 범위 칸 */
export function infoFromSheet(sheet: Sheet, prev: Pick<TblInfo, "conds" | "name">): TblInfo {
const heads: string[] = [];
for (let c = 0; ; c++) {
const h = sheet.칸[at(1, c)]?.값;
if (h === undefined || h === "") break;
heads.push(String(h));
}
let last = 1;
for (const a of Object.keys(sheet.칸)) {
const m = /^([A-Z]+)(\d+)$/.exec(a);
if (m && colIndex(m[1]) < heads.length) last = Math.max(last, Number(m[2]));
}
const conds: Record<string, string> = {};
const vals: Record<string, string> = {};
const names: { of: string; part: "값" | "아래" | "위"; c: number }[] = [];
heads.forEach((h, c) => {
const m = /^(.+)·(아래|위)$/.exec(h);
names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c });
});
for (const nm of names) {
if (nm.part !== "값") conds[nm.of] = "범위";
else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of];
else vals[nm.of] = "";
}
const rows: Rec[] = [];
for (let r = 2; r <= last; r++) {
const rec: Rec = {};
for (const nm of names) {
const v = sheet.칸[at(r, nm.c)]?.값;
const x = v === undefined ? null : typeof v === "boolean" ? String(v) : v;
if (nm.part === "값") rec[nm.of] = x;
else {
const prevPair = (rec[nm.of] as [number | null, number | null] | undefined) ?? [null, null];
prevPair[nm.part === "아래" ? 0 : 1] = typeof x === "number" ? x : null;
rec[nm.of] = prevPair;
}
}
rows.push(rec);
}
return {
key: "",
name: prev.name,
base: "",
source: "",
no: "",
conds,
vals,
rule: null,
rows,
notes: [],
};
}