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