diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index da17458..eb722d7 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; import { projectDevices } from "../schema/project-devices.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; @@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository ); for (const assignment of input.command.payload.rows) { + const values: typeof assignment.target & { + manualQuantity?: number; + } = { ...assignment.target }; + if (assignment.target.quantity !== assignment.expected.quantity) { + const externalTotal = tx + .select({ planningValues: externalModelObjects.planningValues }) + .from(externalModelObjects) + .where( + eq(externalModelObjects.circuitDeviceRowId, assignment.rowId) + ) + .all() + .reduce( + (sum, object) => sum + object.planningValues.effectiveQuantity, + 0 + ); + const manualQuantity = assignment.target.quantity - externalTotal; + if (manualQuantity < 0) { + throw new Error( + "Synchronized quantity is below the total quantity of linked external objects." + ); + } + values.manualQuantity = manualQuantity; + } const updated = tx .update(circuitDeviceRows) - .set(assignment.target) + .set(values) .where(eq(circuitDeviceRows.id, assignment.rowId)) .run(); if (updated.changes !== 1) { diff --git a/tests/project-device-row-sync-project-command.repository.test.ts b/tests/project-device-row-sync-project-command.repository.test.ts index 75d56a8..593a7f6 100644 --- a/tests/project-device-row-sync-project-command.repository.test.ts +++ b/tests/project-device-row-sync-project-command.repository.test.ts @@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; +import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; +import { externalModelSources } from "../src/db/schema/external-model-sources.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; +import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; import { createProjectDeviceRowSyncProjectCommand, type ProjectDeviceSyncRowSnapshot, @@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) { return row; } +function linkExternalObject( + context: DatabaseContext, + rowId: string, + effectiveQuantity: number +) { + context.db + .insert(externalModelSources) + .values({ + id: "source-1", + projectId: "project-1", + name: "Revit", + sourceType: "revit_csv", + }) + .run(); + context.db + .insert(externalImportBatches) + .values({ + id: "batch-1", + projectId: "project-1", + sourceId: "source-1", + importKind: "initial", + importedAtIso: "2026-08-02T16:00:00.000Z", + fileName: "revit.csv", + sha256: "a".repeat(64), + appliedProjectRevision: 0, + configurationVersion: 1, + configurationSnapshot: externalCsvTestConfiguration, + originalBytes: Buffer.from("test"), + document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, + }) + .run(); + context.db + .insert(externalModelObjects) + .values({ + id: "object-1", + projectId: "project-1", + sourceId: "source-1", + ifcGuid: "ifc-1", + lastSeenImportBatchId: "batch-1", + lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: { + rowNumber: 2, + roomNumber: "101", + roomName: "Büro", + familyAndType: "Leuchte: Standard", + selectionMarker: "Leuchte", + circuitIdentifier: "-1F1", + power: "30", + quantity: String(effectiveQuantity), + additionalSourceValues: {}, + }, + planningValues: { + displayName: "Leuchte", + internalDeviceType: "luminaire", + category: "single_phase", + connectionKind: "fixed", + effectiveQuantity, + powerPerUnitW: 30, + simultaneityFactor: 1, + cosPhi: null, + costGroup: null, + remark: null, + }, + overriddenFields: [], + externalRoomMappingId: null, + distributionBoardId: null, + linkedProjectDeviceId: null, + circuitDeviceRowId: rowId, + presenceStatus: "present", + }) + .run(); +} + function snapshot( context: DatabaseContext, rowId: string @@ -474,6 +551,112 @@ describe("project-device row sync project-command repository", () => { } }); + it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 5 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [ + { + rowId: "row-1", + expected, + target: { ...expected, quantity: 2 }, + }, + ] + ), + }); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 2); + assert.equal(row.manualQuantity, 2); + } finally { + fixture.context.close(); + } + }); + + it("subtracts linked external objects when a synced quantity shrinks", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 2 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + linkExternalObject(fixture.context, "row-1", 3); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }] + ), + }); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 4); + assert.equal(row.manualQuantity, 1); + } finally { + fixture.context.close(); + } + }); + + it("rejects a synced quantity below the linked external total", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 2 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + linkExternalObject(fixture.context, "row-1", 3); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }] + ), + }), + /below the total quantity of linked external objects/ + ); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 5); + assert.equal(row.manualQuantity, 2); + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + it("rolls back synchronized rows for a stale project revision", () => { const fixture = createTestDatabase(); try {