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
.codex/*.log
/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. |
| 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. |
| R4 | offen | Revit-Seite, Navigation, Einstellungen, altes Projektseitenmodal entfernen. |
| 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
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
- 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:backup": "tsx scripts/db-backup.ts",
"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": [],
"author": "",
@ -36,6 +36,7 @@
"better-sqlite3": "^12.9.0",
"bootstrap": "^5.3.8",
"drizzle-orm": "^0.45.2",
"exceljs": "^4.4.0",
"express": "^5.2.1",
"lucide-react": "^1.14.0",
"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;
});
}