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

@ -1,4 +1,8 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitDeviceRowQuantity,
calculateCircuitDeviceRowQuantity,
} from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
@ -20,6 +24,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 { rooms } from "../schema/rooms.js";
import {
@ -90,13 +95,8 @@ export class CircuitDeviceRowProjectCommandRepository
change.value,
])
) as CircuitDeviceRowPatchInput;
const targetQuantity = patch.quantity ?? current.quantity;
const targetManualQuantity =
patch.manualQuantity ?? current.manualQuantity;
if (targetManualQuantity > targetQuantity) {
throw new Error(
"Device-row manual quantity must not exceed total quantity."
);
if (patch.quantity !== undefined || patch.manualQuantity !== undefined) {
this.applyQuantityPatch(tx, current, patch);
}
if (
input.source === "user" &&
@ -166,6 +166,46 @@ export class CircuitDeviceRowProjectCommandRepository
};
}
// The row quantity is always the manual share plus every linked Revit
// object. A manual-share edit derives the total; a total edit is only
// valid while no Revit object contributes to the row.
private applyQuantityPatch(
database: AppDatabase,
current: CircuitDeviceRow,
patch: CircuitDeviceRowPatchInput
) {
const externalObjects = database
.select({ planningValues: externalModelObjects.planningValues })
.from(externalModelObjects)
.where(eq(externalModelObjects.circuitDeviceRowId, current.id))
.all()
.map((object) => ({
effectiveQuantity: object.planningValues.effectiveQuantity,
}));
const manualQuantity = patch.manualQuantity ?? current.manualQuantity;
if (patch.quantity === undefined) {
patch.quantity = calculateCircuitDeviceRowQuantity(
manualQuantity,
externalObjects
);
return;
}
if (
externalObjects.length > 0 &&
patch.quantity !==
calculateCircuitDeviceRowQuantity(manualQuantity, externalObjects)
) {
throw new Error(
"Die Anzahl dieser Gerätezeile ist mit Revit-Objekten verknüpft. Bitte die manuelle Anzahl bearbeiten."
);
}
assertCircuitDeviceRowQuantity({
quantity: patch.quantity,
manualQuantity,
externalObjects,
});
}
private assertLinkedProjectDevice(
database: AppDatabase,
projectId: string,

View file

@ -31,7 +31,7 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
execute(input: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]) {
assertExternalObjectRowAssignmentProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
this.apply(tx, input.projectId, input.command);
this.apply(tx, input.projectId, input.command, input.source);
return invertExternalObjectRowAssignmentProjectCommand(input.command);
});
}
@ -39,7 +39,8 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
private apply(
database: AppDatabase,
projectId: string,
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"]
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"],
source: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["source"]
) {
const rowTransitions = new Map(
command.payload.rows.map((transition) => [transition.expected.id, transition])
@ -95,7 +96,14 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
const targetObjects = allProjectObjects.map((object) =>
objectTransitions.get(object.id)?.target ?? object
);
const confirmedConflicts = new Set(command.payload.confirmedConflictObjectIds);
// Undo/redo restores an exact state the user already accepted, for example
// re-linking objects after "Revit-Objekte lösen". Only a new user
// assignment needs explicit confirmation of planning-value conflicts.
const confirmedConflicts = new Set(
source === "user"
? command.payload.confirmedConflictObjectIds
: command.payload.objects.map(({ target }) => target.id)
);
for (const transition of command.payload.rows) {
const linkedObjects = targetObjects.filter(

View file

@ -4641,7 +4641,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: ""
}`}
title={
isProtectionTrigger
cell.lockedReason
? cell.lockedReason
: isProtectionTrigger
? "Schutzgerät bearbeiten"
: column.key === "equipmentIdentifier" &&
row.circuit &&

View file

@ -7,6 +7,8 @@ export type CellKey =
| "equipmentIdentifier"
| "displayName"
| "quantity"
| "manualQuantity"
| "externalQuantity"
| "powerPerUnit"
| "simultaneityFactor"
| "rowTotalPower"
@ -72,6 +74,18 @@ export const allColumns: ColumnDef[] = [
},
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
{ key: "quantity", label: "Anzahl", numeric: true, defaultVisible: true },
{
key: "manualQuantity",
label: "Anz. manuell",
fullLabel: "Anzahl manuell",
numeric: true,
},
{
key: "externalQuantity",
label: "Anz. Revit",
fullLabel: "Anzahl aus verknüpften Revit-Objekten",
numeric: true,
},
{
key: "powerPerUnit",
label: "P/Stk. [kW]",
@ -166,10 +180,16 @@ export function getProjectColumnLayoutStorageKey(
export function normalizeColumnOrder(keys: CellKey[]): CellKey[] {
const unique = [...new Set(keys)];
const allKeys = allColumns.map((column) => column.key);
const merged = [
...unique,
...allKeys.filter((key) => !unique.includes(key)),
];
// Columns missing from a stored layout follow their default predecessor.
const merged = [...unique];
allKeys.forEach((key, index) => {
if (merged.includes(key)) return;
const predecessor = allKeys
.slice(0, index)
.reverse()
.find((candidate) => merged.includes(candidate));
merged.splice(predecessor ? merged.indexOf(predecessor) + 1 : merged.length, 0, key);
});
return [
"equipmentIdentifier",
...merged.filter((key) => key !== "equipmentIdentifier"),
@ -228,6 +248,8 @@ export function parseStoredColumnLayout(
const deviceOnlyColumns = new Set<CellKey>([
"quantity",
"manualQuantity",
"externalQuantity",
"powerPerUnit",
"simultaneityFactor",
"rowTotalPower",
@ -274,6 +296,7 @@ export const deviceFieldKeys = new Set<CellKey>([
"roomNumberSnapshot",
"roomNameSnapshot",
"quantity",
"manualQuantity",
"powerPerUnit",
"simultaneityFactor",
"cosPhi",
@ -357,7 +380,7 @@ export function formatCellValue(value: GridValue, key: CellKey): string {
if (formatted === "-" || typeof value !== "number") {
return formatted;
}
if (key === "quantity") {
if (key === "quantity" || key === "manualQuantity" || key === "externalQuantity") {
return `${formatted} St.`;
}
if (
@ -442,6 +465,7 @@ export function buildDeviceRowEditPatch(
): CircuitDeviceRowUpdatePatch {
if (
key === "quantity" ||
key === "manualQuantity" ||
key === "powerPerUnit" ||
key === "simultaneityFactor"
) {
@ -495,6 +519,8 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
case "roomNumberSnapshot": return device.roomNumberSnapshot;
case "roomNameSnapshot": return device.roomNameSnapshot;
case "quantity": return device.quantity;
case "manualQuantity": return device.manualQuantity;
case "externalQuantity": return device.quantity - device.manualQuantity;
case "powerPerUnit": return device.powerPerUnit;
case "simultaneityFactor": return device.simultaneityFactor;
case "cosPhi": return device.cosPhi;
@ -600,6 +626,7 @@ export function compareSortValues(left: GridValue, right: GridValue): number {
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (
key === "externalQuantity" ||
key === "rowTotalPower" ||
key === "circuitTotalPower" ||
key === "protectionType" ||
@ -609,6 +636,11 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
) return "computed";
if (key === "voltage") return "readonly";
if (rowType === "section") return "readonly";
// The manual share belongs to an existing device row; new rows start from "Anzahl".
if (
key === "manualQuantity" &&
(rowType === "placeholder" || rowType === "reserveCircuit")
) return "readonly";
if (rowType === "placeholder") {
if (deviceFieldKeys.has(key)) return "deviceField";
if (circuitFieldKeys.has(key)) return "circuitField";

View file

@ -39,6 +39,8 @@ export interface VisibleGridCell {
value: GridValue;
/** Separate persisted value when the displayed value is derived. */
editValue?: GridValue;
/** Why an otherwise editable cell is locked, shown as the cell tooltip. */
lockedReason?: string;
}
export interface VisibleGridRow {
@ -144,7 +146,15 @@ function makeVisibleGridRow(
const cells = allColumns.map((column): VisibleGridCell => {
const kind = getCellKind(rowType, column.key);
const editable = kind === "circuitField" || kind === "deviceField";
// Linked Revit objects own part of the quantity; only the manual share stays editable.
const lockedReason =
column.key === "quantity" &&
kind === "deviceField" &&
(device?.externalObjectCount ?? 0) > 0
? "Mit Revit verknüpft – über „Anz. manuell“ bearbeiten."
: undefined;
const editable =
(kind === "circuitField" || kind === "deviceField") && !lockedReason;
let value: GridValue;
let editValue: GridValue | undefined;
@ -200,6 +210,8 @@ function makeVisibleGridRow(
circuitOnlyColumns.has(column.key)
) {
value = getCircuitValue(circuit, column.key);
} else if (column.key === "externalQuantity" && device) {
value = getDeviceValue(device, column.key);
} else if (column.key === "rowTotalPower" && device) {
value = device.rowTotalPower;
} else if (column.key === "rowTotalPower" && circuit) {
@ -210,7 +222,7 @@ function makeVisibleGridRow(
value = circuit.voltage;
}
return { cellKey: column.key, editable, kind, value, editValue };
return { cellKey: column.key, editable, kind, value, editValue, lockedReason };
});
return {