Verify Revit reference CSV

This commit is contained in:
2026-08-02 16:01:05 +02:00
parent bfe8b790b2
commit da689af518
8 changed files with 149 additions and 11 deletions
@@ -253,9 +253,11 @@ keine Projektrevision und laufen über einen eigenen Audit-Store.
geparst und ihr Hash gegen die Vorschau geprüft. geparst und ihr Hash gegen die Vorschau geprüft.
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
Build-/Typecheck-Schritte. Für die exakte Abnahme mit 897 Objekt- und 211 Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
Summenzeilen wird noch die anonymisierte Referenz-CSV benötigt; sie liegt nicht `npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
im Repository. Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die
Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in
einen Commit aufgenommen.
### 14.2 Persistente Objekte und Erstimport ### 14.2 Persistente Objekte und Erstimport
@@ -322,8 +324,9 @@ festgelegt.
nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert. nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert.
- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im - **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im
Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit. Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit.
- **Unvollständige Referenzabnahme:** Die reale beziehungsweise anonymisierte - **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die
Referenz-CSV muss vor Abschluss von 14.1 als lokale Testgrundlage vorliegen. bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu
klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist.
## Freigabepunkt ## Freigabepunkt
+2 -1
View File
@@ -20,7 +20,8 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"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"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
+73
View File
@@ -0,0 +1,73 @@
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";
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);
assert.equal(counts.object, 897, "Expected 897 object rows.");
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,
},
null,
2
)}\n`
);
@@ -19,7 +19,6 @@ const requiredColumnKeys = [
"selectionMarker", "selectionMarker",
"circuitIdentifier", "circuitIdentifier",
"power", "power",
"quantity",
] as const; ] as const;
export function assertExternalCsvConfiguration( export function assertExternalCsvConfiguration(
@@ -56,6 +55,16 @@ export function assertExternalCsvConfiguration(
} }
mappedColumns.add(columnName); mappedColumns.add(columnName);
} }
if (value.columns.quantity !== null) {
const quantityColumn = assertTrimmedString(
value.columns.quantity,
"columns.quantity"
);
if (mappedColumns.has(quantityColumn)) {
throw new Error(`External CSV column is mapped more than once: ${quantityColumn}`);
}
mappedColumns.add(quantityColumn);
}
if (!Array.isArray(value.additionalSourceMappings)) { if (!Array.isArray(value.additionalSourceMappings)) {
throw new Error("External CSV additional source mappings must be an array."); throw new Error("External CSV additional source mappings must be an array.");
@@ -115,6 +124,11 @@ export function assertExternalCsvConfiguration(
throw new Error(`familyTypeRules.${index}.category is unsupported.`); throw new Error(`familyTypeRules.${index}.category is unsupported.`);
} }
assertQuantityRule(rule.quantityRule, index); assertQuantityRule(rule.quantityRule, index);
if (rule.quantityRule.kind === "mapped-column" && value.columns.quantity === null) {
throw new Error(
`familyTypeRules.${index}.quantityRule requires a configured quantity column.`
);
}
assertDisplayNameSuggestion(rule.displayNameSuggestion, index); assertDisplayNameSuggestion(rule.displayNameSuggestion, index);
} }
} }
@@ -15,7 +15,7 @@ export interface ExternalCsvColumnMapping {
selectionMarker: string; selectionMarker: string;
circuitIdentifier: string; circuitIdentifier: string;
power: string; power: string;
quantity: string; quantity: string | null;
} }
export interface ExternalCsvAdditionalSourceMapping { export interface ExternalCsvAdditionalSourceMapping {
@@ -59,7 +59,9 @@ export function parseExternalCsv(
const headerRowIndex = headerMatches[0]; const headerRowIndex = headerMatches[0];
const configuredColumnNames = [ const configuredColumnNames = [
...Object.values(configuration.columns), ...Object.values(configuration.columns).filter(
(column): column is string => column !== null
),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
]; ];
const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value); const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value);
@@ -81,7 +83,9 @@ export function parseExternalCsv(
configuration.columns.familyAndType, configuration.columns.familyAndType,
configuration.columns.selectionMarker, configuration.columns.selectionMarker,
configuration.columns.power, configuration.columns.power,
configuration.columns.quantity, ...(configuration.columns.quantity === null
? []
: [configuration.columns.quantity]),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
].map((column) => headerLookup.get(column)!); ].map((column) => headerLookup.get(column)!);
const seenIfcGuids = new Set<string>(); const seenIfcGuids = new Set<string>();
@@ -274,7 +278,9 @@ function findHeaderRows(
configuration: ExternalCsvConfiguration configuration: ExternalCsvConfiguration
) { ) {
const requiredColumns = new Set([ const requiredColumns = new Set([
...Object.values(configuration.columns), ...Object.values(configuration.columns).filter(
(column): column is string => column !== null
),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
]); ]);
const matches: number[] = []; const matches: number[] = [];
+19
View File
@@ -103,6 +103,25 @@ describe("external CSV configuration", () => {
}); });
assert.match(validateFailure(invalidQuantity), /positive finite number/); assert.match(validateFailure(invalidQuantity), /positive finite number/);
}); });
it("allows a missing quantity column only for fixed quantity rules", () => {
const configuration = createDefaultExternalCsvConfiguration({
...columns,
quantity: null,
});
configuration.familyTypeRules.push({
exactFamilyAndType: "Steckdose: Standard",
internalDeviceType: "Steckdose",
connectionKind: null,
category: "single_phase",
quantityRule: { kind: "fixed", quantity: 1 },
displayNameSuggestion: null,
});
assert.equal(validateExternalCsvConfiguration(configuration).success, true);
configuration.familyTypeRules[0].quantityRule = { kind: "mapped-column" };
assert.match(validateFailure(configuration), /requires a configured quantity column/);
});
}); });
function validateFailure(value: unknown) { function validateFailure(value: unknown) {
+22
View File
@@ -51,6 +51,28 @@ describe("external CSV transport", () => {
assert.deepEqual(serializeExternalCsv(document), source); assert.deepEqual(serializeExternalCsv(document), source);
}); });
it("parses exports without an optional quantity column", () => {
const configuration = {
...externalCsvTestConfiguration,
columns: {
...externalCsvTestConfiguration.columns,
quantity: null,
},
};
const csv = [
'"Projekt-Export";"";"";"";"";"";""',
'"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"CAx_Auswahlkenner";"Elektrische Leistung";"IfcGUID"',
`"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Standard";"Arbeitsplatz";"100";"${firstIfcGuid}"`,
].join("\r\n") + "\r\n";
const document = parseExternalCsv(utf8Bytes(csv, true), configuration);
assert.equal(
document.rows.filter((row) => row.classification === "object").length,
1
);
});
it("preserves embedded delimiters and line endings inside quoted cells", () => { it("preserves embedded delimiters and line endings inside quoted cells", () => {
const csv = quotedRevitCsv.replace( const csv = quotedRevitCsv.replace(
'"Steckdose: Doppelsteckdose"', '"Steckdose: Doppelsteckdose"',