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.
Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen
Build-/Typecheck-Schritte. Für die exakte Abnahme mit 897 Objekt- und 211
Summenzeilen wird noch die anonymisierte Referenz-CSV benötigt; sie liegt nicht
im Repository.
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere
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
@@ -322,8 +324,9 @@ festgelegt.
nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert.
- **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im
Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit.
- **Unvollständige Referenzabnahme:** Die reale beziehungsweise anonymisierte
Referenz-CSV muss vor Abschluss von 14.1 als lokale Testgrundlage vorliegen.
- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die
bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu
klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist.
## Freigabepunkt
+2 -1
View File
@@ -20,7 +20,8 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"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": [],
"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",
"circuitIdentifier",
"power",
"quantity",
] as const;
export function assertExternalCsvConfiguration(
@@ -56,6 +55,16 @@ export function assertExternalCsvConfiguration(
}
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)) {
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.`);
}
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);
}
}
@@ -15,7 +15,7 @@ export interface ExternalCsvColumnMapping {
selectionMarker: string;
circuitIdentifier: string;
power: string;
quantity: string;
quantity: string | null;
}
export interface ExternalCsvAdditionalSourceMapping {
@@ -59,7 +59,9 @@ export function parseExternalCsv(
const headerRowIndex = headerMatches[0];
const configuredColumnNames = [
...Object.values(configuration.columns),
...Object.values(configuration.columns).filter(
(column): column is string => column !== null
),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
];
const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value);
@@ -81,7 +83,9 @@ export function parseExternalCsv(
configuration.columns.familyAndType,
configuration.columns.selectionMarker,
configuration.columns.power,
configuration.columns.quantity,
...(configuration.columns.quantity === null
? []
: [configuration.columns.quantity]),
...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn),
].map((column) => headerLookup.get(column)!);
const seenIfcGuids = new Set<string>();
@@ -274,7 +278,9 @@ function findHeaderRows(
configuration: ExternalCsvConfiguration
) {
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),
]);
const matches: number[] = [];
+19
View File
@@ -103,6 +103,25 @@ describe("external CSV configuration", () => {
});
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) {
+22
View File
@@ -51,6 +51,28 @@ describe("external CSV transport", () => {
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", () => {
const csv = quotedRevitCsv.replace(
'"Steckdose: Doppelsteckdose"',