Add external object row assignment command
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitDeviceRowQuantity,
|
||||
} from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||
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";
|
||||
|
||||
export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||
implements ExternalObjectRowAssignmentProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]) {
|
||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
this.apply(tx, input.projectId, input.command);
|
||||
return invertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||
});
|
||||
}
|
||||
|
||||
private apply(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"]
|
||||
) {
|
||||
const rowTransitions = new Map(
|
||||
command.payload.rows.map((transition) => [transition.expected.id, transition])
|
||||
);
|
||||
const objectTransitions = new Map(
|
||||
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) {
|
||||
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)) {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
const rowContexts = database
|
||||
.select({
|
||||
rowId: circuitDeviceRows.id,
|
||||
projectId: circuitLists.projectId,
|
||||
distributionBoardId: circuitLists.distributionBoardId,
|
||||
category: circuitSections.category,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId))
|
||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||
.all();
|
||||
if (
|
||||
rowContexts.length !== rowIds.length ||
|
||||
rowContexts.some((context) => context.projectId !== projectId)
|
||||
) {
|
||||
throw new Error("Circuit device row belongs to another project.");
|
||||
}
|
||||
const contextByRowId = new Map(rowContexts.map((context) => [context.rowId, context]));
|
||||
|
||||
const allProjectObjects = database.select().from(externalModelObjects)
|
||||
.where(eq(externalModelObjects.projectId, projectId)).all()
|
||||
.map(toExternalObjectSnapshot);
|
||||
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.");
|
||||
}
|
||||
assertCircuitDeviceRowQuantity({
|
||||
quantity: transition.target.quantity,
|
||||
manualQuantity: transition.target.manualQuantity ?? transition.target.quantity,
|
||||
externalObjects: linkedObjects.map((object) => ({
|
||||
effectiveQuantity: object.planningValues.effectiveQuantity,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const transition of command.payload.rows) {
|
||||
const result = database.update(circuitDeviceRows)
|
||||
.set({ quantity: transition.target.quantity })
|
||||
.where(and(
|
||||
eq(circuitDeviceRows.id, transition.expected.id),
|
||||
eq(circuitDeviceRows.quantity, transition.expected.quantity),
|
||||
eq(circuitDeviceRows.manualQuantity, transition.expected.manualQuantity ?? transition.expected.quantity)
|
||||
)).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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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