leistungsbilanz-ts/src/domain/models/project-transfer.model.ts

373 lines
12 KiB
TypeScript

import {
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectTransferFormat = "leistungsbilanz.project" as const;
export const projectTransferFormatVersion = 2 as const;
const legacyProjectTransferFormatVersion = 1 as const;
export interface ProjectTransferEnvelope {
format: typeof projectTransferFormat;
formatVersion: typeof projectTransferFormatVersion;
exportedAtIso: string;
payloadSha256: string;
projectState: ProjectStateSnapshot;
estimateBalances: EstimateBalanceSnapshot[];
}
export function createProjectTransferEnvelope(
exportedAtIso: string,
payloadSha256: string,
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferEnvelope {
return parseProjectTransferEnvelope({
format: projectTransferFormat,
formatVersion: projectTransferFormatVersion,
exportedAtIso,
payloadSha256,
projectState,
estimateBalances,
});
}
export function parseProjectTransferEnvelope(
value: unknown
): ProjectTransferEnvelope {
if (!isPlainObject(value)) {
throw new Error("Project transfer file must be an object.");
}
if (
value.format !== projectTransferFormat ||
(value.formatVersion !== legacyProjectTransferFormatVersion &&
value.formatVersion !== projectTransferFormatVersion)
) {
throw new Error("Project transfer format is not supported.");
}
if (
typeof value.exportedAtIso !== "string" ||
!Number.isFinite(Date.parse(value.exportedAtIso))
) {
throw new Error("Project transfer timestamp is invalid.");
}
if (
typeof value.payloadSha256 !== "string" ||
!/^[a-f0-9]{64}$/.test(value.payloadSha256)
) {
throw new Error("Project transfer checksum is invalid.");
}
const projectState = parseProjectStateSnapshot(value.projectState);
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project transfer snapshot version is not supported.");
}
const estimateBalances =
value.formatVersion === legacyProjectTransferFormatVersion
? []
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
const expectedFieldCount =
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
if (Object.keys(value).length !== expectedFieldCount) {
throw new Error("Project transfer file contains unknown fields.");
}
return {
format: projectTransferFormat,
formatVersion: projectTransferFormatVersion,
exportedAtIso: value.exportedAtIso,
payloadSha256: value.payloadSha256,
projectState,
estimateBalances,
};
}
export function serializeProjectTransferPayload(
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
) {
const parsedState = parseProjectStateSnapshot(projectState);
return JSON.stringify({
projectState: parsedState,
estimateBalances: parseTransferEstimateBalances(
estimateBalances,
parsedState.project.id
),
});
}
export function remapEstimateBalances(
source: EstimateBalanceSnapshot[],
targetProjectId: string,
createId: () => string
): EstimateBalanceSnapshot[] {
return source.map((balance) =>
parseEstimateBalanceSnapshot({
...balance,
id: createId(),
projectId: targetProjectId,
groups: balance.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
})),
})
);
}
export function remapProjectState(
source: ProjectStateSnapshot,
targetProjectId: string,
createId: () => string,
name: string = source.project.name
): ProjectStateSnapshot {
const boardIds = idMap(source.distributionBoards, createId);
const listIds = idMap(source.circuitLists, createId);
const sectionIds = idMap(source.circuitSections, createId);
const circuitIds = idMap(source.circuits, createId);
const componentIds = idMap(
source.distributionBoardComponents,
createId
);
const deviceIds = idMap(source.projectDevices, createId);
const buildingIds = idMap(source.buildings, createId);
const floorIds = idMap(source.floors, createId);
const roomIds = idMap(source.rooms, createId);
const rowIds = new Map(
source.circuits.flatMap((circuit) =>
circuit.deviceRows.map((row) => [row.id, createId()] as const)
)
);
const externalSourceId = source.externalModel.source ? createId() : null;
const externalBatchIds = idMap(source.externalModel.importBatches, createId);
const externalRoomMappingIds = idMap(
source.externalModel.roomMappings,
createId
);
const externalObjectIds = idMap(source.externalModel.objects, createId);
return parseProjectStateSnapshot({
...source,
project: { ...source.project, id: targetProjectId, name },
externalCsvConfiguration:
source.externalCsvConfiguration === null
? null
: {
...source.externalCsvConfiguration,
id: createId(),
projectId: targetProjectId,
},
externalModel: {
source: source.externalModel.source === null
? null
: {
...source.externalModel.source,
id: externalSourceId!,
projectId: targetProjectId,
},
importBatches: source.externalModel.importBatches.map((batch) => ({
...batch,
id: requiredId(externalBatchIds, batch.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
})),
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
...mapping,
id: requiredId(externalRoomMappingIds, mapping.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
roomId:
mapping.roomId === null
? null
: requiredId(roomIds, mapping.roomId),
defaultDistributionBoardId:
mapping.defaultDistributionBoardId === null
? null
: requiredId(boardIds, mapping.defaultDistributionBoardId),
})),
objects: source.externalModel.objects.map((object) => ({
...object,
id: requiredId(externalObjectIds, object.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
lastSeenImportBatchId: requiredId(
externalBatchIds,
object.lastSeenImportBatchId
),
lastAcceptedImportBatchId: requiredId(
externalBatchIds,
object.lastAcceptedImportBatchId
),
externalRoomMappingId:
object.externalRoomMappingId === null
? null
: requiredId(
externalRoomMappingIds,
object.externalRoomMappingId
),
distributionBoardId:
object.distributionBoardId === null
? null
: requiredId(boardIds, object.distributionBoardId),
linkedProjectDeviceId:
object.linkedProjectDeviceId === null
? null
: requiredId(deviceIds, object.linkedProjectDeviceId),
circuitDeviceRowId:
object.circuitDeviceRowId === null
? null
: requiredId(rowIds, object.circuitDeviceRowId),
})),
},
distributionBoards: source.distributionBoards.map((board) => ({
...board,
id: requiredId(boardIds, board.id),
projectId: targetProjectId,
feedingDistributionBoardId:
board.feedingDistributionBoardId === null
? null
: requiredId(boardIds, board.feedingDistributionBoardId),
feedingCircuitId:
board.feedingCircuitId === null
? null
: requiredId(circuitIds, board.feedingCircuitId),
floorId:
board.floorId === null
? null
: requiredId(floorIds, board.floorId),
})),
circuitLists: source.circuitLists.map((list) => ({
...list,
id: requiredId(listIds, list.id),
projectId: targetProjectId,
distributionBoardId: requiredId(boardIds, list.distributionBoardId),
})),
circuitSections: source.circuitSections.map((section) => ({
...section,
id: requiredId(sectionIds, section.id),
circuitListId: requiredId(listIds, section.circuitListId),
})),
distributionBoardComponents:
source.distributionBoardComponents.map((component) => ({
...component,
id: requiredId(componentIds, component.id),
circuitListId: requiredId(
listIds,
component.circuitListId
),
sectionId:
component.sectionId === null
? null
: requiredId(sectionIds, component.sectionId),
})),
circuitProtectionDevices: source.circuitProtectionDevices.map(
(protection) => ({
...protection,
circuitId: requiredId(circuitIds, protection.circuitId),
})
),
distributionBoardComponentProtectionDevices:
source.distributionBoardComponentProtectionDevices.map(
(protection) => ({
...protection,
componentId: requiredId(
componentIds,
protection.componentId
),
})
),
circuits: source.circuits.map((circuit) => ({
...circuit,
id: requiredId(circuitIds, circuit.id),
circuitListId: requiredId(listIds, circuit.circuitListId),
sectionId: requiredId(sectionIds, circuit.sectionId),
deviceRows: circuit.deviceRows.map((row) => ({
...row,
id: requiredId(rowIds, row.id),
circuitId: requiredId(circuitIds, circuit.id),
linkedProjectDeviceId:
row.linkedProjectDeviceId === null
? null
: requiredId(deviceIds, row.linkedProjectDeviceId),
roomId:
row.roomId === null ? null : requiredId(roomIds, row.roomId),
})),
})),
projectDevices: source.projectDevices.map((device) => ({
...device,
id: requiredId(deviceIds, device.id),
projectId: targetProjectId,
})),
buildings: source.buildings.map((building) => ({
...building,
id: requiredId(buildingIds, building.id),
projectId: targetProjectId,
})),
floors: source.floors.map((floor) => ({
...floor,
id: requiredId(floorIds, floor.id),
projectId: targetProjectId,
buildingId:
floor.buildingId === null
? null
: requiredId(buildingIds, floor.buildingId),
})),
rooms: source.rooms.map((room) => ({
...room,
id: requiredId(roomIds, room.id),
projectId: targetProjectId,
floorId:
room.floorId === null ? null : requiredId(floorIds, room.floorId),
})),
});
}
function idMap(
entries: ReadonlyArray<{ id: string }>,
createId: () => string
) {
return new Map(entries.map((entry) => [entry.id, createId()]));
}
function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
const targetId = ids.get(sourceId);
if (!targetId) {
throw new Error(`Project transfer reference "${sourceId}" is invalid.`);
}
return targetId;
}
function parseTransferEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
assertUniqueIds(
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
"estimate group"
);
assertUniqueIds(
balances.flatMap((balance) =>
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
),
"estimate row"
);
return balances;
}
function assertUniqueIds(ids: string[], label: string) {
if (new Set(ids).size !== ids.length) {
throw new Error(`Project transfer contains a duplicate ${label} id.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}