Add Revit CSV preview API
This commit is contained in:
@@ -389,3 +389,22 @@ inverse command. The former direct restore/reconnect endpoints are removed.
|
|||||||
|
|
||||||
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
`displayName` is included in the comparison but is not selected by default in the UI. Updating a
|
||||||
project device never triggers synchronization implicitly.
|
project device never triggers synchronization implicitly.
|
||||||
|
|
||||||
|
## Revit CSV Foundation
|
||||||
|
|
||||||
|
- `GET /projects/:projectId/external-csv/configuration`
|
||||||
|
- returns the complete versioned project configuration or `null`
|
||||||
|
- `PUT /projects/:projectId/external-csv/configuration`
|
||||||
|
- request: `expectedRevision` and the complete validated configuration
|
||||||
|
- executes `external-csv-configuration.update`
|
||||||
|
- returns configuration, revision and project history state
|
||||||
|
- `POST /projects/:projectId/external-csv/preview`
|
||||||
|
- request: file name and Base64-encoded CSV content up to 18 MB
|
||||||
|
- requires a stored project configuration
|
||||||
|
- returns transport metadata, SHA-256, row classifications, suspect row
|
||||||
|
numbers and mapped source values for every recognized IFC object
|
||||||
|
- does not persist a draft, mutate project state or create a revision
|
||||||
|
|
||||||
|
Parser errors expose stable codes such as `header-not-found`,
|
||||||
|
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. A later
|
||||||
|
confirmed import must upload and parse the file again and verify its hash.
|
||||||
|
|||||||
@@ -502,6 +502,13 @@ Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3,
|
|||||||
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
||||||
werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente
|
werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente
|
||||||
externe Objekte und die Revit-GUI sind noch nicht implementiert.
|
externe Objekte und die Revit-GUI sind noch nicht implementiert.
|
||||||
|
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
|
||||||
|
ändern die Konfiguration; der PUT plant Identität und nächsten
|
||||||
|
Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
||||||
|
`POST /api/projects/:projectId/external-csv/preview` nimmt Dateiname und
|
||||||
|
Base64-kodierte CSV bis 18 MB entgegen. Der zustandsfreie Service liefert Hash,
|
||||||
|
Dialekt, Header, Klassifikationszahlen, Verdachtszeilen und gemappte
|
||||||
|
Objektquellwerte. Er schreibt weder Entwurf noch Projektdaten.
|
||||||
|
|
||||||
## Noch nicht unterstützt
|
## Noch nicht unterstützt
|
||||||
|
|
||||||
|
|||||||
@@ -249,9 +249,11 @@ keine Projektrevision und laufen über einen eigenen Audit-Store.
|
|||||||
synthetische Testdaten; die Referenzdatei steht noch aus.**
|
synthetische Testdaten; die Referenzdatei steht noch aus.**
|
||||||
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
|
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
|
||||||
`external-csv-configuration.update` persistieren. **Erledigt.**
|
`external-csv-configuration.update` persistieren. **Erledigt.**
|
||||||
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. Der Server speichert
|
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. **Erledigt:**
|
||||||
keinen Entwurf: Bei Bestätigung wird die Datei erneut übertragen, erneut
|
Konfigurations-Read/Write- und zustandsfreier Vorschau-Endpunkt. **Offen:**
|
||||||
geparst und ihr Hash gegen die Vorschau geprüft.
|
deutscher Projekt-Wizard. Der Server speichert keinen Entwurf: Bei
|
||||||
|
Bestätigung wird die Datei erneut übertragen, erneut 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. Die lokal bereitgestellte Referenz-CSV wird mit
|
Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
parseExternalCsv,
|
parseExternalCsv,
|
||||||
serializeExternalCsv,
|
serializeExternalCsv,
|
||||||
} from "../src/external-model/csv/external-csv-transport.js";
|
} from "../src/external-model/csv/external-csv-transport.js";
|
||||||
|
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
||||||
|
|
||||||
const filePath = resolve(
|
const filePath = resolve(
|
||||||
process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv"
|
process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv"
|
||||||
@@ -45,8 +46,14 @@ const nonEmptyPassthroughRows = document.rows.filter(
|
|||||||
row.cells.some((cell) => cell.value !== "")
|
row.cells.some((cell) => cell.value !== "")
|
||||||
).length;
|
).length;
|
||||||
const serialized = serializeExternalCsv(document);
|
const serialized = serializeExternalCsv(document);
|
||||||
|
const preview = createExternalCsvPreview({
|
||||||
|
fileName: filePath,
|
||||||
|
bytes: source,
|
||||||
|
configuration,
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(counts.object, 897, "Expected 897 object rows.");
|
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(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows.");
|
||||||
assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows.");
|
assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows.");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -66,6 +73,7 @@ process.stdout.write(
|
|||||||
rows: counts,
|
rows: counts,
|
||||||
nonEmptyPassthroughRows,
|
nonEmptyPassthroughRows,
|
||||||
roundTripByteIdentical: true,
|
roundTripByteIdentical: true,
|
||||||
|
previewObjectCount: preview.objectCount,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { ExternalCsvConfigurationReader } from "../../domain/ports/external-csv-configuration.reader.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
||||||
|
export class ExternalCsvConfigurationRepository
|
||||||
|
implements ExternalCsvConfigurationReader
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
getByProject(projectId: string) {
|
||||||
|
const project = this.database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
return { projectExists: false, configuration: null };
|
||||||
|
}
|
||||||
|
const configuration = this.database
|
||||||
|
.select()
|
||||||
|
.from(externalCsvConfigurations)
|
||||||
|
.where(eq(externalCsvConfigurations.projectId, projectId))
|
||||||
|
.get() ?? null;
|
||||||
|
return { projectExists: true, configuration };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ExternalCsvConfigurationSnapshot } from "../models/external-csv-configuration-project-command.model.js";
|
||||||
|
|
||||||
|
export interface ExternalCsvConfigurationReadResult {
|
||||||
|
projectExists: boolean;
|
||||||
|
configuration: ExternalCsvConfigurationSnapshot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalCsvConfigurationReader {
|
||||||
|
getByProject(projectId: string): ExternalCsvConfigurationReadResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { ExternalCsvConfiguration } from "../csv/external-csv-contracts.js";
|
||||||
|
import { parseExternalCsv } from "../csv/external-csv-transport.js";
|
||||||
|
|
||||||
|
export interface ExternalCsvPreviewObject {
|
||||||
|
rowNumber: number;
|
||||||
|
ifcGuid: string;
|
||||||
|
roomNumber: string;
|
||||||
|
roomName: string;
|
||||||
|
familyAndType: string;
|
||||||
|
selectionMarker: string;
|
||||||
|
circuitIdentifier: string;
|
||||||
|
power: string;
|
||||||
|
quantity: string | null;
|
||||||
|
additionalSourceValues: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalCsvPreview {
|
||||||
|
fileName: string;
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
dialect: ReturnType<typeof parseExternalCsv>["dialect"];
|
||||||
|
headerRowNumber: number;
|
||||||
|
headerColumns: string[];
|
||||||
|
rowCount: number;
|
||||||
|
objectCount: number;
|
||||||
|
passthroughCount: number;
|
||||||
|
nonEmptyPassthroughCount: number;
|
||||||
|
suspectObjectCount: number;
|
||||||
|
objects: ExternalCsvPreviewObject[];
|
||||||
|
suspectRowNumbers: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalCsvPreview(input: {
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
configuration: ExternalCsvConfiguration;
|
||||||
|
}): ExternalCsvPreview {
|
||||||
|
const document = parseExternalCsv(input.bytes, input.configuration);
|
||||||
|
const header = document.rows[document.headerRowIndex];
|
||||||
|
const headerIndex = new Map(
|
||||||
|
header.cells.map((cell, index) => [cell.value, index])
|
||||||
|
);
|
||||||
|
const valueAt = (row: (typeof document.rows)[number], column: string) =>
|
||||||
|
row.cells[headerIndex.get(column)!]?.value ?? "";
|
||||||
|
const objects = document.rows
|
||||||
|
.filter((row) => row.classification === "object")
|
||||||
|
.map((row): ExternalCsvPreviewObject => ({
|
||||||
|
rowNumber: row.index + 1,
|
||||||
|
ifcGuid: valueAt(row, input.configuration.columns.ifcGuid),
|
||||||
|
roomNumber: valueAt(row, input.configuration.columns.roomNumber),
|
||||||
|
roomName: valueAt(row, input.configuration.columns.roomName),
|
||||||
|
familyAndType: valueAt(row, input.configuration.columns.familyAndType),
|
||||||
|
selectionMarker: valueAt(row, input.configuration.columns.selectionMarker),
|
||||||
|
circuitIdentifier: valueAt(row, input.configuration.columns.circuitIdentifier),
|
||||||
|
power: valueAt(row, input.configuration.columns.power),
|
||||||
|
quantity:
|
||||||
|
input.configuration.columns.quantity === null
|
||||||
|
? null
|
||||||
|
: valueAt(row, input.configuration.columns.quantity),
|
||||||
|
additionalSourceValues: Object.fromEntries(
|
||||||
|
input.configuration.additionalSourceMappings.map((mapping) => [
|
||||||
|
mapping.targetField,
|
||||||
|
valueAt(row, mapping.sourceColumn),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const passthroughRows = document.rows.filter(
|
||||||
|
(row) => row.classification === "passthrough"
|
||||||
|
);
|
||||||
|
const suspectRows = document.rows.filter(
|
||||||
|
(row) => row.classification === "suspect-object"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
fileName: input.fileName,
|
||||||
|
byteLength: input.bytes.byteLength,
|
||||||
|
sha256: createHash("sha256").update(input.bytes).digest("hex"),
|
||||||
|
dialect: document.dialect,
|
||||||
|
headerRowNumber: document.headerRowIndex + 1,
|
||||||
|
headerColumns: header.cells.map((cell) => cell.value),
|
||||||
|
rowCount: document.rows.length,
|
||||||
|
objectCount: objects.length,
|
||||||
|
passthroughCount: passthroughRows.length,
|
||||||
|
nonEmptyPassthroughCount: passthroughRows.filter((row) =>
|
||||||
|
row.cells.some((cell) => cell.value !== "")
|
||||||
|
).length,
|
||||||
|
suspectObjectCount: suspectRows.length,
|
||||||
|
objects,
|
||||||
|
suspectRowNumbers: suspectRows.map((row) => row.index + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { GlobalDeviceRepository } from "../../db/repositories/global-device.repo
|
|||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
|
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||||
|
|
||||||
export const circuitDeviceRowRepository =
|
export const circuitDeviceRowRepository =
|
||||||
new CircuitDeviceRowRepository(db);
|
new CircuitDeviceRowRepository(db);
|
||||||
@@ -28,3 +29,5 @@ export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
|||||||
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
||||||
export const projectRepository = new ProjectRepository(db);
|
export const projectRepository = new ProjectRepository(db);
|
||||||
export const roomRepository = new RoomRepository(db);
|
export const roomRepository = new RoomRepository(db);
|
||||||
|
export const externalCsvConfigurationRepository =
|
||||||
|
new ExternalCsvConfigurationRepository(db);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
|
||||||
|
import { ExternalCsvParseError } from "../../external-model/csv/external-csv-transport.js";
|
||||||
|
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
|
||||||
|
import { createExternalCsvConfigurationUpdateProjectCommand } from "../../domain/models/external-csv-configuration-project-command.model.js";
|
||||||
|
import {
|
||||||
|
previewExternalCsvSchema,
|
||||||
|
updateExternalCsvConfigurationSchema,
|
||||||
|
} from "../../shared/validation/external-csv.schemas.js";
|
||||||
|
import { externalCsvConfigurationRepository } from "../composition/application-repositories.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
|
||||||
|
export function getExternalCsvConfiguration(req: Request, res: Response) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) return;
|
||||||
|
const result = externalCsvConfigurationRepository.getByProject(projectId);
|
||||||
|
if (!result.projectExists) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
return res.json({ configuration: result.configuration });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateExternalCsvConfiguration(req: Request, res: Response) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) return;
|
||||||
|
const parsed = updateExternalCsvConfigurationSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
assertExternalCsvConfiguration(parsed.data.configuration);
|
||||||
|
const current = externalCsvConfigurationRepository.getByProject(projectId);
|
||||||
|
if (!current.projectExists) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
const target = {
|
||||||
|
id: current.configuration?.id ?? crypto.randomUUID(),
|
||||||
|
projectId,
|
||||||
|
configurationVersion:
|
||||||
|
(current.configuration?.configurationVersion ?? 0) + 1,
|
||||||
|
configuration: parsed.data.configuration,
|
||||||
|
};
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Revit-CSV-Konfiguration bearbeiten",
|
||||||
|
command: createExternalCsvConfigurationUpdateProjectCommand(
|
||||||
|
current.configuration,
|
||||||
|
target
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return res.json({ ...result, configuration: target });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewExternalCsv(req: Request, res: Response) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) return;
|
||||||
|
const parsed = previewExternalCsvSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
const stored = externalCsvConfigurationRepository.getByProject(projectId);
|
||||||
|
if (!stored.projectExists) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
if (!stored.configuration) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: "Für das Projekt ist noch keine Revit-CSV-Konfiguration gespeichert.",
|
||||||
|
code: "EXTERNAL_CSV_CONFIGURATION_REQUIRED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bytes = decodeBase64(parsed.data.contentBase64);
|
||||||
|
return res.json(
|
||||||
|
createExternalCsvPreview({
|
||||||
|
fileName: parsed.data.fileName,
|
||||||
|
bytes,
|
||||||
|
configuration: stored.configuration.configuration,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ExternalCsvParseError) {
|
||||||
|
return res.status(400).json({ error: error.message, code: error.code });
|
||||||
|
}
|
||||||
|
return res.status(400).json({
|
||||||
|
error: error instanceof Error ? error.message : "CSV-Vorschau fehlgeschlagen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(value: string) {
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) {
|
||||||
|
throw new Error("CSV-Inhalt ist nicht gültig Base64-kodiert.");
|
||||||
|
}
|
||||||
|
const bytes = Buffer.from(value, "base64");
|
||||||
|
if (bytes.toString("base64") !== value) {
|
||||||
|
throw new Error("CSV-Inhalt ist nicht kanonisch Base64-kodiert.");
|
||||||
|
}
|
||||||
|
if (bytes.length > 18 * 1024 * 1024) {
|
||||||
|
throw new Error("CSV-Datei überschreitet die maximale Größe von 18 MB.");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectId(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
res.status(400).json({ error: "Invalid projectId" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@ import {
|
|||||||
importProjectTransfer,
|
importProjectTransfer,
|
||||||
importProjectTransferAsNewProject,
|
importProjectTransferAsNewProject,
|
||||||
} from "../controllers/project-transfer.controller.js";
|
} from "../controllers/project-transfer.controller.js";
|
||||||
|
import {
|
||||||
|
getExternalCsvConfiguration,
|
||||||
|
previewExternalCsv,
|
||||||
|
updateExternalCsvConfiguration,
|
||||||
|
} from "../controllers/external-csv.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
@@ -44,6 +49,9 @@ projectRouter.post("/import", importProjectTransferAsNewProject);
|
|||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||||
|
projectRouter.get("/:projectId/external-csv/configuration", getExternalCsvConfiguration);
|
||||||
|
projectRouter.put("/:projectId/external-csv/configuration", updateExternalCsvConfiguration);
|
||||||
|
projectRouter.post("/:projectId/external-csv/preview", previewExternalCsv);
|
||||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const updateExternalCsvConfigurationSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
configuration: z.unknown(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const previewExternalCsvSchema = z
|
||||||
|
.object({
|
||||||
|
fileName: z.string().trim().min(1).max(255),
|
||||||
|
contentBase64: z.string().min(1).max(24_000_000),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
previewExternalCsvSchema,
|
||||||
|
updateExternalCsvConfigurationSchema,
|
||||||
|
} from "../src/shared/validation/external-csv.schemas.js";
|
||||||
|
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
|
||||||
|
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
|
describe("external CSV API contracts", () => {
|
||||||
|
it("accepts strict update and preview request envelopes", () => {
|
||||||
|
assert.equal(
|
||||||
|
updateExternalCsvConfigurationSchema.safeParse({
|
||||||
|
expectedRevision: 4,
|
||||||
|
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
previewExternalCsvSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
}).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects stale-shaped, oversized and unknown request fields", () => {
|
||||||
|
assert.equal(
|
||||||
|
updateExternalCsvConfigurationSchema.safeParse({
|
||||||
|
expectedRevision: -1,
|
||||||
|
configuration: {},
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
previewExternalCsvSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "YWJj",
|
||||||
|
apply: true,
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
previewExternalCsvSchema.safeParse({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
contentBase64: "a".repeat(24_000_001),
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type DatabaseContext,
|
type DatabaseContext,
|
||||||
} from "../src/db/database-context.js";
|
} from "../src/db/database-context.js";
|
||||||
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
|
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
|
||||||
|
import { ExternalCsvConfigurationRepository } from "../src/db/repositories/external-csv-configuration.repository.js";
|
||||||
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
||||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||||
@@ -169,6 +170,23 @@ describe("external CSV configuration project command", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("distinguishes projects without configuration from unknown projects", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const reader = new ExternalCsvConfigurationRepository(context.db);
|
||||||
|
assert.deepEqual(reader.getByProject("project-1"), {
|
||||||
|
projectExists: true,
|
||||||
|
configuration: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(reader.getByProject("missing"), {
|
||||||
|
projectExists: false,
|
||||||
|
configuration: null,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("restores and undoes the complete configuration through snapshot history", () => {
|
it("restores and undoes the complete configuration through snapshot history", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
||||||
|
import {
|
||||||
|
externalCsvTestConfiguration,
|
||||||
|
firstIfcGuid,
|
||||||
|
quotedRevitCsv,
|
||||||
|
utf8Bytes,
|
||||||
|
} from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
|
describe("external CSV preview", () => {
|
||||||
|
it("projects transport statistics and mapped object source values without writes", () => {
|
||||||
|
const configuration = structuredClone(externalCsvTestConfiguration);
|
||||||
|
configuration.additionalSourceMappings.push({
|
||||||
|
sourceColumn: "Raumname",
|
||||||
|
targetField: "sourceRoomLabel",
|
||||||
|
});
|
||||||
|
// The additional mapping intentionally needs a distinct source column in
|
||||||
|
// persisted configuration, so use a separately named synthetic header.
|
||||||
|
configuration.additionalSourceMappings[0].sourceColumn = "Zusatzwert";
|
||||||
|
const csv = quotedRevitCsv
|
||||||
|
.replace('"Anzahl";"IfcGUID"', '"Anzahl";"Zusatzwert";"IfcGUID"')
|
||||||
|
.replaceAll(';"2";"0Ab', ';"2";"A";"0Ab')
|
||||||
|
.replaceAll(';"1";"1Ab', ';"1";"B";"1Ab')
|
||||||
|
.replaceAll(';"1";""', ';"1";"C";""')
|
||||||
|
.replaceAll(';"";""\r\n', ';"";"";""\r\n');
|
||||||
|
|
||||||
|
const preview = createExternalCsvPreview({
|
||||||
|
fileName: "export.csv",
|
||||||
|
bytes: utf8Bytes(csv, true),
|
||||||
|
configuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(preview.fileName, "export.csv");
|
||||||
|
assert.equal(preview.headerRowNumber, 2);
|
||||||
|
assert.equal(preview.rowCount, 7);
|
||||||
|
assert.equal(preview.objectCount, 2);
|
||||||
|
assert.equal(preview.passthroughCount, 2);
|
||||||
|
assert.equal(preview.nonEmptyPassthroughCount, 1);
|
||||||
|
assert.equal(preview.suspectObjectCount, 1);
|
||||||
|
assert.deepEqual(preview.suspectRowNumbers, [6]);
|
||||||
|
assert.match(preview.sha256, /^[a-f0-9]{64}$/);
|
||||||
|
assert.deepEqual(preview.objects[0], {
|
||||||
|
rowNumber: 4,
|
||||||
|
ifcGuid: firstIfcGuid,
|
||||||
|
roomNumber: "01/101",
|
||||||
|
roomName: "Technik",
|
||||||
|
familyAndType: "Steckdose: Doppelsteckdose",
|
||||||
|
selectionMarker: "Arbeitsplatz",
|
||||||
|
circuitIdentifier: "UV_AV_01-2F1",
|
||||||
|
power: "120,5",
|
||||||
|
quantity: "2",
|
||||||
|
additionalSourceValues: { sourceRoomLabel: "A" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user