Files
Aislo/ui_template/sheet/ui_template_sheet_ops.ts
T
eomsangdonandClaude Opus 5.5 e76e638013 feat(M02): 조합 갈라 내기 길 · 자재 조합(UM) 표 · 본뜬키 칸 · 분류 더하기 열 복사
- POST /api/m02/structures/{열}/combo — 고친 담은 로직으로 새 UA(같은 로직 집합이면 있는 키) · 집계표 그 열만 새 키 · 판 어긋나면 조합 만들기 전 409 · SS 장부 맞춤
- 일위대가 조합 줄에 본뜬키 칸(원래 키) · 기존 37 줄 null · M01 고치기에서 안 보내도 유지
- 자재 조합 표 resources/master_data/자재조합.json(키 UM · 대장 등록 · 틀 검사) · POST /structures/{열}/material-combo · GET /material-combos/{키}
- 구조물집계표조합 칸 자재조합 · 분류 더하기로 복사한 열은 저장 때 원본 열의 일위대가 · 자재조합 키와 구조물 도면 · 면적산출 · 재료산출 내용을 새 번호로 받음
- Api_Fetch branchCombo · branchMaterialCombo · fetchMaterialCombo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
2026-09-28 18:56:51 +09:00

219 lines
9.1 KiB
TypeScript

/* =============================================================================
* ui_template_sheet_ops.ts
* 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림).
* 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름.
* ⚠ 타입 말고는 import 하지 않음 — 시험이 Node 로 이 파일을 바로 돌림(`test_sheet_pages.py`).
* ========================================================================== */
import type {
SheetCell,
SheetColumn,
SheetDoc,
SheetRow,
SheetSelection,
} from "./ui_template_sheet_types";
export type SheetMode = "master" | "project";
export const ROW_FIXED_ALL = "전구간";
const PAGE_ROWS = 50;
export const KEY_UNIT = "s:unit";
export const KEY_PRICE = "s:price";
export const KEY_DRAWING = "s:drawing";
export const KEY_AREA = "s:area";
export const KEY_MATERIAL = "s:material";
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 · 재료산출 줄은 손으로 못 침(번호 글). */
export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_MATERIAL] as const;
export type MasterRowKey = (typeof MASTER_ROWS)[number];
/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */
export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA, KEY_MATERIAL];
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
export const PICK_ROWS: readonly string[] = MASTER_ROWS;
/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 면적산출 · 재료산출 칸 — 빈칸 · 잠김. */
export const isBlankPick = (key: string, col: SheetColumn): boolean =>
PICK_ROWS.includes(key) && col.꼴 === "글";
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
return [
...special,
...orderedRows(doc).map((r) => `d:${r.id}`),
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
];
}
/** 고른 칸 → 페이지에 알릴 모양. */
export function selectionOf(doc: SheetDoc, row: string, colId: string): SheetSelection {
const col = doc.열.findIndex((c) => c.id === colId);
return { row, col, colId: col >= 0 ? colId : null };
}
/** 겹치지 않는 새 id — `c1` · `c2` … */
function freshId(prefix: string, used: Iterable<string>): string {
const taken = new Set(used);
let n = 1;
while (taken.has(`${prefix}${n}`)) n += 1;
return `${prefix}${n}`;
}
/** 보이는 줄 차례 — 「전구간」 줄이 맨 위 · 나머지는 문서 차례. */
export function orderedRows(doc: SheetDoc): SheetRow[] {
const fixed = doc.줄.filter((r) => r.고정 === ROW_FIXED_ALL);
return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)];
}
export interface SheetPage {
rows: SheetRow[];
/** 이 쪽 첫 줄의 차례(1부터 · 쪽을 가로질러 셈). */
first: number;
/** 합계 줄을 붙이나 — 마지막 쪽만. */
totals: boolean;
}
/** 쪽 나눔 — project 는 `쪽줄`(없으면 50)마다 · master 는 한 쪽. 머리는 쪽마다 그림(격자 몫). */
export function pagePlan(doc: SheetDoc, mode: SheetMode): SheetPage[] {
const rows = orderedRows(doc);
const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, rows.length);
const count = Math.max(1, Math.ceil(rows.length / size));
return Array.from({ length: count }, (_, p) => ({
rows: rows.slice(p * size, (p + 1) * size),
first: p * size + 1,
totals: p === count - 1,
}));
}
export const isCalcColumn = (col: SheetColumn): boolean => !!col.식;
export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩;
/** 본문 칸을 고칠 수 있나 — master 는 전부(계산 칸은 `=` 식으로) · project 는 손 열만(+ 사용자가 더한 줄의 글 열). */
export function cellEditable(mode: SheetMode, col: SheetColumn, row: SheetRow): boolean {
if (mode === "master") return true;
if (isBoundColumn(col) || isCalcColumn(col)) return false;
return !!col.손 || (!!row.손 && col.꼴 === "글");
}
export function canDeleteRow(mode: SheetMode, row: SheetRow): boolean {
if (mode === "master") return true;
return !!row.손 && row.고정 !== ROW_FIXED_ALL;
}
/** `after` 줄 뒤에 빈 줄(없으면 맨 끝) — 새 줄 id. project 에서 더한 줄은 `손`. */
export function addRow(doc: SheetDoc, mode: SheetMode, after: string | null): string {
const id = freshId(
"r",
doc.줄.map((r) => r.id),
);
const row: SheetRow = { id, 값: {}, ...(mode === "project" ? { 손: true } : {}) };
const at = doc.줄.findIndex((r) => r.id === after);
doc.줄.splice(at < 0 ? doc.줄.length : at + 1, 0, row);
return id;
}
export function deleteRow(doc: SheetDoc, id: string): void {
doc.줄 = doc.줄.filter((r) => r.id !== id);
}
/** `level` 층 새 칸 열 — `after` 열과 그 층 위 머리(`머리[0..level-1]`)가 같은 이웃 끝에 둠 ·
* `[…위 머리, label, "" …]`(아래 층은 빈칸) · 같은 무리에 같은 글이 있으면 뒤에 수를 붙임
* (같은 머리는 가로로 합쳐져 새 열이 안 보임) · 새 열 id(`after` 가 없으면 null). */
export function addLevel(
doc: SheetDoc,
after: string,
level: number,
depth: number,
label: string,
): string | null {
let at = doc.열.findIndex((c) => c.id === after);
if (at < 0) return null;
const base = doc.열[at];
const top = Array.from({ length: level }, (_, i) => base.머리[i] ?? null);
const mates = (col: SheetColumn): boolean => top.every((h, i) => (col.머리[i] ?? null) === h);
while (at + 1 < doc.열.length && mates(doc.열[at + 1])) at += 1;
const used = new Set(doc.열.filter(mates).map((c) => c.머리[level]));
let text = label;
for (let n = 2; used.has(text); n += 1) text = `${label} ${n}`;
const head: (string | null)[] = [...top, text];
while (head.length < depth) head.push("");
const id = freshId(
"c",
doc.열.map((c) => c.id),
);
// 원본(`after`) 열을 따라감 — 일위대가 · 자재조합 같은 키 · 구조물 파일은 저장 때 서버가 복사
doc.열.splice(at + 1, 0, {
id,
머리: head,
단위: base.단위 ?? null,
꼴: "수",
...(base.꼴 !== "글"
? { 본뜬열: base.id, 일위대가: base.일위대가 ?? null, 자재조합: base.자재조합 ?? null }
: {}),
});
return id;
}
/** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */
export function setLayerName(doc: SheetDoc, level: number, text: string): void {
const layers = (doc.층 ??= []);
while (layers.length <= level) layers.push("");
layers[level] = text.trim();
}
/** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */
export function deleteColumn(doc: SheetDoc, id: string): void {
doc.열 = doc.열.filter((c) => c.id !== id);
for (const row of doc.줄) {
delete row.값[id];
if (row.식) delete row.식[id];
}
if (doc.보기?.열너비) delete doc.보기.열너비[id];
}
/** 칸에 친 글 → 저장 값. `=` 로 시작하면 그 칸만의 식(`줄.식`) · 수 열은 수 꼴이면 수 · 빈 글은 칸을 비움. */
export function setCell(doc: SheetDoc, rowId: string, col: SheetColumn, text: string): void {
const row = doc.줄.find((r) => r.id === rowId);
if (!row) return;
const trimmed = text.trim();
if (row.식) {
delete row.식[col.id];
if (!Object.keys(row.식).length) delete row.식;
}
if (trimmed.startsWith("=") && trimmed.slice(1).trim() !== (col.식 ?? "")) {
delete row.값[col.id];
(row.식 ??= {})[col.id] = trimmed.slice(1).trim();
return;
}
if (trimmed === "" || trimmed.startsWith("=")) {
delete row.값[col.id];
return;
}
const plain = trimmed.replace(/,/g, "");
const value: SheetCell =
col.꼴 !== "글" && /^-?(\d+(\.\d*)?|\.\d+)$/.test(plain) ? Number(plain) : trimmed;
// ⚠ 긴 소수는 Number 로 바꾸면 끝자리가 틀어짐 — 보이는 글 그대로 둠(풀이가 십진으로 읽음)
row.값[col.id] = typeof value === "number" && String(value) !== plain ? plain : value;
}
/** 머리 칸 고치기 — 글에 `/` 가 있으면 `level` 층부터 아래로 나눠 넣음(층보다 많으면 끝 층에 이어 붙임) ·
* 층보다 적게 쓰면 남은 층은 `null`(위 칸과 합침). `/` 가 없으면 그 층 글만 바꿈. */
export function setHeadLabel(head: (string | null)[], level: number, text: string, depth: number) {
while (head.length <= level) head.push(null);
const parts = text.split("/").map((part) => part.trim());
if (parts.length < 2) {
head[level] = text.trim();
return;
}
const room = Math.max(1, depth - level);
const fit =
parts.length > room ? [...parts.slice(0, room - 1), parts.slice(room - 1).join("/")] : parts;
while (head.length < depth) head.push(null);
for (let k = level; k < depth; k += 1) head[k] = fit[k - level] ?? null;
}
export function setColumnWidth(doc: SheetDoc, id: string, width: number): void {
doc.보기 ??= {};
(doc.보기.열너비 ??= {})[id] = Math.round(width);
}