forked from jappel/leistungsbilanz-ts
Keep manual quantity consistent when synchronizing device rows
Synchronizing a project device pushes its quantity onto every linked device row, but manualQuantity is not part of the sync snapshot and was left untouched. A device whose quantity is lower than the row's left the row at manualQuantity > quantity, violating the snapshot invariant. The violation surfaced far from its cause: the invariant is only checked when a full state snapshot is read, and the automatic snapshot runs every 25 revisions. A project could therefore accumulate the broken row silently and then reject every subsequent command, because the failing validation rolls back the whole transaction including the revision bump. Recompute manualQuantity as the synchronized quantity minus the total of the linked external objects, matching the invariant that assertCircuitDeviceRowQuantity enforces elsewhere, and reject a synchronized quantity that falls below that total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9504905c8f
commit
c6cdfc42d5
2 changed files with 208 additions and 1 deletions
|
|
@ -14,6 +14,7 @@ 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 { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.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";
|
||||||
|
|
@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const assignment of input.command.payload.rows) {
|
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
|
const updated = tx
|
||||||
.update(circuitDeviceRows)
|
.update(circuitDeviceRows)
|
||||||
.set(assignment.target)
|
.set(values)
|
||||||
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||||
.run();
|
.run();
|
||||||
if (updated.changes !== 1) {
|
if (updated.changes !== 1) {
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.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 { 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 { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceRowSyncProjectCommand,
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
type ProjectDeviceSyncRowSnapshot,
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
|
@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) {
|
||||||
return row;
|
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(
|
function snapshot(
|
||||||
context: DatabaseContext,
|
context: DatabaseContext,
|
||||||
rowId: string
|
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", () => {
|
it("rolls back synchronized rows for a stale project revision", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue