Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts
T

447 lines
17 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_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: [],
};
}