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:
parent
01312b89fe
commit
898be43ebc
12 changed files with 272 additions and 21 deletions
|
|
@ -597,6 +597,13 @@ Critical target rules:
|
||||||
remain canonical and are never silently changed.
|
remain canonical and are never silently changed.
|
||||||
- Multi-socket objects remain indivisible. Row quantity stays
|
- Multi-socket objects remain indivisible. Row quantity stays
|
||||||
`manualQuantity + sum(effectiveQuantity)`.
|
`manualQuantity + sum(effectiveQuantity)`.
|
||||||
|
- A row with linked Revit objects locks "Anzahl" in the editor. Its manual
|
||||||
|
share is edited in the optional column "Anz. manuell"; "Anz. Revit" shows
|
||||||
|
the linked share read-only. `circuit-device-row.update` derives the total
|
||||||
|
from a manual-only edit and rejects any quantity change that breaks
|
||||||
|
`quantity = manualQuantity + sum(effectiveQuantity)`, for every source.
|
||||||
|
- Undo/redo of a row assignment restores an accepted state and does not ask
|
||||||
|
again for planning-value conflict confirmation.
|
||||||
- The project configuration has no independent version counter. Preview and
|
- The project configuration has no independent version counter. Preview and
|
||||||
apply use `expectedRevision`; import batches store a configuration copy.
|
apply use `expectedRevision`; import batches store a configuration copy.
|
||||||
- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they
|
- Apply retransmits and parses the workbook. Undo/Redo never reads Excel; they
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertCircuitDeviceRowQuantity,
|
||||||
|
calculateCircuitDeviceRowQuantity,
|
||||||
|
} from "../../domain/calculations/circuit-device-row-quantity.js";
|
||||||
import {
|
import {
|
||||||
assertCircuitDeviceRowUpdateProjectCommand,
|
assertCircuitDeviceRowUpdateProjectCommand,
|
||||||
createCircuitDeviceRowUpdateProjectCommand,
|
createCircuitDeviceRowUpdateProjectCommand,
|
||||||
|
|
@ -20,6 +24,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 { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -90,13 +95,8 @@ export class CircuitDeviceRowProjectCommandRepository
|
||||||
change.value,
|
change.value,
|
||||||
])
|
])
|
||||||
) as CircuitDeviceRowPatchInput;
|
) as CircuitDeviceRowPatchInput;
|
||||||
const targetQuantity = patch.quantity ?? current.quantity;
|
if (patch.quantity !== undefined || patch.manualQuantity !== undefined) {
|
||||||
const targetManualQuantity =
|
this.applyQuantityPatch(tx, current, patch);
|
||||||
patch.manualQuantity ?? current.manualQuantity;
|
|
||||||
if (targetManualQuantity > targetQuantity) {
|
|
||||||
throw new Error(
|
|
||||||
"Device-row manual quantity must not exceed total quantity."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
input.source === "user" &&
|
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(
|
private assertLinkedProjectDevice(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||||
execute(input: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]) {
|
execute(input: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]) {
|
||||||
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
assertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
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);
|
return invertExternalObjectRowAssignmentProjectCommand(input.command);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +39,8 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||||
private apply(
|
private apply(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"]
|
command: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["command"],
|
||||||
|
source: Parameters<ExternalObjectRowAssignmentProjectCommandStore["execute"]>[0]["source"]
|
||||||
) {
|
) {
|
||||||
const rowTransitions = new Map(
|
const rowTransitions = new Map(
|
||||||
command.payload.rows.map((transition) => [transition.expected.id, transition])
|
command.payload.rows.map((transition) => [transition.expected.id, transition])
|
||||||
|
|
@ -95,7 +96,14 @@ export class ExternalObjectRowAssignmentProjectCommandRepository
|
||||||
const targetObjects = allProjectObjects.map((object) =>
|
const targetObjects = allProjectObjects.map((object) =>
|
||||||
objectTransitions.get(object.id)?.target ?? 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) {
|
for (const transition of command.payload.rows) {
|
||||||
const linkedObjects = targetObjects.filter(
|
const linkedObjects = targetObjects.filter(
|
||||||
|
|
|
||||||
|
|
@ -4641,7 +4641,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
isProtectionTrigger
|
cell.lockedReason
|
||||||
|
? cell.lockedReason
|
||||||
|
: isProtectionTrigger
|
||||||
? "Schutzgerät bearbeiten"
|
? "Schutzgerät bearbeiten"
|
||||||
: column.key === "equipmentIdentifier" &&
|
: column.key === "equipmentIdentifier" &&
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ export type CellKey =
|
||||||
| "equipmentIdentifier"
|
| "equipmentIdentifier"
|
||||||
| "displayName"
|
| "displayName"
|
||||||
| "quantity"
|
| "quantity"
|
||||||
|
| "manualQuantity"
|
||||||
|
| "externalQuantity"
|
||||||
| "powerPerUnit"
|
| "powerPerUnit"
|
||||||
| "simultaneityFactor"
|
| "simultaneityFactor"
|
||||||
| "rowTotalPower"
|
| "rowTotalPower"
|
||||||
|
|
@ -72,6 +74,18 @@ export const allColumns: ColumnDef[] = [
|
||||||
},
|
},
|
||||||
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
|
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
|
||||||
{ key: "quantity", label: "Anzahl", numeric: true, 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",
|
key: "powerPerUnit",
|
||||||
label: "P/Stk. [kW]",
|
label: "P/Stk. [kW]",
|
||||||
|
|
@ -166,10 +180,16 @@ export function getProjectColumnLayoutStorageKey(
|
||||||
export function normalizeColumnOrder(keys: CellKey[]): CellKey[] {
|
export function normalizeColumnOrder(keys: CellKey[]): CellKey[] {
|
||||||
const unique = [...new Set(keys)];
|
const unique = [...new Set(keys)];
|
||||||
const allKeys = allColumns.map((column) => column.key);
|
const allKeys = allColumns.map((column) => column.key);
|
||||||
const merged = [
|
// Columns missing from a stored layout follow their default predecessor.
|
||||||
...unique,
|
const merged = [...unique];
|
||||||
...allKeys.filter((key) => !unique.includes(key)),
|
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 [
|
return [
|
||||||
"equipmentIdentifier",
|
"equipmentIdentifier",
|
||||||
...merged.filter((key) => key !== "equipmentIdentifier"),
|
...merged.filter((key) => key !== "equipmentIdentifier"),
|
||||||
|
|
@ -228,6 +248,8 @@ export function parseStoredColumnLayout(
|
||||||
|
|
||||||
const deviceOnlyColumns = new Set<CellKey>([
|
const deviceOnlyColumns = new Set<CellKey>([
|
||||||
"quantity",
|
"quantity",
|
||||||
|
"manualQuantity",
|
||||||
|
"externalQuantity",
|
||||||
"powerPerUnit",
|
"powerPerUnit",
|
||||||
"simultaneityFactor",
|
"simultaneityFactor",
|
||||||
"rowTotalPower",
|
"rowTotalPower",
|
||||||
|
|
@ -274,6 +296,7 @@ export const deviceFieldKeys = new Set<CellKey>([
|
||||||
"roomNumberSnapshot",
|
"roomNumberSnapshot",
|
||||||
"roomNameSnapshot",
|
"roomNameSnapshot",
|
||||||
"quantity",
|
"quantity",
|
||||||
|
"manualQuantity",
|
||||||
"powerPerUnit",
|
"powerPerUnit",
|
||||||
"simultaneityFactor",
|
"simultaneityFactor",
|
||||||
"cosPhi",
|
"cosPhi",
|
||||||
|
|
@ -357,7 +380,7 @@ export function formatCellValue(value: GridValue, key: CellKey): string {
|
||||||
if (formatted === "-" || typeof value !== "number") {
|
if (formatted === "-" || typeof value !== "number") {
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
if (key === "quantity") {
|
if (key === "quantity" || key === "manualQuantity" || key === "externalQuantity") {
|
||||||
return `${formatted} St.`;
|
return `${formatted} St.`;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -442,6 +465,7 @@ export function buildDeviceRowEditPatch(
|
||||||
): CircuitDeviceRowUpdatePatch {
|
): CircuitDeviceRowUpdatePatch {
|
||||||
if (
|
if (
|
||||||
key === "quantity" ||
|
key === "quantity" ||
|
||||||
|
key === "manualQuantity" ||
|
||||||
key === "powerPerUnit" ||
|
key === "powerPerUnit" ||
|
||||||
key === "simultaneityFactor"
|
key === "simultaneityFactor"
|
||||||
) {
|
) {
|
||||||
|
|
@ -495,6 +519,8 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
|
||||||
case "roomNumberSnapshot": return device.roomNumberSnapshot;
|
case "roomNumberSnapshot": return device.roomNumberSnapshot;
|
||||||
case "roomNameSnapshot": return device.roomNameSnapshot;
|
case "roomNameSnapshot": return device.roomNameSnapshot;
|
||||||
case "quantity": return device.quantity;
|
case "quantity": return device.quantity;
|
||||||
|
case "manualQuantity": return device.manualQuantity;
|
||||||
|
case "externalQuantity": return device.quantity - device.manualQuantity;
|
||||||
case "powerPerUnit": return device.powerPerUnit;
|
case "powerPerUnit": return device.powerPerUnit;
|
||||||
case "simultaneityFactor": return device.simultaneityFactor;
|
case "simultaneityFactor": return device.simultaneityFactor;
|
||||||
case "cosPhi": return device.cosPhi;
|
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 {
|
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||||
if (
|
if (
|
||||||
|
key === "externalQuantity" ||
|
||||||
key === "rowTotalPower" ||
|
key === "rowTotalPower" ||
|
||||||
key === "circuitTotalPower" ||
|
key === "circuitTotalPower" ||
|
||||||
key === "protectionType" ||
|
key === "protectionType" ||
|
||||||
|
|
@ -609,6 +636,11 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||||
) return "computed";
|
) return "computed";
|
||||||
if (key === "voltage") return "readonly";
|
if (key === "voltage") return "readonly";
|
||||||
if (rowType === "section") 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 (rowType === "placeholder") {
|
||||||
if (deviceFieldKeys.has(key)) return "deviceField";
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
||||||
if (circuitFieldKeys.has(key)) return "circuitField";
|
if (circuitFieldKeys.has(key)) return "circuitField";
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export interface VisibleGridCell {
|
||||||
value: GridValue;
|
value: GridValue;
|
||||||
/** Separate persisted value when the displayed value is derived. */
|
/** Separate persisted value when the displayed value is derived. */
|
||||||
editValue?: GridValue;
|
editValue?: GridValue;
|
||||||
|
/** Why an otherwise editable cell is locked, shown as the cell tooltip. */
|
||||||
|
lockedReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisibleGridRow {
|
export interface VisibleGridRow {
|
||||||
|
|
@ -144,7 +146,15 @@ function makeVisibleGridRow(
|
||||||
|
|
||||||
const cells = allColumns.map((column): VisibleGridCell => {
|
const cells = allColumns.map((column): VisibleGridCell => {
|
||||||
const kind = getCellKind(rowType, column.key);
|
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 value: GridValue;
|
||||||
let editValue: GridValue | undefined;
|
let editValue: GridValue | undefined;
|
||||||
|
|
||||||
|
|
@ -200,6 +210,8 @@ function makeVisibleGridRow(
|
||||||
circuitOnlyColumns.has(column.key)
|
circuitOnlyColumns.has(column.key)
|
||||||
) {
|
) {
|
||||||
value = getCircuitValue(circuit, column.key);
|
value = getCircuitValue(circuit, column.key);
|
||||||
|
} else if (column.key === "externalQuantity" && device) {
|
||||||
|
value = getDeviceValue(device, column.key);
|
||||||
} else if (column.key === "rowTotalPower" && device) {
|
} else if (column.key === "rowTotalPower" && device) {
|
||||||
value = device.rowTotalPower;
|
value = device.rowTotalPower;
|
||||||
} else if (column.key === "rowTotalPower" && circuit) {
|
} else if (column.key === "rowTotalPower" && circuit) {
|
||||||
|
|
@ -210,7 +222,7 @@ function makeVisibleGridRow(
|
||||||
value = circuit.voltage;
|
value = circuit.voltage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cellKey: column.key, editable, kind, value, editValue };
|
return { cellKey: column.key, editable, kind, value, editValue, lockedReason };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
|
||||||
|
assert.throws(
|
||||||
|
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
|
quantity: 1,
|
||||||
|
manualQuantity: 2,
|
||||||
|
}),
|
||||||
|
/manualQuantity must not exceed quantity/
|
||||||
|
);
|
||||||
assert.throws(() => store.executeUpdate({
|
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",
|
projectId: "project-1",
|
||||||
expectedRevision: 0,
|
expectedRevision: 0,
|
||||||
source: "user",
|
source: "user",
|
||||||
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
|
||||||
manualQuantity: 2,
|
manualQuantity: 2,
|
||||||
}),
|
}),
|
||||||
}), /manual quantity/);
|
});
|
||||||
assert.equal(getRow(context).manualQuantity, 1);
|
assert.deepEqual([getRow(context).quantity, getRow(context).manualQuantity], [2, 2]);
|
||||||
} finally {
|
} finally {
|
||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
allColumns,
|
allColumns,
|
||||||
buildCircuitEditPatch,
|
buildCircuitEditPatch,
|
||||||
buildDeviceRowEditPatch,
|
buildDeviceRowEditPatch,
|
||||||
|
defaultVisibleColumnKeys,
|
||||||
formatValue,
|
formatValue,
|
||||||
formatCellValue,
|
formatCellValue,
|
||||||
getCircuitSectionLabel,
|
getCircuitSectionLabel,
|
||||||
|
|
@ -107,6 +108,34 @@ describe("circuit grid model", () => {
|
||||||
assert.equal(parseStoredColumnLayout("{invalid"), null);
|
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", () => {
|
it("shows stable circuit sections with German labels", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getCircuitSectionLabel({
|
getCircuitSectionLabel({
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,27 @@ describe("circuit grid projection", () => {
|
||||||
assert.deepEqual(result[0].circuits[1].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
|
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", () => {
|
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
|
||||||
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] });
|
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { 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 { 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 { 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 { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||||
import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-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 { floors } from "../src/db/schema/floors.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
import { rooms } from "../src/db/schema/rooms.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 { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
createExternalObjectNewRowProjectCommand,
|
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", () => {
|
it("rolls back row quantities and links when history persistence fails", () => {
|
||||||
const context = createFixture();
|
const context = createFixture();
|
||||||
try {
|
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", () => {
|
describe("external object new-row project command", () => {
|
||||||
it("creates an external-only row and supports exact persisted undo and redo", () => {
|
it("creates an external-only row and supports exact persisted undo and redo", () => {
|
||||||
const context = createFixture(null);
|
const context = createFixture(null);
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ function createTestDatabase(): DatabaseContext {
|
||||||
name: "Leuchte",
|
name: "Leuchte",
|
||||||
displayName: "Leuchte",
|
displayName: "Leuchte",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
manualQuantity: 1,
|
||||||
powerPerUnit: 0.1,
|
powerPerUnit: 0.1,
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ function createTestDatabase(): DatabaseContext {
|
||||||
name: "Leuchte",
|
name: "Leuchte",
|
||||||
displayName: "Leuchte",
|
displayName: "Leuchte",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
manualQuantity: 1,
|
||||||
powerPerUnit: 0.1,
|
powerPerUnit: 0.1,
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue