forked from jappel/leistungsbilanz-ts
373 lines
12 KiB
TypeScript
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);
|
|
}
|