Add Linear XLSX follow-up import

This commit is contained in:
Julian Appel 2026-09-30 20:40:40 +02:00
parent 1a4c8cad8c
commit 2581beee3c
28 changed files with 1114 additions and 31 deletions

View file

@ -24,7 +24,9 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
import { ExternalFollowUpImportProjectCommandRepository } from "../../db/repositories/external-follow-up-import-project-command.repository.js";
import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js";
import { ExternalObjectDeleteMissingProjectCommandRepository } from "../../db/repositories/external-object-delete-missing-project-command.repository.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
@ -76,8 +78,12 @@ export const externalModelConfigurationProjectCommandStore =
new ExternalModelConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db);
export const externalFollowUpImportProjectCommandStore =
new ExternalFollowUpImportProjectCommandRepository(db);
export const externalObjectPlanningProjectCommandStore =
new ExternalObjectPlanningProjectCommandRepository(db);
export const externalObjectDeleteMissingProjectCommandStore =
new ExternalObjectDeleteMissingProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore =
@ -115,5 +121,7 @@ export const projectCommandService = new ProjectCommandService(
externalObjectNewCircuitProjectCommandStore,
estimateBalanceProjectCommandStore,
externalInitialImportProjectCommandStore,
externalObjectPlanningProjectCommandStore
externalObjectPlanningProjectCommandStore,
externalFollowUpImportProjectCommandStore,
externalObjectDeleteMissingProjectCommandStore
);

View file

@ -0,0 +1,83 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import { createExternalFollowUpImportProjectCommand } from "../../domain/models/external-follow-up-import-project-command.model.js";
import { createExternalXlsxFollowUpImportPlan } from "../../external-model/application/external-xlsx-follow-up-import.js";
import { buildExternalXlsxFollowUpTarget } from "../../external-model/application/external-xlsx-follow-up-target.js";
import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js";
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { distributionBoardRepository, externalModelConfigurationRepository, externalModelStateRepository, floorRepository, projectDeviceRepository, projectRepository, roomRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function planExternalFollowUpImport(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const context = await loadContext(projectId, expectedRevision, req.body?.configuration);
const { plan } = await createExternalXlsxFollowUpImportPlan({
fileName: requiredString(req.body?.fileName, "Dateiname"), bytes: decodeBase64(req.body?.contentBase64), configuration: context.configuration,
catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues,
});
return res.json(plan);
} catch (error) { return res.status(400).json({ error: error instanceof Error ? error.message : "Folgeimport-Vorschau fehlgeschlagen." }); }
}
export async function applyExternalFollowUpImport(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const context = await loadContext(projectId, expectedRevision, req.body?.configuration);
const bytes = decodeBase64(req.body?.contentBase64);
const { plan, document } = await createExternalXlsxFollowUpImportPlan({
fileName: requiredString(req.body?.fileName, "Dateiname"), bytes, configuration: context.configuration,
catalogs: await loadCatalogs(projectId), currentState: context.state, currentReturnValuesByObjectId: context.returnValues,
});
if (plan.sha256 !== req.body?.expectedSha256) return res.status(409).json({ error: "Die XLSX-Datei stimmt nicht mehr mit der geprüften Vorschau überein.", code: "EXTERNAL_XLSX_FILE_CHANGED" });
const built = buildExternalXlsxFollowUpTarget({
projectId, expectedRevision, importedAtIso: new Date().toISOString(), configuration: context.configuration,
originalContentBase64: String(req.body.contentBase64), document, plan, currentState: context.state,
sourceChangeDecisions: requireArray(req.body?.sourceChangeDecisions, "Quelländerungsentscheidungen"),
roomDecisions: requireArray(req.body?.roomDecisions, "Raumentscheidungen"),
familyProjectDeviceDecisions: requireArray(req.body?.familyProjectDeviceDecisions, "ProjectDevice-Entscheidungen"),
objectsWithoutRoomDefaultDistributionBoardId: nullableString(req.body?.objectsWithoutRoomDefaultDistributionBoardId, "Standardverteiler ohne Raum"),
rowsById: context.rowsById, createId: randomUUID,
});
const targetConfiguration = { ...context.configurationSnapshot, configuration: context.configuration };
const result = projectCommandService.executeUser({
projectId, expectedRevision, description: `Revit-Folgeimport ${plan.fileName}`,
command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: context.configurationSnapshot, targetConfiguration, expectedState: context.state, targetState: built.targetState, rows: built.rowTransitions }),
});
return res.status(201).json({ ...result, import: { importBatchId: built.targetState.importBatches.at(-1)!.id, objectCount: plan.objectCount, newObjectCount: plan.newObjectCount, changedObjectCount: plan.changedObjectCount, missingObjectCount: plan.missingObjectCount } });
} catch (error) { return respondWithProjectCommandError(error, res); }
}
async function loadContext(projectId: string, expectedRevision: number, configurationValue: unknown) {
const [project, treeResult] = await Promise.all([projectRepository.findById(projectId), buildProjectCircuitTrees(projectId)]);
if (!project || !treeResult) throw new Error("Projekt nicht gefunden.");
if (project.currentRevision !== expectedRevision || treeResult.currentRevision !== expectedRevision) throw new Error("Der Folgeimportplan ist wegen einer neueren Projektrevision ungültig.");
const stored = externalModelConfigurationRepository.getByProject(projectId);
if (!stored.configuration) throw new Error("Für das Projekt ist keine Revit-XLSX-Konfiguration gespeichert.");
assertExternalModelConfiguration(configurationValue);
const stateResult = externalModelStateRepository.getByProject(projectId);
if (!stateResult.state.source) throw new Error("Für den Folgeimport fehlt ein bestehender Revit-Import.");
const rowContexts = createRowContexts(treeResult);
const returnValues = new Map(stateResult.state.objects.map((object) => [object.id, projectExternalXlsxReturn({ object, row: object.circuitDeviceRowId ? rowContexts.get(object.circuitDeviceRowId)?.returnContext ?? null : null, circuitMode: configurationValue.circuitMode, singlePhaseVoltageV: project.singlePhaseVoltageV, threePhaseVoltageV: project.threePhaseVoltageV }).values]));
return { configuration: configurationValue, configurationSnapshot: stored.configuration, state: stateResult.state, returnValues, rowsById: new Map([...rowContexts].map(([id, value]) => [id, value.snapshot])) };
}
function createRowContexts(treeResult: NonNullable<Awaited<ReturnType<typeof buildProjectCircuitTrees>>>) {
const result = new Map<string, { returnContext: ExternalReturnRowContext; snapshot: CircuitDeviceRowSnapshot }>();
for (const tree of treeResult.trees) for (const section of tree.sections) for (const circuit of section.circuits) for (const row of circuit.deviceRows) result.set(row.id, {
returnContext: { rowId: row.id, displayName: row.displayName, powerPerUnitKw: row.powerPerUnit, cosPhi: row.cosPhi ?? null, phaseType: row.phaseType ?? null, category: section.category ?? null, circuitIdentifier: circuit.equipmentIdentifier, circuitVoltageV: circuit.voltage ?? null, distributionBoardName: tree.distributionBoardName },
snapshot: { id: row.id, circuitId: circuit.id, linkedProjectDeviceId: row.linkedProjectDeviceId ?? null, sortOrder: row.sortOrder, name: row.name, displayName: row.displayName, phaseType: row.phaseType ?? null, connectionKind: row.connectionKind ?? null, costGroup: row.costGroup ?? null, category: row.category ?? null, level: row.level ?? null, roomId: row.roomId ?? null, roomNumberSnapshot: row.roomNumberSnapshot ?? null, roomNameSnapshot: row.roomNameSnapshot ?? null, quantity: row.quantity, manualQuantity: row.manualQuantity, powerPerUnit: row.powerPerUnit, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi ?? null, remark: row.remark ?? null, overriddenFields: row.overriddenFields ?? null },
});
return result;
}
async function loadCatalogs(projectId: string) { const [floors, rooms, distributionBoards, projectDevices] = await Promise.all([floorRepository.listByProject(projectId), roomRepository.listByProject(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]); return { floors, rooms, distributionBoards, projectDevices }; }
function parseExpectedRevision(value: unknown) { if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision."); return value as number; }
function decodeBase64(value: unknown) { if (typeof value !== "string" || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) throw new Error("XLSX-Inhalt ist nicht gültig Base64-kodiert."); const bytes = Buffer.from(value, "base64"); if (bytes.toString("base64") !== value) throw new Error("XLSX-Inhalt ist nicht kanonisch Base64-kodiert."); return bytes; }
function requiredString(value: unknown, label: string) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} fehlt.`); return value.trim(); }
function nullableString(value: unknown, label: string) { if (value === null || value === undefined || value === "") return null; if (typeof value !== "string") throw new Error(`${label} ist ungültig.`); return value; }
function requireArray<T>(value: unknown, label: string): T[] { if (!Array.isArray(value)) throw new Error(`${label} fehlen.`); return value as T[]; }

View file

@ -13,7 +13,7 @@ export function getExternalInitialImportStatus(req: Request, res: Response) {
const result = externalModelStateRepository.getByProject(projectId);
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const batch = result.state.importBatches.at(-1) ?? null;
return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: result.state.objects.length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null });
return res.json({ imported: result.state.source !== null, sourceName: result.state.source?.name ?? null, objectCount: batch?.objectCount ?? result.state.objects.length, missingObjectCount: result.state.objects.filter((object) => object.presenceStatus === "missing").length, importedAtIso: batch?.importedAtIso ?? null, fileName: batch?.fileName ?? null });
}
export async function planExternalInitialImport(req: Request, res: Response) {

View file

@ -1,6 +1,7 @@
import type { Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js";
import { createExternalObjectDeleteMissingProjectCommand } from "../../domain/models/external-object-delete-missing-project-command.model.js";
import { createExternalObjectRowAssignmentProjectCommand } from "../../domain/models/external-object-row-assignment-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js";
@ -57,6 +58,39 @@ export function updateExternalObjects(req: Request, res: Response) {
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function deleteMissingExternalObjects(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
const objectIds = parseObjectIds(req.body?.objectIds);
if (req.body?.confirmed !== true) throw new Error("Das Löschen fehlender Revit-Objekte muss bestätigt werden.");
const { state, projectExists } = externalModelStateRepository.getByProject(projectId);
if (!projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
const byId = new Map(state.objects.map((object) => [object.id, object]));
const selected = objectIds.map((id) => {
const object = byId.get(id);
if (!object || object.presenceStatus !== "missing") throw new Error("Nur im letzten Import fehlende Revit-Objekte dürfen gelöscht werden.");
return object;
});
const treeResult = await buildProjectCircuitTrees(projectId);
const quantitiesByRowId = new Map<string, number>();
for (const object of selected) if (object.circuitDeviceRowId) quantitiesByRowId.set(object.circuitDeviceRowId, (quantitiesByRowId.get(object.circuitDeviceRowId) ?? 0) + object.planningValues.effectiveQuantity);
const rows = [...quantitiesByRowId].map(([rowId, removedQuantity]) => {
const location = findRow(treeResult, rowId);
if (!location) throw new Error("Eine verknüpfte Gerätezeile wurde nicht gefunden.");
const expected = toRowSnapshot(location.row, location.circuitId);
return { expected, target: { ...expected, quantity: expected.quantity - removedQuantity } };
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: `${selected.length} fehlende Revit-Objekte gelöscht`,
command: createExternalObjectDeleteMissingProjectCommand({ objects: selected.map((expected) => ({ expected, target: null })), rows }),
});
return res.json(result);
} catch (error) { return respondWithProjectCommandError(error, res); }
}
export async function assignExternalObjectsToRow(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? "");
try {

View file

@ -63,8 +63,9 @@ import {
getExternalInitialImportStatus,
planExternalInitialImport,
} from "../controllers/external-initial-import.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, deleteMissingExternalObjects, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
import { applyExternalFollowUpImport, planExternalFollowUpImport } from "../controllers/external-follow-up-import.controller.js";
export const projectRouter = Router();
@ -92,8 +93,11 @@ projectRouter.post("/:projectId/external-model/configuration/detect", detectExte
projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus);
projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport);
projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport);
projectRouter.post("/:projectId/external-model/follow-up-import/plan", planExternalFollowUpImport);
projectRouter.post("/:projectId/external-model/follow-up-import/apply", applyExternalFollowUpImport);
projectRouter.get("/:projectId/external-model/objects", listExternalObjects);
projectRouter.put("/:projectId/external-model/objects", updateExternalObjects);
projectRouter.post("/:projectId/external-model/objects/delete-missing", deleteMissingExternalObjects);
projectRouter.post("/:projectId/external-model/objects/assign-row", assignExternalObjectsToRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);