Add Linear Excel transport

This commit is contained in:
Julian Appel 2026-09-30 07:33:06 +02:00
parent d4803a18d2
commit 52d3b1a270
10 changed files with 2131 additions and 83 deletions

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ data/*.db
data/backups/*.db data/backups/*.db
.codex/*.log .codex/*.log
/docs/spec/kbp-Stromkreis.xlsx /docs/spec/kbp-Stromkreis.xlsx
/data/revit-reference/*.xlsx

View file

@ -10,7 +10,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
| --- | --- | --- | | --- | --- | --- |
| R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. | | R0 | abgeschlossen und freigegeben | Bestandsaudit, Zielmodell, Migration und Risiken; Freigabekorrekturen in R1 eingearbeitet. |
| R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. | | R1 | abgeschlossen | Anforderungen, Plan und Architektur auf Excel/Worksharing-ID umgestellt; alle Pflichtprüfungen bestanden. |
| R2 | offen | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei. Danach zwingende Linear-Abnahme durch den Nutzer. | | R2 | implementiert, Linear-Abnahme ausstehend | XLSX-Adapter, Parser, synthetische Fixtures, lokaler Referenzprüfer und reale Rückgabedatei sind erstellt. R3 bleibt bis zum erfolgreichen Linear-Test gesperrt. |
| R3 | gesperrt bis R2-Abnahme | Konfiguration, Identität, Migration, Snapshot 10, Transfer und Backend-CSV-Entfernung. | | R3 | gesperrt bis R2-Abnahme | Konfiguration, Identität, Migration, Snapshot 10, Transfer und Backend-CSV-Entfernung. |
| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. | | R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. |
| R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. | | R5 | offen | Erstimportwizard mit Gerätearten, Familienregeln und optionalen ProjectDevices. |
@ -69,6 +69,24 @@ Danach stoppt die Arbeit. Der Nutzer testet diese Datei in Linear. Erst sein
erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter erfolgreicher Rückimport gibt R3 frei. Scheitert er, wird vor R3 ein konkreter
OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert. OOXML-Patchadapter vorgeschlagen, der das Originalworkbook direkt ändert.
Implementierungsstand vom 30.09.2026: Der Adapter liegt unter
`src/external-model/xlsx`, der Referenzprüfer unter
`scripts/verify-revit-reference-xlsx.ts`. Er hat aus der lokalen Musterdatei
die ignorierte Datei
`data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx` erzeugt. Die Datei
enthält 487 Objektzeilen in Originalreihenfolge, keine Summenzeile und gezielte
Änderungen an den ersten drei Objekten. Synthetische Tests decken Zelltypen,
Zahlenformate, Formelergebnisse, Kommentare, Breiten, Auswahl, exakte
ID-Vergleiche und Grenzen ab.
Für den Adapter ist `exceljs@4.4.0` als direkte Abhängigkeit aufgenommen. Der
Produktionsaudit meldet dafür ausschließlich `uuid@8.3.2`: Die gemeldete
Schwachstelle betrifft die UUID-Funktionen v3/v5 mit übergebenem Puffer;
ExcelJS verwendet die Abhängigkeit nur für UUID v4 bei erweiterten bedingten
Formatierungen. Dieser Adapter ruft keine UUID-Funktion auf. Weitere gemeldete
Auditbefunde gehören zu bereits vorhandenen Abhängigkeiten und werden nicht im
fachlich begrenzten R2-Paket aktualisiert.
## R3 – Datenmodell und Backend-Cutover ## R3 – Datenmodell und Backend-Cutover
- Konfiguration ohne eigene Versionszählung; - Konfiguration ohne eigene Versionszählung;

811
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts" "revit:verify-reference": "tsx scripts/verify-revit-reference-xlsx.ts"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -36,6 +36,7 @@
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0",
"express": "^5.2.1", "express": "^5.2.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"next": "^16.2.4", "next": "^16.2.4",

View file

@ -1,81 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createHash } from "node:crypto";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import {
parseExternalCsv,
serializeExternalCsv,
} from "../src/external-model/csv/external-csv-transport.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
const filePath = resolve(
process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv"
);
const source = readFileSync(filePath);
const configuration = createDefaultExternalCsvConfiguration({
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
circuitIdentifier: "kbp_Stromkreisnummer",
power: "kbp-E-Elektrische Leistung",
quantity: null,
});
configuration.additionalSourceMappings = [
{ sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" },
{ sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" },
{ sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" },
{ sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" },
{ sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" },
];
const document = parseExternalCsv(source, configuration);
const counts = Object.fromEntries(
["metadata", "header", "passthrough", "object", "suspect-object"].map(
(classification) => [
classification,
document.rows.filter((row) => row.classification === classification).length,
]
)
);
const nonEmptyPassthroughRows = document.rows.filter(
(row) =>
row.classification === "passthrough" &&
row.cells.some((cell) => cell.value !== "")
).length;
const serialized = serializeExternalCsv(document);
const preview = createExternalCsvPreview({
fileName: filePath,
bytes: source,
configuration,
});
assert.equal(counts.object, 897, "Expected 897 object rows.");
assert.equal(preview.objectCount, 897, "Expected 897 preview objects.");
assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows.");
assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows.");
assert.equal(
Buffer.compare(Buffer.from(serialized), source),
0,
"Expected a byte-identical CSV round-trip."
);
const sha256 = createHash("sha256").update(source).digest("hex");
process.stdout.write(
`${JSON.stringify(
{
file: filePath,
bytes: source.length,
sha256,
dialect: document.dialect,
rows: counts,
nonEmptyPassthroughRows,
roundTripByteIdentical: true,
previewObjectCount: preview.objectCount,
},
null,
2
)}\n`
);

View file

@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import ExcelJS from "exceljs";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxScalar,
} from "../src/external-model/xlsx/external-xlsx-contracts.js";
import {
createExternalXlsxReturn,
parseExternalXlsx,
} from "../src/external-model/xlsx/external-xlsx-transport.js";
async function main() {
const inputPath = resolve(
process.argv[2] ?? "docs/spec/kbp-Stromkreis.xlsx"
);
const outputPath = resolve(
process.argv[3] ?? "data/revit-reference/kbp-Stromkreis-R2-Rueckgabe.xlsx"
);
assert.notEqual(
inputPath,
outputPath,
"Eingabe- und Ausgabedatei müssen verschieden sein."
);
const source = readFileSync(inputPath);
const configuration = createDefaultExternalXlsxTransportConfiguration();
const document = await parseExternalXlsx({
fileName: inputPath,
bytes: source,
configuration,
});
const objectRows = document.rows.filter((row) => row.classification === "object");
const suspectRows = document.rows.filter(
(row) => row.classification === "suspect-object"
);
const worksharingIdColumn = document.columns.find(
(column) => column.header === configuration.columns.worksharingId
)?.columnNumber;
assert.ok(worksharingIdColumn, "Worksharing-ID-Spalte wurde nicht aufgelöst.");
assert.equal(objectRows.length, 487, "Es werden 487 Objektzeilen erwartet.");
assert.equal(suspectRows.length, 0, "Es werden keine verdächtigen Zeilen erwartet.");
assert.equal(
document.columns.filter((column) => column.comment !== null).length,
14,
"Es werden 14 Kopfzeilenkommentare erwartet."
);
assert.equal(
document.issues.filter((issue) => issue.code === "duplicate-ifc-guid").length,
0,
"Die Musterdatei darf keine exakten IfcGUID-Duplikate enthalten."
);
const testValues = [
{
circuitCombined: "UV_R2_TEST-2F1",
electricalPowerW: 111,
voltageV: 230,
currentA: 0.48,
},
{
circuitCombined: "UV_R2_TEST-2F2",
electricalPowerW: 222,
voltageV: 230,
currentA: 0.97,
},
{
circuitCombined: "UV_R2_TEST-3F1",
electricalPowerW: 333,
voltageV: 400,
currentA: 0.48,
},
] satisfies Record<string, ExternalXlsxScalar>[];
const updates = testValues.map((values, index) => {
const value = objectRows[index]?.cells[worksharingIdColumn - 1]?.value;
if (typeof value !== "string") {
throw new Error("Worksharing-ID muss Text sein.");
}
return { worksharingId: value, values };
});
const result = await createExternalXlsxReturn({
fileName: inputPath,
bytes: source,
configuration,
updates,
});
assert.equal(result.exportedObjectCount, 487);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, result.bytes);
const outputDocument = await parseExternalXlsx({
fileName: outputPath,
bytes: result.bytes,
configuration,
});
assert.equal(
outputDocument.rows.filter((row) => row.classification === "object").length,
487
);
assert.equal(
outputDocument.rows.some((row) => row.classification !== "object"),
false,
"Die Rückgabedatei darf keine Summen- oder Hinweiszeilen enthalten."
);
assert.equal(outputDocument.sheetName, document.sheetName);
assert.deepEqual(
outputDocument.columns,
document.columns,
"Kopfzeilen, Kommentare und Spaltenbreiten müssen erhalten bleiben."
);
assert.deepEqual(
outputDocument.rows.slice(updates.length).map((row) => row.cells),
objectRows.slice(updates.length).map((row) => row.cells),
"Nicht geänderte Objektzellen müssen Wert, Typ und Zahlenformat behalten."
);
const outputWorkbook = new ExcelJS.Workbook();
await outputWorkbook.xlsx.load(result.bytes as never);
const commentedCells: string[] = [];
outputWorkbook.worksheets[0].eachRow({ includeEmpty: true }, (row) => {
row.eachCell({ includeEmpty: true }, (cell) => {
if (cell.note !== undefined) commentedCells.push(cell.address);
});
});
assert.equal(
commentedCells.length,
14,
"Nur die 14 Kopfzellen dürfen Kommentare enthalten."
);
assert.equal(
commentedCells.every((address) => address.endsWith("1")),
true,
"Datenzellen dürfen keine Kommentare enthalten."
);
process.stdout.write(
`${JSON.stringify(
{
input: inputPath,
output: outputPath,
inputBytes: source.length,
outputBytes: result.bytes.length,
inputSha256: createHash("sha256").update(source).digest("hex"),
outputSha256: createHash("sha256").update(result.bytes).digest("hex"),
sheetName: document.sheetName,
objectCount: objectRows.length,
headerCommentCount: document.columns.filter(
(column) => column.comment !== null
).length,
exactDuplicateIfcGuidCount: 0,
changedObjectCount: updates.length,
},
null,
2
)}\n`
);
}
void main();

View file

@ -0,0 +1,153 @@
export const MAX_EXTERNAL_XLSX_BYTES = 10 * 1024 * 1024;
export const DEFAULT_EXTERNAL_XLSX_LIMITS = {
maxRows: 50_000,
maxColumns: 256,
maxCells: 2_000_000,
} as const;
export type ExternalXlsxScalar = string | number | boolean | null;
export interface ExternalXlsxCell {
columnNumber: number;
value: ExternalXlsxScalar;
valueType: "empty" | "string" | "number" | "boolean";
numberFormat: string | null;
formula: string | null;
usedCachedFormulaValue: boolean;
}
export type ExternalXlsxRowClassification =
| "object"
| "suspect-object"
| "passthrough";
export interface ExternalXlsxRow {
rowNumber: number;
classification: ExternalXlsxRowClassification;
cells: ExternalXlsxCell[];
}
export interface ExternalXlsxColumn {
columnNumber: number;
header: string;
comment: string | null;
width: number | null;
}
export type ExternalXlsxIssueCode =
| "cached-formula-value-used"
| "formula-without-cached-value"
| "suspect-object-skipped"
| "duplicate-ifc-guid"
| "optional-return-column-missing";
export interface ExternalXlsxIssue {
code: ExternalXlsxIssueCode;
message: string;
rowNumber?: number;
columnNumber?: number;
field?: ExternalXlsxColumnKey;
}
export interface ExternalXlsxDocument {
sheetName: string;
headerRowNumber: number;
firstDataRowNumber: number;
firstDataColumnNumber: number;
columns: ExternalXlsxColumn[];
rows: ExternalXlsxRow[];
issues: ExternalXlsxIssue[];
}
export interface ExternalXlsxLayoutConfiguration {
headerRow: number;
firstDataRow: number;
firstDataColumn: number;
}
export type ExternalXlsxCircuitMode = "combined" | "separate";
export interface ExternalXlsxColumnConfiguration {
worksharingId: string;
ifcGuid: string | null;
roomNumber: string | null;
roomName: string | null;
familyAndType: string;
selectionMarker: string | null;
socketCount: string | null;
sourceRemark: string | null;
circuitCombined: string | null;
boardName: string | null;
circuitIdentifier: string | null;
designation: string | null;
electricalPowerW: string | null;
voltageV: string | null;
currentA: string | null;
}
export type ExternalXlsxColumnKey = keyof ExternalXlsxColumnConfiguration;
export interface ExternalXlsxTransportConfiguration {
layout: ExternalXlsxLayoutConfiguration;
circuitMode: ExternalXlsxCircuitMode;
columns: ExternalXlsxColumnConfiguration;
}
export interface ExternalXlsxLimits {
maxRows: number;
maxColumns: number;
maxCells: number;
}
export type ExternalXlsxReturnValues = Partial<
Record<
| "circuitCombined"
| "boardName"
| "circuitIdentifier"
| "designation"
| "electricalPowerW"
| "voltageV"
| "currentA",
ExternalXlsxScalar
>
>;
export interface ExternalXlsxReturnUpdate {
worksharingId: string;
values: ExternalXlsxReturnValues;
}
export interface ExternalXlsxReturnResult {
bytes: Uint8Array;
exportedObjectCount: number;
issues: ExternalXlsxIssue[];
}
export function createDefaultExternalXlsxTransportConfiguration(): ExternalXlsxTransportConfiguration {
return {
layout: {
headerRow: 1,
firstDataRow: 3,
firstDataColumn: 1,
},
circuitMode: "combined",
columns: {
worksharingId: "Worksharing-ID",
ifcGuid: "IfcGUID",
roomNumber: "MEP-Raum: Nummer",
roomName: "MEP-Raum: Name",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
socketCount: "CAX_AnzahlObjekte",
sourceRemark: "CAx_Anmerkung",
circuitCombined: "kbp_Stromkreisnummer",
boardName: "kbp-E-Verteiler",
circuitIdentifier: "kbp-E-Stromkreis",
designation: "kbp-E-Bezeichnung",
electricalPowerW: "kbp-E-Elektrische Leistung",
voltageV: "kbp-E-Spannung",
currentA: "kbp-E-Stromstärke",
},
};
}

View file

@ -0,0 +1,582 @@
import ExcelJS from "exceljs";
import type { CellValue, Worksheet } from "exceljs";
import {
DEFAULT_EXTERNAL_XLSX_LIMITS,
MAX_EXTERNAL_XLSX_BYTES,
type ExternalXlsxCell,
type ExternalXlsxColumnKey,
type ExternalXlsxDocument,
type ExternalXlsxIssue,
type ExternalXlsxLimits,
type ExternalXlsxReturnResult,
type ExternalXlsxReturnUpdate,
type ExternalXlsxScalar,
type ExternalXlsxTransportConfiguration,
} from "./external-xlsx-contracts.js";
export type ExternalXlsxErrorCode =
| "invalid-file-extension"
| "file-too-large"
| "invalid-workbook"
| "worksheet-count"
| "worksheet-too-large"
| "invalid-layout"
| "header-not-found"
| "ambiguous-header"
| "required-formula-result-missing"
| "invalid-worksharing-id"
| "duplicate-worksharing-id"
| "return-circuit-column-missing"
| "unknown-worksharing-id";
export class ExternalXlsxError extends Error {
constructor(
public readonly code: ExternalXlsxErrorCode,
message: string
) {
super(message);
this.name = "ExternalXlsxError";
}
}
export interface ParseExternalXlsxInput {
fileName: string;
bytes: Uint8Array;
configuration: ExternalXlsxTransportConfiguration;
limits?: ExternalXlsxLimits;
}
export interface CreateExternalXlsxReturnInput extends ParseExternalXlsxInput {
selectedWorksharingIds?: readonly string[];
updates?: readonly ExternalXlsxReturnUpdate[];
}
export async function parseExternalXlsx(
input: ParseExternalXlsxInput
): Promise<ExternalXlsxDocument> {
assertUpload(input.fileName, input.bytes);
assertConfiguration(input.configuration);
const workbook = await loadWorkbook(input.bytes);
const worksheet = getOnlyWorksheet(workbook);
assertWorksheetLimits(
worksheet,
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
);
return parseWorksheet(worksheet, input.configuration);
}
export async function createExternalXlsxReturn(
input: CreateExternalXlsxReturnInput
): Promise<ExternalXlsxReturnResult> {
assertUpload(input.fileName, input.bytes);
assertConfiguration(input.configuration);
const workbook = await loadWorkbook(input.bytes);
const worksheet = getOnlyWorksheet(workbook);
assertWorksheetLimits(
worksheet,
input.limits ?? DEFAULT_EXTERNAL_XLSX_LIMITS
);
const document = parseWorksheet(worksheet, input.configuration);
const headerLookup = createHeaderLookup(worksheet, input.configuration);
const issues = [...document.issues];
const returnColumns = resolveReturnColumns(
headerLookup,
input.configuration,
issues
);
const worksharingIdColumn = headerLookup.get(
input.configuration.columns.worksharingId
)!;
const objectRows = document.rows.filter((row) => row.classification === "object");
const objectRowById = new Map(
objectRows.map((row) => [
scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null),
row.rowNumber,
])
);
const selectedIds = input.selectedWorksharingIds
? new Set(input.selectedWorksharingIds)
: new Set(objectRowById.keys());
for (const worksharingId of selectedIds) {
if (!objectRowById.has(worksharingId)) {
throw new ExternalXlsxError(
"unknown-worksharing-id",
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
);
}
}
const updates = new Map(
(input.updates ?? []).map((update) => [update.worksharingId, update.values])
);
for (const worksharingId of updates.keys()) {
if (!objectRowById.has(worksharingId)) {
throw new ExternalXlsxError(
"unknown-worksharing-id",
`Die Worksharing-ID ist im Workbook nicht vorhanden: ${worksharingId}`
);
}
}
const selectedRows = objectRows.filter((row) => {
const id = scalarToIdentity(row.cells[worksharingIdColumn - 1]?.value ?? null);
return selectedIds.has(id);
});
const lastColumn = worksheet.columnCount;
const snapshots = selectedRows.map((row) =>
captureRow(worksheet, row.rowNumber, lastColumn)
);
snapshots.forEach((snapshot, index) => {
restoreRow(
worksheet,
input.configuration.layout.firstDataRow + index,
snapshot,
lastColumn
);
const destinationRow = worksheet.getRow(
input.configuration.layout.firstDataRow + index
);
const id = scalarToIdentity(destinationRow.getCell(worksharingIdColumn).value);
const values = updates.get(id);
if (!values) return;
for (const [field, value] of Object.entries(values) as [
keyof typeof values,
ExternalXlsxScalar,
][]) {
const columnNumber = returnColumns.get(field);
if (columnNumber !== undefined) {
destinationRow.getCell(columnNumber).value = value;
}
}
});
const firstRemovedRow =
input.configuration.layout.firstDataRow + snapshots.length;
const removeCount = Math.max(0, worksheet.rowCount - firstRemovedRow + 1);
// ExcelJS 4.4 does not shrink its internal row array when a multi-row
// splice removes the complete tail. Removing the tail one row at a time
// produces an actual OOXML deletion instead of serialized empty rows.
for (let index = 0; index < removeCount; index += 1) {
worksheet.spliceRows(worksheet.rowCount, 1);
}
const buffer = await workbook.xlsx.writeBuffer();
return {
bytes: new Uint8Array(buffer),
exportedObjectCount: snapshots.length,
issues,
};
}
function parseWorksheet(
worksheet: Worksheet,
configuration: ExternalXlsxTransportConfiguration
): ExternalXlsxDocument {
const headerLookup = createHeaderLookup(worksheet, configuration);
const issues: ExternalXlsxIssue[] = [];
const worksharingIdColumn = headerLookup.get(configuration.columns.worksharingId)!;
const familyAndTypeColumn = headerLookup.get(configuration.columns.familyAndType)!;
const ifcGuidColumn = configuration.columns.ifcGuid
? headerLookup.get(configuration.columns.ifcGuid)
: undefined;
const seenWorksharingIds = new Set<string>();
const seenIfcGuids = new Set<string>();
const rows = [] as ExternalXlsxDocument["rows"];
for (
let rowNumber = configuration.layout.firstDataRow;
rowNumber <= worksheet.rowCount;
rowNumber += 1
) {
const row = worksheet.getRow(rowNumber);
const cells: ExternalXlsxCell[] = [];
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
cells.push(
toExternalCell(row.getCell(columnNumber), rowNumber, columnNumber, issues)
);
}
const worksharingIdCell = cells[worksharingIdColumn - 1];
const familyAndTypeCell = cells[familyAndTypeColumn - 1];
for (const [field, requiredCell] of [
["Worksharing-ID", worksharingIdCell],
["Familie und Typ", familyAndTypeCell],
] as const) {
if (requiredCell?.formula != null && !requiredCell.usedCachedFormulaValue) {
throw new ExternalXlsxError(
"required-formula-result-missing",
`Das Pflichtfeld ${field} hat in Zeile ${rowNumber} kein gespeichertes Formelergebnis.`
);
}
}
const worksharingId = scalarToIdentity(worksharingIdCell?.value ?? null);
const familyAndType = scalarToIdentity(familyAndTypeCell?.value ?? null);
if (!worksharingId) {
const classification = familyAndType ? "suspect-object" : "passthrough";
if (classification === "suspect-object") {
issues.push({
code: "suspect-object-skipped",
message: `Zeile ${rowNumber} enthält Familie und Typ, aber keine Worksharing-ID, und wird übersprungen.`,
rowNumber,
});
}
rows.push({ rowNumber, classification, cells });
continue;
}
if (!isWorksharingId(worksharingId)) {
throw new ExternalXlsxError(
"invalid-worksharing-id",
`Zeile ${rowNumber} enthält eine ungültige Worksharing-ID.`
);
}
if (seenWorksharingIds.has(worksharingId)) {
throw new ExternalXlsxError(
"duplicate-worksharing-id",
`Die Worksharing-ID kommt mehrfach vor: ${worksharingId}`
);
}
seenWorksharingIds.add(worksharingId);
if (ifcGuidColumn !== undefined) {
const ifcGuid = scalarToIdentity(cells[ifcGuidColumn - 1]?.value ?? null);
if (ifcGuid) {
if (seenIfcGuids.has(ifcGuid)) {
issues.push({
code: "duplicate-ifc-guid",
message: `IfcGUID kommt mehrfach vor: ${ifcGuid}`,
rowNumber,
columnNumber: ifcGuidColumn,
field: "ifcGuid",
});
}
seenIfcGuids.add(ifcGuid);
}
}
rows.push({ rowNumber, classification: "object", cells });
}
const headerRow = worksheet.getRow(configuration.layout.headerRow);
const columns = [] as ExternalXlsxDocument["columns"];
for (let columnNumber = 1; columnNumber <= worksheet.columnCount; columnNumber += 1) {
const cell = headerRow.getCell(columnNumber);
columns.push({
columnNumber,
header: scalarToIdentity(toScalar(cell.value).value),
comment: getCommentText(cell.note),
width: worksheet.getColumn(columnNumber).width ?? null,
});
}
return {
sheetName: worksheet.name,
headerRowNumber: configuration.layout.headerRow,
firstDataRowNumber: configuration.layout.firstDataRow,
firstDataColumnNumber: configuration.layout.firstDataColumn,
columns,
rows,
issues,
};
}
function createHeaderLookup(
worksheet: Worksheet,
configuration: ExternalXlsxTransportConfiguration
) {
const headers = new Map<string, number[]>();
const row = worksheet.getRow(configuration.layout.headerRow);
for (
let columnNumber = configuration.layout.firstDataColumn;
columnNumber <= worksheet.columnCount;
columnNumber += 1
) {
const header = scalarToIdentity(toScalar(row.getCell(columnNumber).value).value);
if (!header) continue;
headers.set(header, [...(headers.get(header) ?? []), columnNumber]);
}
const configured = Object.entries(configuration.columns) as [
ExternalXlsxColumnKey,
string | null,
][];
const required = new Set<ExternalXlsxColumnKey>(["worksharingId", "familyAndType"]);
const lookup = new Map<string, number>();
for (const [field, header] of configured) {
if (header === null) continue;
const matches = headers.get(header) ?? [];
if (matches.length > 1) {
throw new ExternalXlsxError(
"ambiguous-header",
`Die konfigurierte Kopfzelle ist nicht eindeutig: ${header}`
);
}
if (matches.length === 0 && required.has(field)) {
throw new ExternalXlsxError(
"header-not-found",
`Die konfigurierte Pflichtspalte fehlt: ${header}`
);
}
if (matches.length === 1) lookup.set(header, matches[0]);
}
return lookup;
}
function resolveReturnColumns(
headerLookup: Map<string, number>,
configuration: ExternalXlsxTransportConfiguration,
issues: ExternalXlsxIssue[]
) {
const result = new Map<keyof ExternalXlsxReturnUpdate["values"], number>();
const circuitFields: ("circuitCombined" | "boardName" | "circuitIdentifier")[] =
configuration.circuitMode === "combined"
? ["circuitCombined"]
: ["boardName", "circuitIdentifier"];
for (const field of circuitFields) {
const header = configuration.columns[field];
const columnNumber = header === null ? undefined : headerLookup.get(header);
if (columnNumber === undefined) {
throw new ExternalXlsxError(
"return-circuit-column-missing",
`Die Stromkreisspalte des gewählten Modus fehlt: ${field}`
);
}
result.set(field, columnNumber);
}
const optionalFields = [
"designation",
"electricalPowerW",
"voltageV",
"currentA",
] as const;
for (const field of optionalFields) {
const header = configuration.columns[field];
const columnNumber = header === null ? undefined : headerLookup.get(header);
if (columnNumber === undefined) {
issues.push({
code: "optional-return-column-missing",
message: `Die optionale Rückgabespalte fehlt und wird übersprungen: ${field}`,
field,
});
} else {
result.set(field, columnNumber);
}
}
return result;
}
function toExternalCell(
cell: ExcelJS.Cell,
rowNumber: number,
columnNumber: number,
issues: ExternalXlsxIssue[]
): ExternalXlsxCell {
const converted = toScalar(cell.value);
if (converted.formula !== null) {
issues.push({
code: converted.usedCachedFormulaValue
? "cached-formula-value-used"
: "formula-without-cached-value",
message: converted.usedCachedFormulaValue
? `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} wird mit ihrem gespeicherten Ergebnis gelesen.`
: `Formel in Zeile ${rowNumber}, Spalte ${columnNumber} hat kein nutzbares gespeichertes Ergebnis und bleibt leer.`,
rowNumber,
columnNumber,
});
}
return {
columnNumber,
value: converted.value,
valueType: getScalarValueType(converted.value),
numberFormat: cell.numFmt || null,
formula: converted.formula,
usedCachedFormulaValue: converted.usedCachedFormulaValue,
};
}
function toScalar(value: CellValue): {
value: ExternalXlsxScalar;
formula: string | null;
usedCachedFormulaValue: boolean;
} {
if (value === null || value === undefined) {
return { value: null, formula: null, usedCachedFormulaValue: false };
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return { value, formula: null, usedCachedFormulaValue: false };
}
if (value instanceof Date) {
return { value: value.toISOString(), formula: null, usedCachedFormulaValue: false };
}
if ("formula" in value || "sharedFormula" in value) {
const formula =
("formula" in value ? value.formula : value.sharedFormula) ?? "";
const result = "result" in value ? value.result : undefined;
if (
typeof result === "string" ||
typeof result === "number" ||
typeof result === "boolean"
) {
return { value: result, formula, usedCachedFormulaValue: true };
}
return { value: null, formula, usedCachedFormulaValue: false };
}
if ("error" in value) {
return { value: null, formula: null, usedCachedFormulaValue: false };
}
if ("richText" in value) {
return {
value: value.richText.map((part) => part.text).join(""),
formula: null,
usedCachedFormulaValue: false,
};
}
if ("text" in value) {
return { value: value.text, formula: null, usedCachedFormulaValue: false };
}
return { value: null, formula: null, usedCachedFormulaValue: false };
}
function captureRow(worksheet: Worksheet, rowNumber: number, lastColumn: number) {
const row = worksheet.getRow(rowNumber);
return {
height: row.height,
cells: Array.from({ length: lastColumn }, (_, index) => {
const cell = row.getCell(index + 1);
const converted = toScalar(cell.value);
return {
value: converted.formula === null ? cloneValue(cell.value) : converted.value,
style: structuredClone(cell.style),
note: cell.note === undefined ? undefined : structuredClone(cell.note),
};
}),
};
}
function restoreRow(
worksheet: Worksheet,
rowNumber: number,
snapshot: ReturnType<typeof captureRow>,
lastColumn: number
) {
const row = worksheet.getRow(rowNumber);
row.height = snapshot.height;
for (let columnNumber = 1; columnNumber <= lastColumn; columnNumber += 1) {
const cell = row.getCell(columnNumber);
const source = snapshot.cells[columnNumber - 1];
cell.value = source.value;
cell.style = structuredClone(source.style);
if (source.note === undefined) {
// ExcelJS has no public comment-removal API. Its note setter creates a
// Note even for undefined, which would add an empty comment to the cell.
(cell as unknown as { _comment?: unknown })._comment = undefined;
} else {
cell.note = source.note;
}
}
}
function cloneValue(value: CellValue): CellValue {
if (value instanceof Date) return new Date(value);
if (value && typeof value === "object") return structuredClone(value);
return value;
}
function getCommentText(note: ExcelJS.Cell["note"]): string | null {
if (note === undefined) return null;
if (typeof note === "string") return note;
return note.texts?.map((part) => part.text).join("") ?? null;
}
function getScalarValueType(value: ExternalXlsxScalar): ExternalXlsxCell["valueType"] {
if (value === null) return "empty";
if (typeof value === "string") return "string";
if (typeof value === "number") return "number";
return "boolean";
}
function scalarToIdentity(value: ExternalXlsxScalar | CellValue): string {
const scalar =
typeof value === "object" && value !== null ? toScalar(value).value : value;
return typeof scalar === "string" ? scalar : scalar === null ? "" : String(scalar);
}
function isWorksharingId(value: string) {
return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}-[0-9A-Fa-f]{8}$/.test(
value
);
}
function assertUpload(fileName: string, bytes: Uint8Array) {
if (!fileName.toLocaleLowerCase("en-US").endsWith(".xlsx")) {
throw new ExternalXlsxError(
"invalid-file-extension",
"Es werden ausschließlich .xlsx-Dateien unterstützt."
);
}
if (bytes.byteLength > MAX_EXTERNAL_XLSX_BYTES) {
throw new ExternalXlsxError(
"file-too-large",
"Die XLSX-Datei darf höchstens 10 MiB groß sein."
);
}
}
async function loadWorkbook(bytes: Uint8Array) {
const workbook = new ExcelJS.Workbook();
try {
await workbook.xlsx.load(
Buffer.from(bytes) as unknown as Parameters<typeof workbook.xlsx.load>[0]
);
} catch (error) {
throw new ExternalXlsxError(
"invalid-workbook",
`Die XLSX-Datei konnte nicht gelesen werden: ${error instanceof Error ? error.message : "Unbekannter Fehler"}`
);
}
return workbook;
}
function getOnlyWorksheet(workbook: ExcelJS.Workbook) {
if (workbook.worksheets.length !== 1) {
throw new ExternalXlsxError(
"worksheet-count",
"Die XLSX-Datei muss genau ein Arbeitsblatt enthalten."
);
}
return workbook.worksheets[0];
}
function assertWorksheetLimits(worksheet: Worksheet, limits: ExternalXlsxLimits) {
const rowCount = worksheet.rowCount;
const columnCount = worksheet.columnCount;
if (
rowCount > limits.maxRows ||
columnCount > limits.maxColumns ||
rowCount * columnCount > limits.maxCells
) {
throw new ExternalXlsxError(
"worksheet-too-large",
`Das Arbeitsblatt überschreitet die Grenzen (${rowCount} Zeilen, ${columnCount} Spalten).`
);
}
}
function assertConfiguration(configuration: ExternalXlsxTransportConfiguration) {
const { headerRow, firstDataRow, firstDataColumn } = configuration.layout;
if (
!Number.isInteger(headerRow) ||
headerRow < 1 ||
!Number.isInteger(firstDataRow) ||
firstDataRow <= headerRow ||
!Number.isInteger(firstDataColumn) ||
firstDataColumn < 1
) {
throw new ExternalXlsxError(
"invalid-layout",
"Kopfzeile, erste Datenzeile oder erste Datenspalte sind ungültig."
);
}
if (!configuration.columns.worksharingId || !configuration.columns.familyAndType) {
throw new ExternalXlsxError(
"invalid-layout",
"Worksharing-ID und Familie und Typ müssen konfiguriert sein."
);
}
}

View file

@ -0,0 +1,267 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import {
createExternalXlsxReturn,
ExternalXlsxError,
parseExternalXlsx,
} from "../src/external-model/xlsx/external-xlsx-transport.js";
import {
createRevitXlsxFixture,
externalXlsxTestConfiguration,
firstWorksharingId,
secondWorksharingId,
} from "./fixtures/revit-xlsx-fixtures.js";
describe("external XLSX transport", () => {
it("reads typed cells and classifies ID-less rows without blocking summaries", async () => {
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture(),
configuration: externalXlsxTestConfiguration,
});
assert.equal(document.sheetName, "Linear Export");
assert.equal(document.headerRowNumber, 1);
assert.equal(document.firstDataRowNumber, 3);
assert.equal(document.firstDataColumnNumber, 1);
assert.deepEqual(
document.rows.map((row) => row.classification),
["object", "object", "suspect-object", "passthrough"]
);
assert.equal(document.rows[0].cells[5].value, 2);
assert.equal(document.rows[0].cells[15].value, true);
assert.equal(document.columns[0].comment, "Kommentar Worksharing-ID");
assert.equal(document.columns[0].width, 12);
assert.deepEqual(
document.issues.map((issue) => issue.code),
["suspect-object-skipped"]
);
});
it("resolves editable layout positions without a separate ID column", async () => {
const configuration = {
...externalXlsxTestConfiguration,
layout: { headerRow: 2, firstDataRow: 5, firstDataColumn: 2 },
};
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture(configuration.layout),
configuration,
});
assert.equal(document.headerRowNumber, 2);
assert.equal(document.firstDataRowNumber, 5);
assert.equal(document.firstDataColumnNumber, 2);
assert.equal(document.columns[1].header, "Worksharing-ID");
assert.equal(document.rows[0].cells[1].value, firstWorksharingId);
assert.deepEqual(
document.rows.map((row) => row.classification),
["object", "object", "suspect-object", "passthrough"]
);
});
it("compares Worksharing-ID and IfcGUID exactly and case-sensitively", async () => {
const lowerCaseId = secondWorksharingId.toLowerCase();
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
secondWorksharingId: lowerCaseId,
firstIfcGuid: "IfcGUID",
secondIfcGuid: "IFCGUID",
}),
configuration: externalXlsxTestConfiguration,
});
assert.equal(
document.rows.filter((row) => row.classification === "object").length,
2
);
assert.equal(
document.issues.some((issue) => issue.code === "duplicate-ifc-guid"),
false
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({ duplicateWorksharingId: true }),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("duplicate-worksharing-id")
);
const duplicateIfcDocument = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
firstIfcGuid: "same",
secondIfcGuid: "same",
}),
configuration: externalXlsxTestConfiguration,
});
assert.equal(
duplicateIfcDocument.issues.filter(
(issue) => issue.code === "duplicate-ifc-guid"
).length,
1
);
});
it("uses cached formula values without evaluating formulas", async () => {
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({ includeFormula: true }),
configuration: externalXlsxTestConfiguration,
});
const powerCell = document.rows[0].cells[11];
assert.equal(powerCell.value, 250);
assert.equal(powerCell.formula, "125*2");
assert.equal(powerCell.usedCachedFormulaValue, true);
assert.equal(
document.issues.some(
(issue) => issue.code === "cached-formula-value-used"
),
true
);
});
it("blocks a required formula without a cached result", async () => {
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
includeRequiredFormulaWithoutResult: true,
}),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("required-formula-result-missing")
);
});
it("writes selected object rows in source order and preserves workbook structure", async () => {
const source = await createRevitXlsxFixture({ includeFormula: true });
const result = await createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: externalXlsxTestConfiguration,
selectedWorksharingIds: [secondWorksharingId, firstWorksharingId],
updates: [
{
worksharingId: firstWorksharingId,
values: {
circuitCombined: "UV99-3F7",
electricalPowerW: 321.5,
voltageV: 400,
currentA: 0.46,
},
},
],
});
assert.equal(result.exportedObjectCount, 2);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(result.bytes as never);
const worksheet = workbook.worksheets[0];
assert.equal(worksheet.name, "Linear Export");
assert.equal(worksheet.rowCount, 4);
assert.equal(worksheet.getCell("A3").value, firstWorksharingId);
assert.equal(worksheet.getCell("A4").value, secondWorksharingId);
assert.equal(worksheet.getCell("H3").value, "UV99-3F7");
assert.equal(worksheet.getCell("L3").value, 321.5);
assert.equal(worksheet.getCell("M3").value, 400);
assert.equal(worksheet.getCell("N3").value, 0.46);
assert.equal(worksheet.getCell("P4").value, "bleibt");
assert.equal(worksheet.getCell("L3").numFmt, "0.00");
assert.equal(worksheet.getColumn(1).width, 12);
assert.equal(
typeof worksheet.getCell("A1").note === "string"
? worksheet.getCell("A1").note
: worksheet.getCell("A1").note?.texts?.[0]?.text,
"Kommentar Worksharing-ID"
);
const commentedCells: string[] = [];
worksheet.eachRow({ includeEmpty: true }, (row) => {
row.eachCell({ includeEmpty: true }, (cell) => {
if (cell.note !== undefined) commentedCells.push(cell.address);
});
});
assert.deepEqual(
commentedCells,
Array.from({ length: 16 }, (_, index) =>
worksheet.getCell(1, index + 1).address
)
);
});
it("warns for missing optional return columns and blocks missing circuit columns", async () => {
const source = await createRevitXlsxFixture();
const optionalMissing = {
...externalXlsxTestConfiguration,
columns: {
...externalXlsxTestConfiguration.columns,
designation: "Nicht vorhanden",
},
};
const result = await createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: optionalMissing,
});
assert.equal(
result.issues.some(
(issue) =>
issue.code === "optional-return-column-missing" &&
issue.field === "designation"
),
true
);
await assert.rejects(
createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: {
...externalXlsxTestConfiguration,
columns: {
...externalXlsxTestConfiguration.columns,
circuitCombined: "Nicht vorhanden",
},
},
}),
isXlsxError("return-circuit-column-missing")
);
});
it("enforces extension, byte size and post-read sheet limits", async () => {
const source = await createRevitXlsxFixture();
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsm",
bytes: source,
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("invalid-file-extension")
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: new Uint8Array(10 * 1024 * 1024 + 1),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("file-too-large")
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: source,
configuration: externalXlsxTestConfiguration,
limits: { maxRows: 3, maxColumns: 100, maxCells: 1_000 },
}),
isXlsxError("worksheet-too-large")
);
});
});
function isXlsxError(code: ExternalXlsxError["code"]) {
return (error: unknown) =>
error instanceof ExternalXlsxError && error.code === code;
}

134
tests/fixtures/revit-xlsx-fixtures.ts vendored Normal file
View file

@ -0,0 +1,134 @@
import ExcelJS from "exceljs";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxTransportConfiguration,
} from "../../src/external-model/xlsx/external-xlsx-contracts.js";
export const firstWorksharingId =
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000001";
export const secondWorksharingId =
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000002";
export const externalXlsxTestConfiguration: ExternalXlsxTransportConfiguration =
createDefaultExternalXlsxTransportConfiguration();
export async function createRevitXlsxFixture(options?: {
duplicateWorksharingId?: boolean;
secondWorksharingId?: string;
firstIfcGuid?: string;
secondIfcGuid?: string;
includeFormula?: boolean;
includeRequiredFormulaWithoutResult?: boolean;
headerRow?: number;
firstDataRow?: number;
firstDataColumn?: number;
}) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Linear Export");
const headerRow = options?.headerRow ?? 1;
const firstDataRow = options?.firstDataRow ?? 3;
const firstDataColumn = options?.firstDataColumn ?? 1;
const headers = [
"Worksharing-ID",
"CAx_Auswahlkenner",
"MEP-Raum: Nummer",
"MEP-Raum: Name",
"Familie und Typ",
"CAX_AnzahlObjekte",
"CAx_Anmerkung",
"kbp_Stromkreisnummer",
"kbp-E-Verteiler",
"kbp-E-Stromkreis",
"kbp-E-Bezeichnung",
"kbp-E-Elektrische Leistung",
"kbp-E-Spannung",
"kbp-E-Stromstärke",
"IfcGUID",
"Unbekannt",
];
headers.forEach((header, index) => {
const columnNumber = firstDataColumn + index;
const cell = worksheet.getCell(headerRow, columnNumber);
cell.value = header;
cell.note = `Kommentar ${header}`;
cell.font = { bold: true };
worksheet.getColumn(columnNumber).width = 12 + index;
});
setRowValues(worksheet, firstDataRow, firstDataColumn, [
firstWorksharingId,
"Steckdose",
"01.101",
"Büro",
"Steckdose: Standard",
2,
"Quelle A",
"UV01-2F1",
"UV01",
"-2F1",
"Arbeitsplatz",
250,
230,
1.09,
options?.firstIfcGuid ?? "IfcCaseSensitive",
true,
]);
worksheet.getCell(firstDataRow, firstDataColumn + 11).numFmt = "0.00";
worksheet.getCell(firstDataRow, firstDataColumn + 12).numFmt = "0";
worksheet.getCell(firstDataRow, firstDataColumn + 13).numFmt = "0.00";
if (options?.includeFormula) {
worksheet.getCell(firstDataRow, firstDataColumn + 11).value = {
formula: "125*2",
result: 250,
};
}
if (options?.includeRequiredFormulaWithoutResult) {
worksheet.getCell(firstDataRow, firstDataColumn).value = { formula: "\"\"" };
}
setRowValues(worksheet, firstDataRow + 1, firstDataColumn, [
options?.duplicateWorksharingId
? firstWorksharingId
: options?.secondWorksharingId ?? secondWorksharingId,
"Leuchte",
null,
null,
"Leuchte: Decke",
1,
"Quelle B",
"UV01-1F1",
"UV01",
"-1F1",
"Beleuchtung",
42.5,
230,
0.18,
options?.secondIfcGuid ?? "IfcCaseSensitive2",
"bleibt",
]);
worksheet.getCell(firstDataRow + 1, firstDataColumn + 11).numFmt = "0.00";
setRowValues(worksheet, firstDataRow + 2, firstDataColumn, [
null,
"verdächtig",
null,
null,
"Gerät: Ohne ID",
]);
setRowValues(worksheet, firstDataRow + 3, firstDataColumn, [
null,
"Summe: 292,5 W",
]);
return new Uint8Array(await workbook.xlsx.writeBuffer());
}
function setRowValues(
worksheet: ExcelJS.Worksheet,
rowNumber: number,
firstColumn: number,
values: ExcelJS.CellValue[]
) {
values.forEach((value, index) => {
worksheet.getCell(rowNumber, firstColumn + index).value = value;
});
}