forked from jappel/leistungsbilanz-ts
Add Linear XLSX follow-up import
This commit is contained in:
parent
1a4c8cad8c
commit
2581beee3c
28 changed files with 1114 additions and 31 deletions
|
|
@ -0,0 +1,84 @@
|
|||
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import type { ExternalModelConfigurationSnapshot } from "./external-model-configuration-project-command.model.js";
|
||||
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||
|
||||
export const externalFollowUpImportCommandType = "external-import.apply-follow-up" as const;
|
||||
export const externalFollowUpImportCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ExternalFollowUpImportPayload {
|
||||
expectedConfiguration: ExternalModelConfigurationSnapshot;
|
||||
targetConfiguration: ExternalModelConfigurationSnapshot;
|
||||
expectedState: ExternalModelStateSnapshot;
|
||||
targetState: ExternalModelStateSnapshot;
|
||||
rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>;
|
||||
}
|
||||
|
||||
export interface ExternalFollowUpImportProjectCommand extends SerializedProjectCommand<ExternalFollowUpImportPayload> {
|
||||
schemaVersion: typeof externalFollowUpImportCommandSchemaVersion;
|
||||
type: typeof externalFollowUpImportCommandType;
|
||||
}
|
||||
|
||||
export function createExternalFollowUpImportProjectCommand(payload: ExternalFollowUpImportPayload): ExternalFollowUpImportProjectCommand {
|
||||
const command: ExternalFollowUpImportProjectCommand = { schemaVersion: 1, type: externalFollowUpImportCommandType, payload: { ...payload, rows: payload.rows.map(({ expected, target }) => ({ expected: normalizeRow(expected), target: normalizeRow(target) })) } };
|
||||
assertExternalFollowUpImportProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function invertExternalFollowUpImportProjectCommand(command: ExternalFollowUpImportProjectCommand) {
|
||||
return createExternalFollowUpImportProjectCommand({
|
||||
expectedConfiguration: command.payload.targetConfiguration,
|
||||
targetConfiguration: command.payload.expectedConfiguration,
|
||||
expectedState: command.payload.targetState,
|
||||
targetState: command.payload.expectedState,
|
||||
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertExternalFollowUpImportProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalFollowUpImportProjectCommand {
|
||||
if (command.schemaVersion !== 1 || command.type !== externalFollowUpImportCommandType || !isRecord(command.payload)) throw new Error("Nicht unterstützter Revit-Folgeimport-Command.");
|
||||
if (Object.keys(command.payload).sort().join(",") !== "expectedConfiguration,expectedState,rows,targetConfiguration,targetState" || !Array.isArray(command.payload.rows)) throw new Error("Der Revit-Folgeimport-Command ist unvollständig.");
|
||||
const expectedConfiguration = parseConfiguration(command.payload.expectedConfiguration);
|
||||
const targetConfiguration = parseConfiguration(command.payload.targetConfiguration);
|
||||
if (expectedConfiguration.id !== targetConfiguration.id || expectedConfiguration.projectId !== targetConfiguration.projectId) throw new Error("Der Folgeimport darf die Konfigurationsidentität nicht ändern.");
|
||||
const expectedState = parseExternalModelStateSnapshot(command.payload.expectedState);
|
||||
const targetState = parseExternalModelStateSnapshot(command.payload.targetState);
|
||||
if (!expectedState.source || !targetState.source || expectedState.source.id !== targetState.source.id || expectedState.source.projectId !== targetState.source.projectId) throw new Error("Der Folgeimport benötigt dieselbe Revit-Quelle auf beiden Seiten.");
|
||||
const batchDelta = targetState.importBatches.length - expectedState.importBatches.length;
|
||||
if (Math.abs(batchDelta) !== 1) throw new Error("Der Folgeimport muss genau einen Importbatch hinzufügen oder entfernen.");
|
||||
const larger = batchDelta > 0 ? targetState : expectedState;
|
||||
const smaller = batchDelta > 0 ? expectedState : targetState;
|
||||
const largerConfiguration = batchDelta > 0 ? targetConfiguration : expectedConfiguration;
|
||||
const addedBatch = larger.importBatches.at(-1)!;
|
||||
if (addedBatch.importKind !== "follow_up" || addedBatch.configurationSnapshot === null || addedBatch.originalContentBase64 === null || addedBatch.document === null) throw new Error("Der Folgeimport-Batch ist unvollständig.");
|
||||
if (canonical(addedBatch.configurationSnapshot) !== canonical(largerConfiguration.configuration)) throw new Error("Die Konfigurationskopie des Folgeimports stimmt nicht mit dem Ziel überein.");
|
||||
if (larger.importBatches.length !== smaller.importBatches.length + 1 || smaller.importBatches.some((batch, index) => !sameBatchMetadata(batch, larger.importBatches[index]))) throw new Error("Der Folgeimport darf bestehende Importbatch-Metadaten nicht verändern.");
|
||||
const largerObjectById = new Map(larger.objects.map((object) => [object.id, object]));
|
||||
for (const object of smaller.objects) {
|
||||
const next = largerObjectById.get(object.id);
|
||||
if (!next || next.worksharingId !== object.worksharingId || next.sourceId !== object.sourceId || next.circuitDeviceRowId !== object.circuitDeviceRowId || next.linkedProjectDeviceId !== object.linkedProjectDeviceId) throw new Error("Der Folgeimport darf bestehende Objektidentitäten oder Links nicht verändern.");
|
||||
}
|
||||
const smallerIds = new Set(smaller.objects.map((object) => object.id));
|
||||
if (larger.objects.some((object) => !smallerIds.has(object.id) && (object.circuitDeviceRowId !== null || object.presenceStatus !== "present" || object.lastSeenImportBatchId !== addedBatch.id))) throw new Error("Neue Folgeimportobjekte müssen unverknüpft und vorhanden sein.");
|
||||
if (larger.objects.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === addedBatch.id).length !== addedBatch.objectCount) throw new Error("Die Objektzahl des Folgeimportbatches ist inkonsistent.");
|
||||
const largerMappings = new Map(larger.roomMappings.map((mapping) => [mapping.id, mapping]));
|
||||
if (smaller.roomMappings.some((mapping) => canonical(mapping) !== canonical(largerMappings.get(mapping.id)))) throw new Error("Der Folgeimport darf bestehende Raumzuordnungen nicht verändern.");
|
||||
const rowIds = new Set<string>();
|
||||
for (const transition of command.payload.rows) {
|
||||
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang im Folgeimport.");
|
||||
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
|
||||
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität im Folgeimport.");
|
||||
const expectedRest = { ...expected, quantity: 0 }; const targetRest = { ...target, quantity: 0 };
|
||||
if (canonical(expectedRest) !== canonical(targetRest) || expected.quantity === target.quantity) throw new Error("Der Folgeimport darf an Gerätezeilen nur die Gesamtmenge ändern.");
|
||||
rowIds.add(expected.id);
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfiguration(value: unknown): ExternalModelConfigurationSnapshot { if (!isRecord(value) || typeof value.id !== "string" || typeof value.projectId !== "string") throw new Error("Ungültige Revit-Konfiguration im Folgeimport."); assertExternalModelConfiguration(value.configuration); return value as unknown as ExternalModelConfigurationSnapshot; }
|
||||
function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot { return { ...row, manualQuantity: row.manualQuantity ?? row.quantity }; }
|
||||
function parseRow(value: unknown): CircuitDeviceRowSnapshot { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
|
||||
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
||||
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }
|
||||
function sameBatchMetadata(left: ExternalModelStateSnapshot["importBatches"][number], right: ExternalModelStateSnapshot["importBatches"][number] | undefined) { if (!right) return false; return left.id === right.id && left.projectId === right.projectId && left.sourceId === right.sourceId && left.importKind === right.importKind && left.importedAtIso === right.importedAtIso && left.fileName === right.fileName && left.sha256 === right.sha256 && left.objectCount === right.objectCount; }
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||
import { assertCircuitDeviceRowInsertProjectCommand, circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot } from "./circuit-device-row-structure-project-command.model.js";
|
||||
|
||||
export const externalObjectDeleteMissingCommandType = "external-model-object.delete-missing" as const;
|
||||
type ObjectTransition = { expected: ExternalModelObjectSnapshot | null; target: ExternalModelObjectSnapshot | null };
|
||||
export interface ExternalObjectDeleteMissingProjectCommand extends SerializedProjectCommand<{ objects: ObjectTransition[]; rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }> }> {
|
||||
schemaVersion: 1;
|
||||
type: typeof externalObjectDeleteMissingCommandType;
|
||||
}
|
||||
|
||||
export function createExternalObjectDeleteMissingProjectCommand(payload: ExternalObjectDeleteMissingProjectCommand["payload"]): ExternalObjectDeleteMissingProjectCommand {
|
||||
const command: ExternalObjectDeleteMissingProjectCommand = { schemaVersion: 1, type: externalObjectDeleteMissingCommandType, payload };
|
||||
assertExternalObjectDeleteMissingProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function invertExternalObjectDeleteMissingProjectCommand(command: ExternalObjectDeleteMissingProjectCommand) {
|
||||
return createExternalObjectDeleteMissingProjectCommand({
|
||||
objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertExternalObjectDeleteMissingProjectCommand(command: SerializedProjectCommand<unknown>): asserts command is ExternalObjectDeleteMissingProjectCommand {
|
||||
if (command.schemaVersion !== 1 || command.type !== externalObjectDeleteMissingCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || !Array.isArray(command.payload.rows) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Command zum Löschen fehlender Revit-Objekte.");
|
||||
const objectIds = new Set<string>();
|
||||
let deletion: boolean | null = null;
|
||||
for (const transition of command.payload.objects) {
|
||||
if (!isRecord(transition) || (transition.expected === null) === (transition.target === null)) throw new Error("Ungültiger Löschübergang für ein Revit-Objekt.");
|
||||
const object = parseObject(transition.expected ?? transition.target);
|
||||
if (object.presenceStatus !== "missing" || objectIds.has(object.id)) throw new Error("Nur eindeutig fehlende Revit-Objekte dürfen gelöscht werden.");
|
||||
const nextDeletion = transition.target === null;
|
||||
if (deletion !== null && deletion !== nextDeletion) throw new Error("Löschen und Wiederherstellen dürfen nicht gemischt werden.");
|
||||
deletion = nextDeletion;
|
||||
objectIds.add(object.id);
|
||||
}
|
||||
const affectedRowIds = new Set(command.payload.objects.flatMap(({ expected, target }) => (expected ?? target)!.circuitDeviceRowId ? [(expected ?? target)!.circuitDeviceRowId!] : []));
|
||||
const rowIds = new Set<string>();
|
||||
for (const transition of command.payload.rows) {
|
||||
if (!isRecord(transition)) throw new Error("Ungültiger Gerätezeilenübergang.");
|
||||
const expected = parseRow(transition.expected); const target = parseRow(transition.target);
|
||||
if (expected.id !== target.id || rowIds.has(expected.id) || expected.manualQuantity !== target.manualQuantity) throw new Error("Ungültige Gerätezeilenidentität.");
|
||||
if (canonical({ ...expected, quantity: 0 }) !== canonical({ ...target, quantity: 0 }) || expected.quantity === target.quantity) throw new Error("Beim Löschen fehlender Objekte darf nur die Gesamtmenge geändert werden.");
|
||||
rowIds.add(expected.id);
|
||||
}
|
||||
if (canonical([...affectedRowIds].sort()) !== canonical([...rowIds].sort())) throw new Error("Die Mengenübergänge der betroffenen Gerätezeilen sind unvollständig.");
|
||||
}
|
||||
|
||||
function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; }
|
||||
function parseRow(value: unknown) { const envelope = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, payload: { row: value } }; assertCircuitDeviceRowInsertProjectCommand(envelope); return envelope.payload.row; }
|
||||
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
||||
function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; return JSON.stringify(value); }
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ExternalFollowUpImportProjectCommand } from "../models/external-follow-up-import-project-command.model.js";
|
||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||
|
||||
export interface ExternalFollowUpImportProjectCommandStore {
|
||||
execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalFollowUpImportProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalFollowUpImportProjectCommand };
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { ExternalObjectDeleteMissingProjectCommand } from "../models/external-object-delete-missing-project-command.model.js";
|
||||
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||
export interface ExternalObjectDeleteMissingProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectDeleteMissingProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectDeleteMissingProjectCommand }; }
|
||||
|
|
@ -182,8 +182,12 @@ import {
|
|||
externalInitialImportCommandType,
|
||||
} from "../models/external-initial-import-project-command.model.js";
|
||||
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
|
||||
import { assertExternalFollowUpImportProjectCommand, externalFollowUpImportCommandType } from "../models/external-follow-up-import-project-command.model.js";
|
||||
import type { ExternalFollowUpImportProjectCommandStore } from "../ports/external-follow-up-import-project-command.store.js";
|
||||
import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js";
|
||||
import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js";
|
||||
import { assertExternalObjectDeleteMissingProjectCommand, externalObjectDeleteMissingCommandType } from "../models/external-object-delete-missing-project-command.model.js";
|
||||
import type { ExternalObjectDeleteMissingProjectCommandStore } from "../ports/external-object-delete-missing-project-command.store.js";
|
||||
import {
|
||||
assertExternalObjectRowAssignmentProjectCommand,
|
||||
externalObjectRowAssignmentCommandType,
|
||||
|
|
@ -251,7 +255,9 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore,
|
||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore
|
||||
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore,
|
||||
private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore,
|
||||
private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore
|
||||
) {}
|
||||
|
||||
executeUser(
|
||||
|
|
@ -650,11 +656,21 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case externalFollowUpImportCommandType: {
|
||||
assertExternalFollowUpImportProjectCommand(input.command);
|
||||
if (!this.externalFollowUpImportStore) throw new Error("External follow-up import store is not available.");
|
||||
return this.externalFollowUpImportStore.execute({ ...input, command: input.command }).revision;
|
||||
}
|
||||
case externalObjectPlanningUpdateCommandType: {
|
||||
assertExternalObjectPlanningUpdateProjectCommand(input.command);
|
||||
if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available.");
|
||||
return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision;
|
||||
}
|
||||
case externalObjectDeleteMissingCommandType: {
|
||||
assertExternalObjectDeleteMissingProjectCommand(input.command);
|
||||
if (!this.externalObjectDeleteMissingStore) throw new Error("External missing-object delete store is not available.");
|
||||
return this.externalObjectDeleteMissingStore.execute({ ...input, command: input.command }).revision;
|
||||
}
|
||||
case externalObjectRowAssignmentCommandType: {
|
||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||
if (!this.externalObjectRowAssignmentStore) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue