Add external object new circuit command
This commit is contained in:
@@ -299,6 +299,12 @@ objects as one external-only row (`manualQuantity = 0`) into an existing
|
|||||||
circuit. Its history-only inverse removes the row only while its complete
|
circuit. Its history-only inverse removes the row only while its complete
|
||||||
snapshot and exact object-link set remain unchanged. Ordinary detach continues
|
snapshot and exact object-link set remain unchanged. Ordinary detach continues
|
||||||
to preserve the row.
|
to preserve the row.
|
||||||
|
`external-object.assign-to-new-circuit` atomically inserts a preplanned circuit
|
||||||
|
with stable BMK/UUIDs, derived voltage, one external-only row, required default
|
||||||
|
protection and its external object links. Its history-only inverse requires the
|
||||||
|
complete unchanged circuit snapshot and exact link set. Redo never recalculates
|
||||||
|
the BMK. All three external assignment stores share
|
||||||
|
`external-object-assignment.persistence.ts` for compatibility and link safety.
|
||||||
Confirmed initial state is written only through
|
Confirmed initial state is written only through
|
||||||
`external-import.apply-initial`. The command rechecks configuration version,
|
`external-import.apply-initial`. The command rechecks configuration version,
|
||||||
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
|
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
created every 25 revisions and only their newest 12 entries are retained.
|
created every 25 revisions and only their newest 12 entries are retained.
|
||||||
- The Revit/CSV foundation and confirmed initial import are implemented.
|
- The Revit/CSV foundation and confirmed initial import are implemented.
|
||||||
Existing-row assignment, move and detach and creation of an external-only row
|
Existing-row assignment, move and detach plus creation of an external-only
|
||||||
in an existing circuit have atomic persistent commands. Their editor UI, the
|
row in an existing or newly created circuit have atomic persistent commands.
|
||||||
new-circuit path, follow-up import, conflict review and return export are
|
Their API planning and editor UI, follow-up import, conflict review and return
|
||||||
pending.
|
export are pending.
|
||||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||||
- The global device library supports basic CRUD and copy operations, but has no
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
versioning, permissions or controlled synchronization model.
|
versioning, permissions or controlled synchronization model.
|
||||||
|
|||||||
@@ -559,6 +559,17 @@ und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind.
|
|||||||
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
|
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
|
||||||
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
|
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
|
||||||
bestehen.
|
bestehen.
|
||||||
|
`external-object.assign-to-new-circuit` erzeugt einen neuen Stromkreis mit
|
||||||
|
vorab festgeschriebenem BMK, abgeleiteter Projektspannung, genau einer
|
||||||
|
externen Row und verpflichtendem Standardschutz zusammen mit den Objektlinks.
|
||||||
|
Verteilungs-, Gruppen-, Raum-, Mengen- und Planungsregeln werden vor dem Insert
|
||||||
|
gegen den aktuellen Projektstand geprüft. Der historische Gegenbefehl ist kein
|
||||||
|
allgemeiner Löschpfad: Er entfernt den vollständigen Circuit-Teilbaum nur bei
|
||||||
|
unverändertem Snapshot und unveränderter Objektlinkmenge. Redo verwendet exakt
|
||||||
|
dieselben IDs, dasselbe BMK und denselben Schutzsnapshot.
|
||||||
|
Die drei Zuordnungsadapter teilen sich
|
||||||
|
`external-object-assignment.persistence.ts` für Snapshotvergleich,
|
||||||
|
Kompatibilitätsregeln und link-sichere Updates.
|
||||||
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
|
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
|
||||||
ändern die Konfiguration; der PUT plant Identität und nächsten
|
ändern die Konfiguration; der PUT plant Identität und nächsten
|
||||||
Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
Konfigurationsstand serverseitig und verwendet den typisierten Command.
|
||||||
|
|||||||
@@ -205,9 +205,9 @@ bleiben und dürfen spätere Quellstände nicht erneut auswerten.
|
|||||||
- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
|
- `external-object.update-row-assignment`: Ein oder mehrere Objekte einer
|
||||||
bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen
|
bestehenden Row zuweisen, zwischen bestehenden Rows verschieben oder lösen
|
||||||
und alle betroffenen materialisierten Mengen atomar aktualisieren.
|
und alle betroffenen materialisierten Mengen atomar aktualisieren.
|
||||||
- `external-model-object.assign-to-new-row`: stabile neue Row einfügen,
|
- `external-object.assign-to-new-row`: stabile neue Row einfügen,
|
||||||
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
|
Objekte verknüpfen und gegebenenfalls den Reserve-Status aktualisieren.
|
||||||
- `external-model-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
|
- `external-object.assign-to-new-circuit`: stabilen Circuit samt Schutz,
|
||||||
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
|
Row und Objektlinks in einem Schritt einfügen. Das geplante BMK wird im
|
||||||
Command gespeichert und bei Redo nicht neu berechnet.
|
Command gespeichert und bei Redo nicht neu berechnet.
|
||||||
- `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
|
- `external-model-object.delete-missing`: nur ausdrücklich bestätigte,
|
||||||
@@ -330,7 +330,13 @@ Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
|
|||||||
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
|
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
|
||||||
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
|
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
|
||||||
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
|
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
|
||||||
als Nutzeraktion gesperrt. Der neue Circuit folgt separat.
|
als Nutzeraktion gesperrt. `external-object.assign-to-new-circuit` schließt
|
||||||
|
den dritten Zielpfad: Ein serverseitig vorgeplanter Circuit mit festem BMK,
|
||||||
|
Standardschutz und genau einer externen Row wird gemeinsam mit den
|
||||||
|
Objektlinks eingefügt. Seine Historiengegenrichtung prüft den vollständigen
|
||||||
|
Circuit-Snapshot und die exakte Linkmenge, bevor sie den Teilbaum entfernt.
|
||||||
|
BMK und UUIDs bleiben bei Redo unverändert. Damit ist die persistente
|
||||||
|
Command-Grundlage dieses Schritts abgeschlossen; API-Planung und UI folgen.
|
||||||
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
|
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
|
||||||
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
|
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
|
||||||
testen.
|
testen.
|
||||||
|
|||||||
@@ -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 { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||||
import {
|
import {
|
||||||
assertExternalObjectNewRowProjectCommand,
|
assertExternalObjectNewRowProjectCommand,
|
||||||
@@ -7,17 +7,14 @@ import {
|
|||||||
invertExternalObjectNewRowProjectCommand,
|
invertExternalObjectNewRowProjectCommand,
|
||||||
type ExternalObjectNewRowProjectCommand,
|
type ExternalObjectNewRowProjectCommand,
|
||||||
} from "../../domain/models/external-object-new-row-project-command.model.js";
|
} 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 type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js";
|
||||||
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.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 type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
|
||||||
import {
|
import {
|
||||||
assertCircuitDeviceRowReferencesInProject,
|
assertCircuitDeviceRowReferencesInProject,
|
||||||
toCircuitDeviceRowInsertValues,
|
toCircuitDeviceRowInsertValues,
|
||||||
@@ -25,6 +22,12 @@ import {
|
|||||||
} from "./circuit-device-row-structure.persistence.js";
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
import {
|
||||||
|
applyExternalObjectLinks,
|
||||||
|
assertExternalObjectsCompatibleWithRow,
|
||||||
|
loadExpectedExternalObjectTransitions,
|
||||||
|
snapshotsEqual,
|
||||||
|
} from "./external-object-assignment.persistence.js";
|
||||||
|
|
||||||
export class ExternalObjectNewRowProjectCommandRepository
|
export class ExternalObjectNewRowProjectCommandRepository
|
||||||
implements ExternalObjectNewRowProjectCommandStore
|
implements ExternalObjectNewRowProjectCommandStore
|
||||||
@@ -68,14 +71,16 @@ export class ExternalObjectNewRowProjectCommandRepository
|
|||||||
throw new Error("External device-row id already exists.");
|
throw new Error("External device-row id already exists.");
|
||||||
}
|
}
|
||||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
const objects = this.loadExpectedObjects(database, projectId, command);
|
||||||
this.assertTargetCompatibility(
|
assertExternalObjectsCompatibleWithRow({
|
||||||
database,
|
database,
|
||||||
|
projectId,
|
||||||
row,
|
row,
|
||||||
context.distributionBoardId,
|
distributionBoardId: context.distributionBoardId,
|
||||||
context.category,
|
category: context.category,
|
||||||
objects.map((object) => object.target),
|
assignedObjects: objects.map(({ target }) => target),
|
||||||
new Set(command.payload.confirmedConflictObjectIds)
|
allLinkedObjects: objects.map(({ target }) => target),
|
||||||
);
|
confirmedConflictObjectIds: new Set(command.payload.confirmedConflictObjectIds),
|
||||||
|
});
|
||||||
assertCircuitDeviceRowQuantity({
|
assertCircuitDeviceRowQuantity({
|
||||||
quantity: row.quantity,
|
quantity: row.quantity,
|
||||||
manualQuantity: 0,
|
manualQuantity: 0,
|
||||||
@@ -86,7 +91,11 @@ export class ExternalObjectNewRowProjectCommandRepository
|
|||||||
|
|
||||||
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
|
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
|
||||||
this.updateCircuitState(database, projectId, row.circuitId);
|
this.updateCircuitState(database, projectId, row.circuitId);
|
||||||
this.applyObjectLinks(database, objects);
|
applyExternalObjectLinks(
|
||||||
|
database,
|
||||||
|
objects,
|
||||||
|
"External object changed during new-row assignment."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private remove(
|
private remove(
|
||||||
@@ -98,7 +107,7 @@ export class ExternalObjectNewRowProjectCommandRepository
|
|||||||
this.loadCircuitContext(database, projectId, expectedRow.circuitId);
|
this.loadCircuitContext(database, projectId, expectedRow.circuitId);
|
||||||
const currentRow = database.select().from(circuitDeviceRows)
|
const currentRow = database.select().from(circuitDeviceRows)
|
||||||
.where(eq(circuitDeviceRows.id, expectedRow.id)).get();
|
.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.");
|
throw new Error("Created external device row changed before history removal.");
|
||||||
}
|
}
|
||||||
const objects = this.loadExpectedObjects(database, projectId, command);
|
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.");
|
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)
|
const deleted = database.delete(circuitDeviceRows)
|
||||||
.where(eq(circuitDeviceRows.id, expectedRow.id)).run();
|
.where(eq(circuitDeviceRows.id, expectedRow.id)).run();
|
||||||
if (deleted.changes !== 1) {
|
if (deleted.changes !== 1) {
|
||||||
@@ -144,76 +157,12 @@ export class ExternalObjectNewRowProjectCommandRepository
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
command: ExternalObjectNewRowProjectCommand
|
command: ExternalObjectNewRowProjectCommand
|
||||||
) {
|
) {
|
||||||
const transitions = new Map(
|
return loadExpectedExternalObjectTransitions(
|
||||||
command.payload.objects.map((transition) => [transition.expected.id, transition])
|
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) {
|
private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) {
|
||||||
@@ -225,42 +174,3 @@ export class ExternalObjectNewRowProjectCommandRepository
|
|||||||
updateDerivedCircuitVoltage(database, projectId, circuitId);
|
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,
|
assertExternalObjectRowAssignmentProjectCommand,
|
||||||
invertExternalObjectRowAssignmentProjectCommand,
|
invertExternalObjectRowAssignmentProjectCommand,
|
||||||
} from "../../domain/models/external-object-row-assignment-project-command.model.js";
|
} 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 { 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 type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.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 { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.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
|
export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||||
implements ExternalObjectRowAssignmentProjectCommandStore
|
implements ExternalObjectRowAssignmentProjectCommandStore
|
||||||
@@ -44,29 +48,23 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
|||||||
command.payload.objects.map((transition) => [transition.expected.id, transition])
|
command.payload.objects.map((transition) => [transition.expected.id, transition])
|
||||||
);
|
);
|
||||||
const rowIds = [...rowTransitions.keys()];
|
const rowIds = [...rowTransitions.keys()];
|
||||||
const objectIds = [...objectTransitions.keys()];
|
|
||||||
const currentRows = database.select().from(circuitDeviceRows)
|
const currentRows = database.select().from(circuitDeviceRows)
|
||||||
.where(inArray(circuitDeviceRows.id, rowIds)).all();
|
.where(inArray(circuitDeviceRows.id, rowIds)).all();
|
||||||
const currentObjects = database.select().from(externalModelObjects)
|
if (currentRows.length !== rowIds.length) {
|
||||||
.where(inArray(externalModelObjects.id, objectIds)).all();
|
|
||||||
if (currentRows.length !== rowIds.length || currentObjects.length !== objectIds.length) {
|
|
||||||
throw new Error("External object assignment target no longer exists.");
|
throw new Error("External object assignment target no longer exists.");
|
||||||
}
|
}
|
||||||
for (const row of currentRows) {
|
for (const row of currentRows) {
|
||||||
const expected = rowTransitions.get(row.id)!.expected;
|
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.");
|
throw new Error("Circuit device row changed before external object assignment.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const object of currentObjects) {
|
loadExpectedExternalObjectTransitions(
|
||||||
const expected = objectTransitions.get(object.id)!.expected;
|
database,
|
||||||
if (!same(toExternalObjectSnapshot(object), expected)) {
|
projectId,
|
||||||
throw new Error("External object changed before row assignment.");
|
command.payload.objects,
|
||||||
}
|
"External object changed before row assignment."
|
||||||
if (object.projectId !== projectId) {
|
);
|
||||||
throw new Error("External object belongs to another project.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowContexts = database
|
const rowContexts = database
|
||||||
.select({
|
.select({
|
||||||
@@ -83,7 +81,9 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
|||||||
.all();
|
.all();
|
||||||
if (
|
if (
|
||||||
rowContexts.length !== rowIds.length ||
|
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.");
|
throw new Error("Circuit device row belongs to another project.");
|
||||||
}
|
}
|
||||||
@@ -95,47 +95,26 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
|||||||
const targetObjects = allProjectObjects.map((object) =>
|
const targetObjects = allProjectObjects.map((object) =>
|
||||||
objectTransitions.get(object.id)?.target ?? 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);
|
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) {
|
for (const transition of command.payload.rows) {
|
||||||
const linkedObjects = targetObjects.filter(
|
const linkedObjects = targetObjects.filter(
|
||||||
(object) => object.circuitDeviceRowId === transition.target.id
|
(object) => object.circuitDeviceRowId === transition.target.id
|
||||||
);
|
);
|
||||||
const selectionMarkers = new Set(
|
const assignedObjects = command.payload.objects
|
||||||
linkedObjects.map((object) => object.acceptedSourceValues.selectionMarker.trim())
|
.map(({ target }) => target)
|
||||||
);
|
.filter((object) => object.circuitDeviceRowId === transition.target.id);
|
||||||
if (selectionMarkers.size > 1) {
|
const context = contextByRowId.get(transition.target.id)!;
|
||||||
throw new Error("External objects with different selection markers require separate rows.");
|
assertExternalObjectsCompatibleWithRow({
|
||||||
}
|
database,
|
||||||
|
projectId,
|
||||||
|
row: transition.target,
|
||||||
|
distributionBoardId: context.distributionBoardId,
|
||||||
|
category: context.category!,
|
||||||
|
assignedObjects,
|
||||||
|
allLinkedObjects: linkedObjects,
|
||||||
|
confirmedConflictObjectIds: confirmedConflicts,
|
||||||
|
});
|
||||||
assertCircuitDeviceRowQuantity({
|
assertCircuitDeviceRowQuantity({
|
||||||
quantity: transition.target.quantity,
|
quantity: transition.target.quantity,
|
||||||
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
|
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
|
||||||
@@ -155,50 +134,10 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
|||||||
)).run();
|
)).run();
|
||||||
if (result.changes !== 1) throw new Error("Circuit device row changed during assignment.");
|
if (result.changes !== 1) throw new Error("Circuit device row changed during assignment.");
|
||||||
}
|
}
|
||||||
for (const transition of command.payload.objects) {
|
applyExternalObjectLinks(
|
||||||
const result = database.update(externalModelObjects)
|
database,
|
||||||
.set({ circuitDeviceRowId: transition.target.circuitDeviceRowId })
|
command.payload.objects,
|
||||||
.where(eq(externalModelObjects.id, transition.expected.id)).run();
|
"External object changed during assignment."
|
||||||
if (result.changes !== 1) throw new Error("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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import {
|
||||||
|
assertCircuitInsertProjectCommand,
|
||||||
|
circuitInsertCommandType,
|
||||||
|
circuitStructureCommandSchemaVersion,
|
||||||
|
type CircuitSnapshot,
|
||||||
|
} from "./circuit-structure-project-command.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||||
|
|
||||||
|
export const externalObjectAssignToNewCircuitCommandType =
|
||||||
|
"external-object.assign-to-new-circuit" as const;
|
||||||
|
export const externalObjectDeleteCreatedCircuitCommandType =
|
||||||
|
"external-object.unassign-and-delete-created-circuit" as const;
|
||||||
|
export const externalObjectNewCircuitCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ExternalObjectNewCircuitPayload {
|
||||||
|
circuit: CircuitSnapshot;
|
||||||
|
objects: Array<{
|
||||||
|
expected: ExternalModelObjectSnapshot;
|
||||||
|
target: ExternalModelObjectSnapshot;
|
||||||
|
}>;
|
||||||
|
confirmedConflictObjectIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalObjectNewCircuitProjectCommand
|
||||||
|
extends SerializedProjectCommand<ExternalObjectNewCircuitPayload> {
|
||||||
|
schemaVersion: typeof externalObjectNewCircuitCommandSchemaVersion;
|
||||||
|
type:
|
||||||
|
| typeof externalObjectAssignToNewCircuitCommandType
|
||||||
|
| typeof externalObjectDeleteCreatedCircuitCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalObjectNewCircuitProjectCommand(
|
||||||
|
type: ExternalObjectNewCircuitProjectCommand["type"],
|
||||||
|
payload: ExternalObjectNewCircuitPayload
|
||||||
|
): ExternalObjectNewCircuitProjectCommand {
|
||||||
|
const row = payload.circuit.deviceRows[0];
|
||||||
|
const command: ExternalObjectNewCircuitProjectCommand = {
|
||||||
|
schemaVersion: externalObjectNewCircuitCommandSchemaVersion,
|
||||||
|
type,
|
||||||
|
payload: {
|
||||||
|
circuit: row
|
||||||
|
? {
|
||||||
|
...payload.circuit,
|
||||||
|
deviceRows: [{
|
||||||
|
...row,
|
||||||
|
manualQuantity: row.manualQuantity ?? row.quantity,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
: payload.circuit,
|
||||||
|
objects: payload.objects,
|
||||||
|
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertExternalObjectNewCircuitProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invertExternalObjectNewCircuitProjectCommand(
|
||||||
|
command: ExternalObjectNewCircuitProjectCommand
|
||||||
|
) {
|
||||||
|
return createExternalObjectNewCircuitProjectCommand(
|
||||||
|
command.type === externalObjectAssignToNewCircuitCommandType
|
||||||
|
? externalObjectDeleteCreatedCircuitCommandType
|
||||||
|
: externalObjectAssignToNewCircuitCommandType,
|
||||||
|
{
|
||||||
|
circuit: command.payload.circuit,
|
||||||
|
objects: command.payload.objects.map(({ expected, target }) => ({
|
||||||
|
expected: target,
|
||||||
|
target: expected,
|
||||||
|
})),
|
||||||
|
confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExternalObjectNewCircuitProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ExternalObjectNewCircuitProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== externalObjectNewCircuitCommandSchemaVersion ||
|
||||||
|
(command.type !== externalObjectAssignToNewCircuitCommandType &&
|
||||||
|
command.type !== externalObjectDeleteCreatedCircuitCommandType) ||
|
||||||
|
!isRecord(command.payload) ||
|
||||||
|
!Array.isArray(command.payload.objects) ||
|
||||||
|
command.payload.objects.length === 0 ||
|
||||||
|
!Array.isArray(command.payload.confirmedConflictObjectIds)
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported external object new-circuit command.");
|
||||||
|
}
|
||||||
|
assertCircuitInsertProjectCommand({
|
||||||
|
schemaVersion: circuitStructureCommandSchemaVersion,
|
||||||
|
type: circuitInsertCommandType,
|
||||||
|
payload: { circuit: command.payload.circuit },
|
||||||
|
});
|
||||||
|
const circuit = command.payload.circuit as CircuitSnapshot;
|
||||||
|
if (
|
||||||
|
circuit.deviceRows.length !== 1 ||
|
||||||
|
circuit.deviceRows[0]!.manualQuantity !== 0 ||
|
||||||
|
circuit.protectionDevice === undefined ||
|
||||||
|
circuit.protectionDevice === null
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"An external circuit must contain one external-only row and protection device."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rowId = circuit.deviceRows[0]!.id;
|
||||||
|
const objectIds = new Set<string>();
|
||||||
|
for (const transition of command.payload.objects) {
|
||||||
|
if (!isRecord(transition)) throw new Error("Invalid external object transition.");
|
||||||
|
const expected = parseObject(transition.expected);
|
||||||
|
const target = parseObject(transition.target);
|
||||||
|
if (expected.id !== target.id || objectIds.has(expected.id)) {
|
||||||
|
throw new Error("New-circuit assignment contains mismatched or duplicate objects.");
|
||||||
|
}
|
||||||
|
objectIds.add(expected.id);
|
||||||
|
const assigning = command.type === externalObjectAssignToNewCircuitCommandType;
|
||||||
|
if (
|
||||||
|
(assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== rowId)) ||
|
||||||
|
(!assigning && (expected.circuitDeviceRowId !== rowId || target.circuitDeviceRowId !== null))
|
||||||
|
) {
|
||||||
|
throw new Error("External object links do not match the new-circuit action.");
|
||||||
|
}
|
||||||
|
assertOnlyAssignmentChanged(expected, target);
|
||||||
|
}
|
||||||
|
const confirmedIds = new Set<string>();
|
||||||
|
for (const objectId of command.payload.confirmedConflictObjectIds) {
|
||||||
|
if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) {
|
||||||
|
throw new Error("New-circuit assignment contains an invalid conflict confirmation.");
|
||||||
|
}
|
||||||
|
confirmedIds.add(objectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObject(value: unknown): ExternalModelObjectSnapshot {
|
||||||
|
return parseExternalModelStateSnapshot({
|
||||||
|
source: null,
|
||||||
|
importBatches: [],
|
||||||
|
roomMappings: [],
|
||||||
|
objects: [value],
|
||||||
|
}).objects[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOnlyAssignmentChanged(
|
||||||
|
expected: ExternalModelObjectSnapshot,
|
||||||
|
target: ExternalModelObjectSnapshot
|
||||||
|
) {
|
||||||
|
const expectedRest = { ...expected, circuitDeviceRowId: null };
|
||||||
|
const targetRest = { ...target, circuitDeviceRowId: null };
|
||||||
|
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
|
||||||
|
throw new Error("New-circuit assignment may only change the external object row link.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
|
if (isRecord(value)) {
|
||||||
|
return `{${Object.keys(value).sort().map((key) =>
|
||||||
|
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
|
||||||
|
).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ExternalObjectNewCircuitProjectCommand } from "../models/external-object-new-circuit-project-command.model.js";
|
||||||
|
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteExternalObjectNewCircuitCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ExternalObjectNewCircuitProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalObjectNewCircuitProjectCommandStore {
|
||||||
|
execute(input: ExecuteExternalObjectNewCircuitCommandInput): {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ExternalObjectNewCircuitProjectCommand;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -182,6 +182,12 @@ import {
|
|||||||
externalObjectDeleteCreatedRowCommandType,
|
externalObjectDeleteCreatedRowCommandType,
|
||||||
} from "../models/external-object-new-row-project-command.model.js";
|
} from "../models/external-object-new-row-project-command.model.js";
|
||||||
import type { ExternalObjectNewRowProjectCommandStore } from "../ports/external-object-new-row-project-command.store.js";
|
import type { ExternalObjectNewRowProjectCommandStore } from "../ports/external-object-new-row-project-command.store.js";
|
||||||
|
import {
|
||||||
|
assertExternalObjectNewCircuitProjectCommand,
|
||||||
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
|
externalObjectDeleteCreatedCircuitCommandType,
|
||||||
|
} from "../models/external-object-new-circuit-project-command.model.js";
|
||||||
|
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js";
|
||||||
|
|
||||||
interface DispatchProjectCommandInput {
|
interface DispatchProjectCommandInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -220,7 +226,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
|
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
|
||||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore
|
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||||
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
executeUser(
|
executeUser(
|
||||||
@@ -618,6 +625,17 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case externalObjectAssignToNewCircuitCommandType:
|
||||||
|
case externalObjectDeleteCreatedCircuitCommandType: {
|
||||||
|
assertExternalObjectNewCircuitProjectCommand(input.command);
|
||||||
|
if (!this.externalObjectNewCircuitStore) {
|
||||||
|
throw new Error("External object new-circuit store is not available.");
|
||||||
|
}
|
||||||
|
return this.externalObjectNewCircuitStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
case projectStateRestoreCommandType: {
|
case projectStateRestoreCommandType: {
|
||||||
assertProjectStateRestoreCommand(input.command);
|
assertProjectStateRestoreCommand(input.command);
|
||||||
return this.projectStateRestoreStore.execute({
|
return this.projectStateRestoreStore.execute({
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repos
|
|||||||
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
|
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
|
||||||
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-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 { 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";
|
||||||
|
|
||||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||||
export const circuitDeviceRowProjectCommandStore =
|
export const circuitDeviceRowProjectCommandStore =
|
||||||
@@ -77,6 +78,8 @@ export const externalObjectRowAssignmentProjectCommandStore =
|
|||||||
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
new ExternalObjectRowAssignmentProjectCommandRepository(db);
|
||||||
export const externalObjectNewRowProjectCommandStore =
|
export const externalObjectNewRowProjectCommandStore =
|
||||||
new ExternalObjectNewRowProjectCommandRepository(db);
|
new ExternalObjectNewRowProjectCommandRepository(db);
|
||||||
|
export const externalObjectNewCircuitProjectCommandStore =
|
||||||
|
new ExternalObjectNewCircuitProjectCommandRepository(db);
|
||||||
export const projectCommandService = new ProjectCommandService(
|
export const projectCommandService = new ProjectCommandService(
|
||||||
circuitProjectCommandStore,
|
circuitProjectCommandStore,
|
||||||
circuitDeviceRowProjectCommandStore,
|
circuitDeviceRowProjectCommandStore,
|
||||||
@@ -103,5 +106,6 @@ export const projectCommandService = new ProjectCommandService(
|
|||||||
externalCsvConfigurationProjectCommandStore,
|
externalCsvConfigurationProjectCommandStore,
|
||||||
externalInitialImportProjectCommandStore,
|
externalInitialImportProjectCommandStore,
|
||||||
externalObjectRowAssignmentProjectCommandStore,
|
externalObjectRowAssignmentProjectCommandStore,
|
||||||
externalObjectNewRowProjectCommandStore
|
externalObjectNewRowProjectCommandStore,
|
||||||
|
externalObjectNewCircuitProjectCommandStore
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|||||||
import { createDatabaseContext, type AppDatabase } from "../src/db/database-context.js";
|
import { createDatabaseContext, type AppDatabase } from "../src/db/database-context.js";
|
||||||
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
|
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
|
||||||
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
|
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
|
||||||
|
import { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||||
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
|
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
|
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
||||||
@@ -24,6 +26,10 @@ import {
|
|||||||
createExternalObjectNewRowProjectCommand,
|
createExternalObjectNewRowProjectCommand,
|
||||||
externalObjectAssignToNewRowCommandType,
|
externalObjectAssignToNewRowCommandType,
|
||||||
} from "../src/domain/models/external-object-new-row-project-command.model.js";
|
} from "../src/domain/models/external-object-new-row-project-command.model.js";
|
||||||
|
import {
|
||||||
|
createExternalObjectNewCircuitProjectCommand,
|
||||||
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
|
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
|
||||||
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||||||
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
@@ -376,3 +382,192 @@ describe("external object new-row project command", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function newExternalCircuit(database: AppDatabase) {
|
||||||
|
const sourceRow = row(database, "row-2");
|
||||||
|
return {
|
||||||
|
id: "circuit-new",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: "-2F1.3",
|
||||||
|
displayName: "Steckdose",
|
||||||
|
sortOrder: 30,
|
||||||
|
cableType: null,
|
||||||
|
cableCrossSection: null,
|
||||||
|
cableLength: null,
|
||||||
|
rcdAssignment: null,
|
||||||
|
terminalDesignation: null,
|
||||||
|
voltage: 230,
|
||||||
|
controlRequirement: null,
|
||||||
|
status: null,
|
||||||
|
isReserve: false,
|
||||||
|
remark: null,
|
||||||
|
deviceRows: [{
|
||||||
|
...sourceRow,
|
||||||
|
id: "row-new",
|
||||||
|
circuitId: "circuit-new",
|
||||||
|
quantity: 2,
|
||||||
|
manualQuantity: 0,
|
||||||
|
}],
|
||||||
|
protectionDevice: {
|
||||||
|
circuitId: "circuit-new",
|
||||||
|
type: "FI_LS" as const,
|
||||||
|
ratedCurrentA: 16,
|
||||||
|
fuseUtilizationCategory: null,
|
||||||
|
tripCharacteristic: "B" as const,
|
||||||
|
rcdType: "A" as const,
|
||||||
|
ratedResidualCurrentMa: 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("external object new-circuit project command", () => {
|
||||||
|
it("creates circuit, protection, row and links as one undoable revision", () => {
|
||||||
|
const context = createFixture(null);
|
||||||
|
try {
|
||||||
|
const object = objectSnapshot(null);
|
||||||
|
const circuit = newExternalCircuit(context.db);
|
||||||
|
const command = createExternalObjectNewCircuitProjectCommand(
|
||||||
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
|
{
|
||||||
|
circuit,
|
||||||
|
objects: [{
|
||||||
|
expected: object,
|
||||||
|
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||||
|
}],
|
||||||
|
confirmedConflictObjectIds: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
|
||||||
|
.equipmentIdentifier,
|
||||||
|
"-2F1.3"
|
||||||
|
);
|
||||||
|
assert.equal(row(context.db, "row-new").manualQuantity, 0);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(circuitProtectionDevices)
|
||||||
|
.where(eq(circuitProtectionDevices.circuitId, "circuit-new")).get()!.type,
|
||||||
|
"FI_LS"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
|
||||||
|
"row-new"
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: inserted.inverse,
|
||||||
|
}),
|
||||||
|
/only be removed through project history/
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: undone.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get()!
|
||||||
|
.equipmentIdentifier,
|
||||||
|
"-2F1.3"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a preplanned BMK that does not belong to the target group", () => {
|
||||||
|
const context = createFixture(null);
|
||||||
|
try {
|
||||||
|
const object = objectSnapshot(null);
|
||||||
|
const circuit = {
|
||||||
|
...newExternalCircuit(context.db),
|
||||||
|
equipmentIdentifier: "-1F1.3",
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalObjectNewCircuitProjectCommand(
|
||||||
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
|
{
|
||||||
|
circuit,
|
||||||
|
objects: [{
|
||||||
|
expected: object,
|
||||||
|
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||||
|
}],
|
||||||
|
confirmedConflictObjectIds: [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/equipment identifier does not match target group/
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the complete circuit subtree when history persistence fails", () => {
|
||||||
|
const context = createFixture(null);
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_new_circuit_history
|
||||||
|
BEFORE INSERT ON project_change_sets
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
|
||||||
|
`);
|
||||||
|
const object = objectSnapshot(null);
|
||||||
|
assert.throws(
|
||||||
|
() => new ExternalObjectNewCircuitProjectCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalObjectNewCircuitProjectCommand(
|
||||||
|
externalObjectAssignToNewCircuitCommandType,
|
||||||
|
{
|
||||||
|
circuit: newExternalCircuit(context.db),
|
||||||
|
objects: [{
|
||||||
|
expected: object,
|
||||||
|
target: { ...object, circuitDeviceRowId: "row-new" },
|
||||||
|
}],
|
||||||
|
confirmedConflictObjectIds: [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/late history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(circuits).where(eq(circuits.id, "circuit-new")).get(),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user