Add Linear Excel transport
This commit is contained in:
parent
d4803a18d2
commit
52d3b1a270
10 changed files with 2131 additions and 83 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ data/*.db
|
|||
data/backups/*.db
|
||||
.codex/*.log
|
||||
/docs/spec/kbp-Stromkreis.xlsx
|
||||
/data/revit-reference/*.xlsx
|
||||
|
|
|
|||
|
|
@ -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
811
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
);
|
||||
162
scripts/verify-revit-reference-xlsx.ts
Normal file
162
scripts/verify-revit-reference-xlsx.ts
Normal 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();
|
||||
153
src/external-model/xlsx/external-xlsx-contracts.ts
Normal file
153
src/external-model/xlsx/external-xlsx-contracts.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
582
src/external-model/xlsx/external-xlsx-transport.ts
Normal file
582
src/external-model/xlsx/external-xlsx-transport.ts
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
267
tests/external-xlsx-transport.test.ts
Normal file
267
tests/external-xlsx-transport.test.ts
Normal 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
134
tests/fixtures/revit-xlsx-fixtures.ts
vendored
Normal 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;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue