Keep Revit and manual row quantities consistent

Editing "Anzahl" on a row with linked Revit objects set manualQuantity to
the new total, so the row no longer matched its objects and detaching
them failed with "row.manualQuantity must not exceed row.quantity".

Such rows now lock "Anzahl" in the editor. The new optional columns
"Anz. manuell" (editable) and "Anz. Revit" (read-only) show both shares;
stored column layouts get them hidden next to "Anzahl". The device-row
update derives the total from a manual-only edit and rejects any change
that breaks quantity = manual + Revit, for every source. Two history
fixtures inserted rows with quantity 1 and the column default
manualQuantity 0, a state no runtime path creates; they now insert 1/1.

Undoing "Revit-Objekte lösen" re-validated planning-value conflicts that
the user had confirmed on the original assignment and was rejected.
Undo/redo of row assignments now restores the accepted state without a
new confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-29 14:03:29 +01:00
parent 01312b89fe
commit 898be43ebc
12 changed files with 272 additions and 21 deletions

View file

@ -384,19 +384,37 @@ describe("circuit device-row project-command repository", () => {
}
});
it("rejects a manual share above the materialized total", () => {
it("derives the total from a manual-share edit and rejects inconsistent quantity pairs", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 1,
manualQuantity: 2,
}),
/manualQuantity must not exceed quantity/
);
assert.throws(() => store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 3,
manualQuantity: 2,
}),
}), /does not match manual and external quantity/);
assert.deepEqual([getRow(context).quantity, getRow(context).manualQuantity], [1, 1]);
store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
manualQuantity: 2,
}),
}), /manual quantity/);
assert.equal(getRow(context).manualQuantity, 1);
});
assert.deepEqual([getRow(context).quantity, getRow(context).manualQuantity], [2, 2]);
} finally {
context.close();
}

View file

@ -4,6 +4,7 @@ import {
allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
defaultVisibleColumnKeys,
formatValue,
formatCellValue,
getCircuitSectionLabel,
@ -107,6 +108,34 @@ describe("circuit grid model", () => {
assert.equal(parseStoredColumnLayout("{invalid"), null);
});
it("adds new quantity columns hidden and next to Anzahl in stored layouts", () => {
const layout = parseStoredColumnLayout(
JSON.stringify({
order: ["equipmentIdentifier", "remark", "quantity", "displayName"],
visible: ["equipmentIdentifier", "quantity"],
})
);
assert.ok(layout);
const quantityIndex = layout.order.indexOf("quantity");
assert.deepEqual(
layout.order.slice(quantityIndex, quantityIndex + 3),
["quantity", "manualQuantity", "externalQuantity"]
);
assert.deepEqual(layout.visible, ["equipmentIdentifier", "quantity"]);
assert.equal(defaultVisibleColumnKeys.includes("manualQuantity"), false);
assert.equal(defaultVisibleColumnKeys.includes("externalQuantity"), false);
});
it("edits the manual share only on existing device rows", () => {
assert.equal(getCellKind("deviceRow", "manualQuantity"), "deviceField");
assert.equal(getCellKind("circuitCompact", "manualQuantity"), "deviceField");
assert.equal(getCellKind("placeholder", "manualQuantity"), "readonly");
assert.equal(getCellKind("reserveCircuit", "manualQuantity"), "readonly");
assert.equal(getCellKind("deviceRow", "externalQuantity"), "computed");
assert.deepEqual(buildDeviceRowEditPatch("manualQuantity", "2"), { manualQuantity: 2 });
assert.equal(formatCellValue(2, "externalQuantity"), "2 St.");
});
it("shows stable circuit sections with German labels", () => {
assert.equal(
getCircuitSectionLabel({

View file

@ -158,6 +158,27 @@ describe("circuit grid projection", () => {
assert.deepEqual(result[0].circuits[1].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
});
it("locks the total quantity of Revit-linked rows and exposes the manual and Revit shares", () => {
const linkedDevice = { ...makeDevice("device-linked", "Steckdose", "1.04", 10), quantity: 3, manualQuantity: 1, externalObjectCount: 1 };
const linkedSections: CircuitTreeSectionDto[] = [{
...sections[0],
circuits: [makeCircuit("circuit-linked", "-1F4", "Steckdosen", [linkedDevice, makeDevice("device-manual", "Leuchte", "1.05", 20)], 10)],
}];
const rows = buildVisibleGridRowsWithStructure(linkedSections, { headerComponents: [], footerComponents: [] });
const cell = (deviceId: string, key: string) =>
rows.find((row) => row.device?.id === deviceId)!.cells.find((entry) => entry.cellKey === key)!;
assert.equal(cell("device-linked", "quantity").editable, false);
assert.match(cell("device-linked", "quantity").lockedReason ?? "", /Anz\. manuell/);
assert.equal(cell("device-linked", "manualQuantity").editable, true);
assert.equal(cell("device-linked", "manualQuantity").value, 1);
assert.equal(cell("device-linked", "externalQuantity").editable, false);
assert.equal(cell("device-linked", "externalQuantity").value, 2);
assert.equal(cell("device-manual", "quantity").editable, true);
assert.equal(cell("device-manual", "quantity").lockedReason, undefined);
assert.equal(cell("device-manual", "externalQuantity").value, 0);
});
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] });

View file

@ -7,6 +7,7 @@ import { createDatabaseContext, type AppDatabase } from "../src/db/database-cont
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 { ExternalObjectNewCircuitProjectCommandRepository } from "../src/db/repositories/external-object-new-circuit-project-command.repository.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js";
@ -24,6 +25,7 @@ import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js
import { floors } from "../src/db/schema/floors.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js";
import {
createExternalObjectNewRowProjectCommand,
@ -223,6 +225,36 @@ describe("external object row assignment project command", () => {
}
});
it("re-links a detached object on undo although its planning values differ from the row", () => {
const context = createFixture();
try {
// The original assignment confirmed this planning-value conflict.
context.db.update(circuitDeviceRows).set({ displayName: "Abweichend" }).where(eq(circuitDeviceRows.id, "row-1")).run();
const currentRow = row(context.db, "row-1");
const object = objectSnapshot("row-1");
const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db);
const detached = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalObjectRowAssignmentProjectCommand({
rows: [{ expected: currentRow, target: { ...currentRow, quantity: 1 } }],
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: null } }],
confirmedConflictObjectIds: [],
}),
});
const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: detached.revision.changeSetId, command: detached.inverse });
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1");
assert.deepEqual([row(context.db, "row-1").quantity, row(context.db, "row-1").manualQuantity], [3, 1]);
repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: detached.revision.changeSetId, command: undone.inverse });
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
} finally {
context.close();
}
});
it("rolls back row quantities and links when history persistence fails", () => {
const context = createFixture();
try {
@ -245,6 +277,54 @@ describe("external object row assignment project command", () => {
});
});
describe("device-row quantity edits with linked Revit objects", () => {
const quantities = (database: AppDatabase, id: string) => {
const current = row(database, id);
return [current.quantity, current.manualQuantity];
};
it("rejects a total edit on a linked row and keeps Revit and manual share consistent", () => {
const context = createFixture();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(
() => store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 4 }) }),
/mit Revit-Objekten verknüpft/
);
assert.deepEqual(quantities(context.db, "row-1"), [3, 1]);
} finally {
context.close();
}
});
it("derives the total from a manual-share edit through undo and redo", () => {
const context = createFixture();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
const edited = store.executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { manualQuantity: 2 }) });
assert.deepEqual(quantities(context.db, "row-1"), [4, 2]);
const undone = store.executeUpdate({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: edited.revision.changeSetId, command: edited.inverse });
assert.deepEqual(quantities(context.db, "row-1"), [3, 1]);
store.executeUpdate({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: edited.revision.changeSetId, command: undone.inverse });
assert.deepEqual(quantities(context.db, "row-1"), [4, 2]);
} finally {
context.close();
}
});
it("keeps editing the total of a row without Revit objects", () => {
const context = createFixture(null);
try {
new CircuitDeviceRowProjectCommandRepository(context.db).executeUpdate({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitDeviceRowUpdateProjectCommand("row-1", { quantity: 5 }) });
assert.deepEqual(quantities(context.db, "row-1"), [5, 5]);
} finally {
context.close();
}
});
});
describe("external object new-row project command", () => {
it("creates an external-only row and supports exact persisted undo and redo", () => {
const context = createFixture(null);

View file

@ -132,6 +132,7 @@ function createTestDatabase(): DatabaseContext {
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})

View file

@ -59,6 +59,7 @@ function createTestDatabase(): DatabaseContext {
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})