Add external object new circuit command
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||
|
||||
export interface ExternalObjectLinkTransition {
|
||||
expected: ExternalModelObjectSnapshot;
|
||||
target: ExternalModelObjectSnapshot;
|
||||
}
|
||||
|
||||
export function loadExpectedExternalObjectTransitions(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
transitions: ExternalObjectLinkTransition[],
|
||||
changedMessage: string
|
||||
) {
|
||||
const byId = new Map(
|
||||
transitions.map((transition) => [transition.expected.id, transition])
|
||||
);
|
||||
const current = database.select().from(externalModelObjects)
|
||||
.where(inArray(externalModelObjects.id, [...byId.keys()])).all();
|
||||
if (current.length !== byId.size) {
|
||||
throw new Error("One or more external objects no longer exist.");
|
||||
}
|
||||
for (const object of current) {
|
||||
if (
|
||||
object.projectId !== projectId ||
|
||||
!snapshotsEqual(toExternalObjectSnapshot(object), byId.get(object.id)!.expected)
|
||||
) {
|
||||
throw new Error(changedMessage);
|
||||
}
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
|
||||
export function assertExternalObjectsCompatibleWithRow(input: {
|
||||
database: AppDatabase;
|
||||
projectId: string;
|
||||
row: CircuitDeviceRowSnapshot;
|
||||
distributionBoardId: string;
|
||||
category: CircuitGroupCategory;
|
||||
assignedObjects: ExternalModelObjectSnapshot[];
|
||||
allLinkedObjects: ExternalModelObjectSnapshot[];
|
||||
confirmedConflictObjectIds: ReadonlySet<string>;
|
||||
}) {
|
||||
const mappings = input.database.select().from(externalRoomMappings)
|
||||
.where(eq(externalRoomMappings.projectId, input.projectId)).all();
|
||||
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
||||
const markers = new Set(
|
||||
input.allLinkedObjects.map((object) =>
|
||||
object.acceptedSourceValues.selectionMarker.trim()
|
||||
)
|
||||
);
|
||||
if (markers.size > 1) {
|
||||
throw new Error("External objects with different selection markers require separate rows.");
|
||||
}
|
||||
for (const object of input.assignedObjects) {
|
||||
if (object.distributionBoardId !== input.distributionBoardId) {
|
||||
throw new Error("External object distribution does not match target circuit.");
|
||||
}
|
||||
if (object.planningValues.category !== input.category) {
|
||||
throw new Error("External object category does not match target circuit group.");
|
||||
}
|
||||
const roomId = object.externalRoomMappingId === null
|
||||
? null
|
||||
: roomIdByMappingId.get(object.externalRoomMappingId) ?? null;
|
||||
if (roomId !== input.row.roomId) {
|
||||
throw new Error("External object room does not match target device row.");
|
||||
}
|
||||
if (
|
||||
!matchesRowPlanningValues(input.row, object) &&
|
||||
!input.confirmedConflictObjectIds.has(object.id)
|
||||
) {
|
||||
throw new Error("External object planning values require explicit conflict confirmation.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyExternalObjectLinks(
|
||||
database: AppDatabase,
|
||||
transitions: ExternalObjectLinkTransition[],
|
||||
changedMessage: string
|
||||
) {
|
||||
for (const { expected, target } of transitions) {
|
||||
const updated = database.update(externalModelObjects)
|
||||
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
|
||||
.where(and(
|
||||
eq(externalModelObjects.id, expected.id),
|
||||
expected.circuitDeviceRowId === null
|
||||
? isNull(externalModelObjects.circuitDeviceRowId)
|
||||
: eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId)
|
||||
)).run();
|
||||
if (updated.changes !== 1) throw new Error(changedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function toExternalObjectSnapshot(
|
||||
object: typeof externalModelObjects.$inferSelect
|
||||
): ExternalModelObjectSnapshot {
|
||||
return { ...object };
|
||||
}
|
||||
|
||||
export function snapshotsEqual(left: unknown, right: unknown) {
|
||||
return canonicalJson(left) === canonicalJson(right);
|
||||
}
|
||||
|
||||
function matchesRowPlanningValues(
|
||||
row: CircuitDeviceRowSnapshot,
|
||||
object: ExternalModelObjectSnapshot
|
||||
) {
|
||||
const planning = object.planningValues;
|
||||
return (
|
||||
row.displayName === (planning.displayName ?? row.displayName) &&
|
||||
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
|
||||
row.category === planning.category &&
|
||||
row.connectionKind === planning.connectionKind &&
|
||||
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
||||
row.simultaneityFactor === planning.simultaneityFactor &&
|
||||
row.cosPhi === planning.cosPhi &&
|
||||
row.costGroup === planning.costGroup &&
|
||||
row.remark === planning.remark
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||
import {
|
||||
assertExternalObjectNewCircuitProjectCommand,
|
||||
externalObjectAssignToNewCircuitCommandType,
|
||||
externalObjectDeleteCreatedCircuitCommandType,
|
||||
invertExternalObjectNewCircuitProjectCommand,
|
||||
type ExternalObjectNewCircuitProjectCommand,
|
||||
} from "../../domain/models/external-object-new-circuit-project-command.model.js";
|
||||
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||
import type { CircuitSnapshot } from "../../domain/models/circuit-structure-project-command.model.js";
|
||||
import type { ExternalObjectNewCircuitProjectCommandStore } from "../../domain/ports/external-object-new-circuit-project-command.store.js";
|
||||
import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowInsertValues,
|
||||
toCircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import {
|
||||
applyExternalObjectLinks,
|
||||
assertExternalObjectsCompatibleWithRow,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
snapshotsEqual,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class ExternalObjectNewCircuitProjectCommandRepository
|
||||
implements ExternalObjectNewCircuitProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: Parameters<ExternalObjectNewCircuitProjectCommandStore["execute"]>[0]) {
|
||||
assertExternalObjectNewCircuitProjectCommand(input.command);
|
||||
if (
|
||||
input.source === "user" &&
|
||||
input.command.type === externalObjectDeleteCreatedCircuitCommandType
|
||||
) {
|
||||
throw new Error("Created external circuits may only be removed through project history.");
|
||||
}
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
if (input.command.type === externalObjectAssignToNewCircuitCommandType) {
|
||||
this.insert(tx, input.projectId, input.command);
|
||||
} else {
|
||||
this.remove(tx, input.projectId, input.command);
|
||||
}
|
||||
return invertExternalObjectNewCircuitProjectCommand(input.command);
|
||||
});
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewCircuitProjectCommand
|
||||
) {
|
||||
const circuit = command.payload.circuit;
|
||||
const row = circuit.deviceRows[0]!;
|
||||
const context = this.loadSectionContext(
|
||||
database,
|
||||
projectId,
|
||||
circuit.circuitListId,
|
||||
circuit.sectionId
|
||||
);
|
||||
const parsedIdentifier = parseGroupedEquipmentIdentifier(circuit.equipmentIdentifier);
|
||||
if (
|
||||
!parsedIdentifier ||
|
||||
parsedIdentifier.kind !== "circuit" ||
|
||||
parsedIdentifier.category !== context.category ||
|
||||
parsedIdentifier.groupNumber !== context.groupNumber
|
||||
) {
|
||||
throw new Error("External circuit equipment identifier does not match target group.");
|
||||
}
|
||||
if (row.category !== context.category || !isElectricalPhaseType(row.phaseType)) {
|
||||
throw new Error("External device row does not match target circuit group.");
|
||||
}
|
||||
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
||||
const voltage = resolveCircuitVoltage(database, projectId, circuit.sectionId, [row.phaseType]);
|
||||
if (circuit.voltage !== voltage) {
|
||||
throw new Error("External circuit voltage must match the project phase voltage.");
|
||||
}
|
||||
if (database.select({ id: circuits.id }).from(circuits)
|
||||
.where(eq(circuits.id, circuit.id)).get()) {
|
||||
throw new Error("External circuit id already exists.");
|
||||
}
|
||||
if (database.select({ id: circuits.id }).from(circuits).where(and(
|
||||
eq(circuits.circuitListId, circuit.circuitListId),
|
||||
eq(circuits.equipmentIdentifier, circuit.equipmentIdentifier)
|
||||
)).get()) {
|
||||
throw new Error("Duplicate equipmentIdentifier in circuit list.");
|
||||
}
|
||||
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, row.id)).get()) {
|
||||
throw new Error("External device-row id already exists.");
|
||||
}
|
||||
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
assertExternalObjectsCompatibleWithRow({
|
||||
database,
|
||||
projectId,
|
||||
row,
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category,
|
||||
assignedObjects: objects.map(({ target }) => target),
|
||||
allLinkedObjects: objects.map(({ target }) => target),
|
||||
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
|
||||
});
|
||||
assertCircuitDeviceRowQuantity({
|
||||
quantity: row.quantity,
|
||||
manualQuantity: 0,
|
||||
externalObjects: objects.map(({ target }) => ({
|
||||
effectiveQuantity: target.planningValues.effectiveQuantity,
|
||||
})),
|
||||
});
|
||||
|
||||
database.insert(circuits).values(toCircuitValues(circuit)).run();
|
||||
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
|
||||
database.insert(circuitProtectionDevices).values(circuit.protectionDevice!).run();
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-circuit assignment."
|
||||
);
|
||||
}
|
||||
|
||||
private remove(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewCircuitProjectCommand
|
||||
) {
|
||||
const expectedCircuit = command.payload.circuit;
|
||||
this.loadSectionContext(
|
||||
database,
|
||||
projectId,
|
||||
expectedCircuit.circuitListId,
|
||||
expectedCircuit.sectionId
|
||||
);
|
||||
const current = this.loadCircuitSnapshot(database, expectedCircuit.id);
|
||||
if (!current || !snapshotsEqual(current, expectedCircuit)) {
|
||||
throw new Error("Created external circuit changed before history removal.");
|
||||
}
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
const rowId = expectedCircuit.deviceRows[0]!.id;
|
||||
const linkedObjects = database.select({ id: externalModelObjects.id })
|
||||
.from(externalModelObjects)
|
||||
.where(eq(externalModelObjects.circuitDeviceRowId, rowId)).all();
|
||||
if (
|
||||
linkedObjects.length !== objects.length ||
|
||||
linkedObjects.some(({ id }) => !objects.some(({ expected }) => expected.id === id))
|
||||
) {
|
||||
throw new Error("Created external circuit has different object links.");
|
||||
}
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-circuit assignment."
|
||||
);
|
||||
const deleted = database.delete(circuits).where(and(
|
||||
eq(circuits.id, expectedCircuit.id),
|
||||
eq(circuits.circuitListId, expectedCircuit.circuitListId)
|
||||
)).run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Created external circuit changed during history removal.");
|
||||
}
|
||||
}
|
||||
|
||||
private loadSectionContext(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitListId: string,
|
||||
sectionId: string
|
||||
) {
|
||||
const context = database.select({
|
||||
projectId: circuitLists.projectId,
|
||||
distributionBoardId: circuitLists.distributionBoardId,
|
||||
category: circuitSections.category,
|
||||
groupNumber: circuitSections.groupNumber,
|
||||
}).from(circuitSections)
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuitSections.circuitListId))
|
||||
.where(and(
|
||||
eq(circuitSections.id, sectionId),
|
||||
eq(circuitSections.circuitListId, circuitListId)
|
||||
)).get();
|
||||
if (
|
||||
!context ||
|
||||
context.projectId !== projectId ||
|
||||
context.category === null ||
|
||||
context.groupNumber === null
|
||||
) {
|
||||
throw new Error("Target circuit group does not belong to the project.");
|
||||
}
|
||||
return {
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category,
|
||||
groupNumber: context.groupNumber,
|
||||
};
|
||||
}
|
||||
|
||||
private loadExpectedObjects(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: ExternalObjectNewCircuitProjectCommand
|
||||
) {
|
||||
return loadExpectedExternalObjectTransitions(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.objects,
|
||||
"External object changed before new-circuit assignment."
|
||||
);
|
||||
}
|
||||
|
||||
private loadCircuitSnapshot(database: AppDatabase, circuitId: string): CircuitSnapshot | null {
|
||||
const circuit = database.select().from(circuits).where(eq(circuits.id, circuitId)).get();
|
||||
if (!circuit) return null;
|
||||
const rows = database.select().from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, circuitId))
|
||||
.orderBy(asc(circuitDeviceRows.sortOrder), asc(circuitDeviceRows.id)).all();
|
||||
const protection = database.select().from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId)).get();
|
||||
return {
|
||||
...circuit,
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
||||
protectionDevice: protection ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toCircuitValues(circuit: CircuitSnapshot) {
|
||||
const { deviceRows: _deviceRows, protectionDevice: _protectionDevice, ...values } = circuit;
|
||||
return { ...values, isReserve: circuit.isReserve ? 1 : 0 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||
import {
|
||||
assertExternalObjectNewRowProjectCommand,
|
||||
@@ -7,17 +7,14 @@ import {
|
||||
invertExternalObjectNewRowProjectCommand,
|
||||
type ExternalObjectNewRowProjectCommand,
|
||||
} from "../../domain/models/external-object-new-row-project-command.model.js";
|
||||
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||
import type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js";
|
||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||
import {
|
||||
assertCircuitDeviceRowReferencesInProject,
|
||||
toCircuitDeviceRowInsertValues,
|
||||
@@ -25,6 +22,12 @@ import {
|
||||
} from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||
import {
|
||||
applyExternalObjectLinks,
|
||||
assertExternalObjectsCompatibleWithRow,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
snapshotsEqual,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class ExternalObjectNewRowProjectCommandRepository
|
||||
implements ExternalObjectNewRowProjectCommandStore
|
||||
@@ -68,14 +71,16 @@ export class ExternalObjectNewRowProjectCommandRepository
|
||||
throw new Error("External device-row id already exists.");
|
||||
}
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
this.assertTargetCompatibility(
|
||||
assertExternalObjectsCompatibleWithRow({
|
||||
database,
|
||||
projectId,
|
||||
row,
|
||||
context.distributionBoardId,
|
||||
context.category,
|
||||
objects.map((object) => object.target),
|
||||
new Set(command.payload.confirmedConflictObjectIds)
|
||||
);
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category,
|
||||
assignedObjects: objects.map(({ target }) => target),
|
||||
allLinkedObjects: objects.map(({ target }) => target),
|
||||
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
|
||||
});
|
||||
assertCircuitDeviceRowQuantity({
|
||||
quantity: row.quantity,
|
||||
manualQuantity: 0,
|
||||
@@ -86,7 +91,11 @@ export class ExternalObjectNewRowProjectCommandRepository
|
||||
|
||||
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
|
||||
this.updateCircuitState(database, projectId, row.circuitId);
|
||||
this.applyObjectLinks(database, objects);
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-row assignment."
|
||||
);
|
||||
}
|
||||
|
||||
private remove(
|
||||
@@ -98,7 +107,7 @@ export class ExternalObjectNewRowProjectCommandRepository
|
||||
this.loadCircuitContext(database, projectId, expectedRow.circuitId);
|
||||
const currentRow = database.select().from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, expectedRow.id)).get();
|
||||
if (!currentRow || !same(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) {
|
||||
if (!currentRow || !snapshotsEqual(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) {
|
||||
throw new Error("Created external device row changed before history removal.");
|
||||
}
|
||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||
@@ -111,7 +120,11 @@ export class ExternalObjectNewRowProjectCommandRepository
|
||||
) {
|
||||
throw new Error("Created external device row has different object links.");
|
||||
}
|
||||
this.applyObjectLinks(database, objects);
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
objects,
|
||||
"External object changed during new-row assignment."
|
||||
);
|
||||
const deleted = database.delete(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, expectedRow.id)).run();
|
||||
if (deleted.changes !== 1) {
|
||||
@@ -144,76 +157,12 @@ export class ExternalObjectNewRowProjectCommandRepository
|
||||
projectId: string,
|
||||
command: ExternalObjectNewRowProjectCommand
|
||||
) {
|
||||
const transitions = new Map(
|
||||
command.payload.objects.map((transition) => [transition.expected.id, transition])
|
||||
return loadExpectedExternalObjectTransitions(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.objects,
|
||||
"External object changed before new-row assignment."
|
||||
);
|
||||
const current = database.select().from(externalModelObjects)
|
||||
.where(inArray(externalModelObjects.id, [...transitions.keys()])).all();
|
||||
if (current.length !== transitions.size) {
|
||||
throw new Error("One or more external objects no longer exist.");
|
||||
}
|
||||
for (const object of current) {
|
||||
const expected = transitions.get(object.id)!.expected;
|
||||
if (object.projectId !== projectId || !same(toExternalObjectSnapshot(object), expected)) {
|
||||
throw new Error("External object changed before new-row assignment.");
|
||||
}
|
||||
}
|
||||
return command.payload.objects;
|
||||
}
|
||||
|
||||
private assertTargetCompatibility(
|
||||
database: AppDatabase,
|
||||
row: CircuitDeviceRowSnapshot,
|
||||
distributionBoardId: string,
|
||||
category: string,
|
||||
objects: ExternalModelObjectSnapshot[],
|
||||
confirmedConflicts: Set<string>
|
||||
) {
|
||||
const roomMappings = database.select().from(externalRoomMappings)
|
||||
.where(eq(externalRoomMappings.projectId, objects[0]!.projectId)).all();
|
||||
const roomIdByMappingId = new Map(roomMappings.map((mapping) => [mapping.id, mapping.roomId]));
|
||||
const selectionMarkers = new Set(
|
||||
objects.map((object) => object.acceptedSourceValues.selectionMarker.trim())
|
||||
);
|
||||
if (selectionMarkers.size > 1) {
|
||||
throw new Error("External objects with different selection markers require separate rows.");
|
||||
}
|
||||
for (const object of objects) {
|
||||
if (object.distributionBoardId !== distributionBoardId) {
|
||||
throw new Error("External object distribution does not match target circuit.");
|
||||
}
|
||||
if (object.planningValues.category !== category) {
|
||||
throw new Error("External object category does not match target circuit group.");
|
||||
}
|
||||
const objectRoomId = object.externalRoomMappingId === null
|
||||
? null
|
||||
: roomIdByMappingId.get(object.externalRoomMappingId) ?? null;
|
||||
if (objectRoomId !== row.roomId) {
|
||||
throw new Error("External object room does not match the new device row.");
|
||||
}
|
||||
if (!matchesRowPlanningValues(row, object) && !confirmedConflicts.has(object.id)) {
|
||||
throw new Error("External object planning values require explicit conflict confirmation.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyObjectLinks(
|
||||
database: AppDatabase,
|
||||
objects: ExternalObjectNewRowProjectCommand["payload"]["objects"]
|
||||
) {
|
||||
for (const { expected, target } of objects) {
|
||||
const result = database.update(externalModelObjects)
|
||||
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
|
||||
.where(and(
|
||||
eq(externalModelObjects.id, expected.id),
|
||||
expected.circuitDeviceRowId === null
|
||||
? isNull(externalModelObjects.circuitDeviceRowId)
|
||||
: eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId)
|
||||
)).run();
|
||||
if (result.changes !== 1) {
|
||||
throw new Error("External object changed during new-row assignment.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) {
|
||||
@@ -225,42 +174,3 @@ export class ExternalObjectNewRowProjectCommandRepository
|
||||
updateDerivedCircuitVoltage(database, projectId, circuitId);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesRowPlanningValues(
|
||||
row: CircuitDeviceRowSnapshot,
|
||||
object: ExternalModelObjectSnapshot
|
||||
) {
|
||||
const planning = object.planningValues;
|
||||
return (
|
||||
row.displayName === (planning.displayName ?? row.displayName) &&
|
||||
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
|
||||
row.category === planning.category &&
|
||||
row.connectionKind === planning.connectionKind &&
|
||||
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
||||
row.simultaneityFactor === planning.simultaneityFactor &&
|
||||
row.cosPhi === planning.cosPhi &&
|
||||
row.costGroup === planning.costGroup &&
|
||||
row.remark === planning.remark
|
||||
);
|
||||
}
|
||||
|
||||
function toExternalObjectSnapshot(
|
||||
object: typeof externalModelObjects.$inferSelect
|
||||
): ExternalModelObjectSnapshot {
|
||||
return { ...object };
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown) {
|
||||
return canonicalJson(left) === canonicalJson(right);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@ import {
|
||||
assertExternalObjectRowAssignmentProjectCommand,
|
||||
invertExternalObjectRowAssignmentProjectCommand,
|
||||
} 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 { ExternalObjectRowAssignmentProjectCommandStore } from "../../domain/ports/external-object-row-assignment-project-command.store.js";
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
applyExternalObjectLinks,
|
||||
assertExternalObjectsCompatibleWithRow,
|
||||
loadExpectedExternalObjectTransitions,
|
||||
snapshotsEqual,
|
||||
toExternalObjectSnapshot,
|
||||
} from "./external-object-assignment.persistence.js";
|
||||
|
||||
export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||
implements ExternalObjectRowAssignmentProjectCommandStore
|
||||
@@ -44,29 +48,23 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||
command.payload.objects.map((transition) => [transition.expected.id, transition])
|
||||
);
|
||||
const rowIds = [...rowTransitions.keys()];
|
||||
const objectIds = [...objectTransitions.keys()];
|
||||
const currentRows = database.select().from(circuitDeviceRows)
|
||||
.where(inArray(circuitDeviceRows.id, rowIds)).all();
|
||||
const currentObjects = database.select().from(externalModelObjects)
|
||||
.where(inArray(externalModelObjects.id, objectIds)).all();
|
||||
if (currentRows.length !== rowIds.length || currentObjects.length !== objectIds.length) {
|
||||
if (currentRows.length !== rowIds.length) {
|
||||
throw new Error("External object assignment target no longer exists.");
|
||||
}
|
||||
for (const row of currentRows) {
|
||||
const expected = rowTransitions.get(row.id)!.expected;
|
||||
if (!same(toCircuitDeviceRowSnapshot(row), expected)) {
|
||||
if (!snapshotsEqual(toCircuitDeviceRowSnapshot(row), expected)) {
|
||||
throw new Error("Circuit device row changed before external object assignment.");
|
||||
}
|
||||
}
|
||||
for (const object of currentObjects) {
|
||||
const expected = objectTransitions.get(object.id)!.expected;
|
||||
if (!same(toExternalObjectSnapshot(object), expected)) {
|
||||
throw new Error("External object changed before row assignment.");
|
||||
}
|
||||
if (object.projectId !== projectId) {
|
||||
throw new Error("External object belongs to another project.");
|
||||
}
|
||||
}
|
||||
loadExpectedExternalObjectTransitions(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.objects,
|
||||
"External object changed before row assignment."
|
||||
);
|
||||
|
||||
const rowContexts = database
|
||||
.select({
|
||||
@@ -83,7 +81,9 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||
.all();
|
||||
if (
|
||||
rowContexts.length !== rowIds.length ||
|
||||
rowContexts.some((context) => context.projectId !== projectId)
|
||||
rowContexts.some((context) =>
|
||||
context.projectId !== projectId || context.category === null
|
||||
)
|
||||
) {
|
||||
throw new Error("Circuit device row belongs to another project.");
|
||||
}
|
||||
@@ -95,47 +95,26 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||
const targetObjects = allProjectObjects.map((object) =>
|
||||
objectTransitions.get(object.id)?.target ?? object
|
||||
);
|
||||
const mappings = database.select().from(externalRoomMappings)
|
||||
.where(eq(externalRoomMappings.projectId, projectId)).all();
|
||||
const roomIdByMappingId = new Map(mappings.map((mapping) => [mapping.id, mapping.roomId]));
|
||||
const confirmedConflicts = new Set(command.payload.confirmedConflictObjectIds);
|
||||
|
||||
for (const transition of command.payload.objects) {
|
||||
const object = transition.target;
|
||||
if (object.circuitDeviceRowId === null) continue;
|
||||
const row = rowTransitions.get(object.circuitDeviceRowId)?.target;
|
||||
const context = contextByRowId.get(object.circuitDeviceRowId);
|
||||
if (!row || !context) throw new Error("External object target row is incomplete.");
|
||||
if (
|
||||
object.distributionBoardId === null ||
|
||||
object.distributionBoardId !== context.distributionBoardId
|
||||
) {
|
||||
throw new Error("External object distribution does not match target row.");
|
||||
}
|
||||
if (object.planningValues.category !== context.category) {
|
||||
throw new Error("External object category does not match target circuit group.");
|
||||
}
|
||||
const objectRoomId = object.externalRoomMappingId === null
|
||||
? null
|
||||
: roomIdByMappingId.get(object.externalRoomMappingId) ?? null;
|
||||
if (objectRoomId !== row.roomId) {
|
||||
throw new Error("External object room does not match target row.");
|
||||
}
|
||||
if (!matchesRowPlanningValues(row, object) && !confirmedConflicts.has(object.id)) {
|
||||
throw new Error("External object planning values require explicit conflict confirmation.");
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of command.payload.rows) {
|
||||
const linkedObjects = targetObjects.filter(
|
||||
(object) => object.circuitDeviceRowId === transition.target.id
|
||||
);
|
||||
const selectionMarkers = new Set(
|
||||
linkedObjects.map((object) => object.acceptedSourceValues.selectionMarker.trim())
|
||||
);
|
||||
if (selectionMarkers.size > 1) {
|
||||
throw new Error("External objects with different selection markers require separate rows.");
|
||||
}
|
||||
const assignedObjects = command.payload.objects
|
||||
.map(({ target }) => target)
|
||||
.filter((object) => object.circuitDeviceRowId === transition.target.id);
|
||||
const context = contextByRowId.get(transition.target.id)!;
|
||||
assertExternalObjectsCompatibleWithRow({
|
||||
database,
|
||||
projectId,
|
||||
row: transition.target,
|
||||
distributionBoardId: context.distributionBoardId,
|
||||
category: context.category!,
|
||||
assignedObjects,
|
||||
allLinkedObjects: linkedObjects,
|
||||
confirmedConflictObjectIds: confirmedConflicts,
|
||||
});
|
||||
assertCircuitDeviceRowQuantity({
|
||||
quantity: transition.target.quantity,
|
||||
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
|
||||
@@ -155,50 +134,10 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||
)).run();
|
||||
if (result.changes !== 1) throw new Error("Circuit device row changed during assignment.");
|
||||
}
|
||||
for (const transition of command.payload.objects) {
|
||||
const result = database.update(externalModelObjects)
|
||||
.set({ circuitDeviceRowId: transition.target.circuitDeviceRowId })
|
||||
.where(eq(externalModelObjects.id, transition.expected.id)).run();
|
||||
if (result.changes !== 1) throw new Error("External object changed during assignment.");
|
||||
}
|
||||
applyExternalObjectLinks(
|
||||
database,
|
||||
command.payload.objects,
|
||||
"External object changed during assignment."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesRowPlanningValues(
|
||||
row: CircuitDeviceRowSnapshot,
|
||||
object: ExternalModelObjectSnapshot
|
||||
) {
|
||||
const planning = object.planningValues;
|
||||
return (
|
||||
row.displayName === (planning.displayName ?? row.displayName) &&
|
||||
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
|
||||
row.category === planning.category &&
|
||||
row.connectionKind === planning.connectionKind &&
|
||||
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
|
||||
row.simultaneityFactor === planning.simultaneityFactor &&
|
||||
row.cosPhi === planning.cosPhi &&
|
||||
row.costGroup === planning.costGroup &&
|
||||
row.remark === planning.remark
|
||||
);
|
||||
}
|
||||
|
||||
function toExternalObjectSnapshot(
|
||||
object: typeof externalModelObjects.$inferSelect
|
||||
): ExternalModelObjectSnapshot {
|
||||
return { ...object };
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown) {
|
||||
return canonicalJson(left) === canonicalJson(right);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user