Derive device voltages from project settings

This commit is contained in:
2026-07-29 09:53:58 +02:00
parent b1a11397b3
commit 084103bf54
39 changed files with 2696 additions and 76 deletions
+5 -1
View File
@@ -150,7 +150,11 @@ synchronizes linked circuit rows implicitly.
- response: - response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }` `{ "project": { ... }, "revision": { ... }, "history": { ... } }`
- persistent Undo/Redo restores metadata, voltage values and the enabled - persistent Undo/Redo restores metadata, voltage values and the enabled
distribution-board supply types together distribution-board supply types together; project-device and circuit
voltages are recalculated from the restored project settings in the same
transaction
- project-device and circuit voltage are derived values and are not accepted
as editable frontend fields
- at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled; - at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled;
a supply type currently used by a distribution board cannot be disabled a supply type currently used by a distribution board cannot be disabled
- unchanged values are rejected without creating a revision; a stale - unchanged values are rejected without creating a revision; a stale
@@ -265,7 +265,9 @@ Completed foundation:
snapshots in the same transaction snapshots in the same transaction
- `project.update-settings` persists both project voltage defaults, its exact - `project.update-settings` persists both project voltage defaults, its exact
inverse, revision and history transition atomically; the project page and inverse, revision and history transition atomically; the project page and
existing PUT route require the current expected revision existing PUT route require the current expected revision; the same
transaction derives every project-device and circuit voltage again, so
Undo/Redo cannot leave mixed voltage states
- `distribution-board.insert` persists the complete generated board, circuit - `distribution-board.insert` persists the complete generated board, circuit
list and default-section structure with stable ids; its delete inverse list and default-section structure with stable ids; its delete inverse
refuses changed or populated structures and the project-page POST tracks the refuses changed or populated structures and the project-page POST tracks the
+13 -7
View File
@@ -35,21 +35,27 @@ requirements and intended sequencing, not proof of implementation.
- Persist changes through project commands so they remain undoable after a - Persist changes through project commands so they remain undoable after a
restart. restart.
## Device Voltage Defaults ## Device Voltage Derivation
- Derive voltage from the selected phase type and the project settings: - Derive voltage from the selected phase type and the project settings:
- single-phase uses the project's single-phase voltage; - single-phase uses the project's single-phase voltage;
- three-phase uses the project's three-phase voltage. - three-phase uses the project's three-phase voltage.
- Apply this default consistently when creating global devices, project - Voltage is not editable on global devices, project devices or circuits.
devices and manual circuit-list entries. Global devices store only their phase; copying one into a project derives
- Define explicit override behavior before implementation so a user-entered the target project's voltage.
exceptional voltage is not silently overwritten when phase type or project - Project-device voltage follows its phase. Circuit voltage follows the
settings change. section phase; an unassigned circuit is three-phase only when all assigned
device rows with a valid phase are three-phase.
- Changing project voltage settings updates all project devices and circuits
atomically in the same persistent Undo/Redo step.
- Database migration `0019` and snapshot schema version `5` normalize older
stored values. Version-four and older imports remain supported and are
normalized while being upgraded.
## Recommended Sequence ## Recommended Sequence
1. Distribution-board floor and supply fields. 1. Distribution-board floor and supply fields.
2. Device voltage default and override semantics. 2. Device voltage derivation without overrides.
3. Snapshot-to-revision descriptions and history presentation. 3. Snapshot-to-revision descriptions and history presentation.
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
-5
View File
@@ -24,7 +24,6 @@ const emptyGlobalDevice: CreateGlobalDeviceInput = {
quantity: 1, quantity: 1,
installedPowerPerUnitKw: 0.1, installedPowerPerUnitKw: 0.1,
demandFactor: 1, demandFactor: 1,
voltageV: 230,
phaseCount: 1, phaseCount: 1,
powerFactor: 1, powerFactor: 1,
note: "", note: "",
@@ -49,7 +48,6 @@ export default function ProjectsPage() {
quantity: "1", quantity: "1",
installedPowerPerUnitKw: "0.1", installedPowerPerUnitKw: "0.1",
demandFactor: "1", demandFactor: "1",
voltageV: "230",
phaseCount: "1", phaseCount: "1",
powerFactor: "1", powerFactor: "1",
note: "", note: "",
@@ -153,7 +151,6 @@ export default function ProjectsPage() {
quantity: Number(globalDeviceForm.quantity), quantity: Number(globalDeviceForm.quantity),
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw), installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
demandFactor: Number(globalDeviceForm.demandFactor), demandFactor: Number(globalDeviceForm.demandFactor),
voltageV: toOptionalNumber(globalDeviceForm.voltageV),
phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1, phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1,
powerFactor: toOptionalNumber(globalDeviceForm.powerFactor), powerFactor: toOptionalNumber(globalDeviceForm.powerFactor),
note: globalDeviceForm.note.trim() || undefined, note: globalDeviceForm.note.trim() || undefined,
@@ -170,7 +167,6 @@ export default function ProjectsPage() {
quantity: String(emptyGlobalDevice.quantity), quantity: String(emptyGlobalDevice.quantity),
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw), installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
demandFactor: String(emptyGlobalDevice.demandFactor), demandFactor: String(emptyGlobalDevice.demandFactor),
voltageV: String(emptyGlobalDevice.voltageV ?? ""),
phaseCount: String(emptyGlobalDevice.phaseCount ?? 1), phaseCount: String(emptyGlobalDevice.phaseCount ?? 1),
powerFactor: String(emptyGlobalDevice.powerFactor ?? ""), powerFactor: String(emptyGlobalDevice.powerFactor ?? ""),
note: emptyGlobalDevice.note ?? "", note: emptyGlobalDevice.note ?? "",
@@ -207,7 +203,6 @@ export default function ProjectsPage() {
quantity: device.quantity, quantity: device.quantity,
installedPowerPerUnitKw: device.installedPowerPerUnitKw, installedPowerPerUnitKw: device.installedPowerPerUnitKw,
demandFactor: device.demandFactor, demandFactor: device.demandFactor,
voltageV: device.voltageV ?? undefined,
phaseCount: device.phaseCount ?? undefined, phaseCount: device.phaseCount ?? undefined,
powerFactor: device.powerFactor ?? undefined, powerFactor: device.powerFactor ?? undefined,
note: device.note ?? undefined, note: device.note ?? undefined,
@@ -0,0 +1,48 @@
UPDATE `global_devices`
SET `voltage_v` = NULL;
--> statement-breakpoint
UPDATE `project_devices` AS `pd`
SET `voltage_v` = CASE
WHEN `pd`.`phase_type` = 'three_phase' THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END
FROM `projects` AS `p`
WHERE `p`.`id` = `pd`.`project_id`;
--> statement-breakpoint
WITH `derived_circuit_voltages` AS (
SELECT
`c`.`id` AS `circuit_id`,
CASE
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
WHEN EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `three_phase_row`
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
AND `three_phase_row`.`phase_type` = 'three_phase'
) AND NOT EXISTS (
SELECT 1
FROM `circuit_device_rows` AS `other_row`
WHERE `other_row`.`circuit_id` = `c`.`id`
AND `other_row`.`phase_type` = 'single_phase'
) THEN `p`.`three_phase_voltage_v`
ELSE `p`.`single_phase_voltage_v`
END AS `derived_voltage`
FROM `circuits` AS `c`
INNER JOIN `circuit_lists` AS `cl`
ON `cl`.`id` = `c`.`circuit_list_id`
INNER JOIN `projects` AS `p`
ON `p`.`id` = `cl`.`project_id`
INNER JOIN `circuit_sections` AS `cs`
ON `cs`.`id` = `c`.`section_id`
)
UPDATE `circuits`
SET `voltage` = (
SELECT `derived_voltage`
FROM `derived_circuit_voltages`
WHERE `circuit_id` = `circuits`.`id`
)
WHERE `id` IN (
SELECT `circuit_id`
FROM `derived_circuit_voltages`
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -134,6 +134,13 @@
"when": 1785308458789, "when": 1785308458789,
"tag": "0018_fancy_argent", "tag": "0018_fancy_argent",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1785310907349,
"tag": "0019_normalize_project_voltages",
"breakpoints": true
} }
] ]
} }
@@ -20,11 +20,14 @@ import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js"; import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.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 { resolveCircuitVoltage } from "./project-voltage.persistence.js";
interface PersistedMoveRow { interface PersistedMoveRow {
id: string; id: string;
circuitId: string; circuitId: string;
sortOrder: number; sortOrder: number;
phaseType: string | null;
} }
export class CircuitDeviceRowMoveProjectCommandRepository export class CircuitDeviceRowMoveProjectCommandRepository
@@ -85,6 +88,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
); );
this.applyMoves(database, command.payload.moves); this.applyMoves(database, command.payload.moves);
this.updateReserveStates(database, circuitIds); this.updateReserveStates(database, circuitIds);
this.updateVoltages(database, projectId, circuitIds);
return inverse; return inverse;
} }
@@ -94,7 +98,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
const { targetCircuit, targetCircuitAction, moves } = const { targetCircuit, targetCircuitAction, moves } =
command.payload; command.payload;
const rowsById = this.loadExpectedRows(database, moves); const rowsById = this.loadExpectedRows(database, moves);
this.assertCircuitLocation(database, projectId, targetCircuit); const appliedTargetCircuit = {
...targetCircuit,
voltage: resolveCircuitVoltage(
database,
projectId,
targetCircuit.sectionId,
moves.map((move) => rowsById.get(move.rowId)?.phaseType)
),
};
this.assertCircuitLocation(database, projectId, appliedTargetCircuit);
if (targetCircuitAction === "create") { if (targetCircuitAction === "create") {
const sourceCircuitIds = [ const sourceCircuitIds = [
@@ -106,13 +119,13 @@ export class CircuitDeviceRowMoveProjectCommandRepository
sourceCircuitIds, sourceCircuitIds,
targetCircuit.circuitListId targetCircuit.circuitListId
); );
this.assertTargetCircuitAvailable(database, targetCircuit); this.assertTargetCircuitAvailable(database, appliedTargetCircuit);
this.insertTargetCircuit(database, targetCircuit); this.insertTargetCircuit(database, appliedTargetCircuit);
const inverse = const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand( createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete", "delete",
targetCircuit, appliedTargetCircuit,
this.reverseMoves(moves, rowsById) this.reverseMoves(moves, rowsById)
); );
this.applyMoves(database, moves); this.applyMoves(database, moves);
@@ -120,12 +133,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
...sourceCircuitIds, ...sourceCircuitIds,
targetCircuit.id, targetCircuit.id,
]); ]);
this.updateVoltages(database, projectId, [
...sourceCircuitIds,
targetCircuit.id,
]);
return inverse; return inverse;
} }
this.assertTargetCircuitUnchanged( this.assertTargetCircuitUnchanged(
database, database,
targetCircuit, appliedTargetCircuit,
moves.map((move) => move.rowId) moves.map((move) => move.rowId)
); );
const destinationCircuitIds = [ const destinationCircuitIds = [
@@ -140,7 +157,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
const inverse = const inverse =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand( createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create", "create",
targetCircuit, appliedTargetCircuit,
this.reverseMoves(moves, rowsById) this.reverseMoves(moves, rowsById)
); );
this.applyMoves(database, moves); this.applyMoves(database, moves);
@@ -148,6 +165,11 @@ export class CircuitDeviceRowMoveProjectCommandRepository
targetCircuit.id, targetCircuit.id,
...destinationCircuitIds, ...destinationCircuitIds,
]); ]);
this.updateVoltages(
database,
projectId,
destinationCircuitIds
);
const deleted = database const deleted = database
.delete(circuits) .delete(circuits)
.where( .where(
@@ -169,6 +191,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
return inverse; return inverse;
} }
private updateVoltages(
database: AppDatabase,
projectId: string,
circuitIds: string[]
) {
for (const circuitId of new Set(circuitIds)) {
updateDerivedCircuitVoltage(database, projectId, circuitId);
}
}
private loadExpectedRows( private loadExpectedRows(
database: AppDatabase, database: AppDatabase,
moves: CircuitDeviceRowMoveAssignment[] moves: CircuitDeviceRowMoveAssignment[]
@@ -179,6 +211,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
id: circuitDeviceRows.id, id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId, circuitId: circuitDeviceRows.circuitId,
sortOrder: circuitDeviceRows.sortOrder, sortOrder: circuitDeviceRows.sortOrder,
phaseType: circuitDeviceRows.phaseType,
}) })
.from(circuitDeviceRows) .from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds)) .where(inArray(circuitDeviceRows.id, rowIds))
@@ -22,6 +22,7 @@ import {
type CircuitDeviceRowPatchInput, type CircuitDeviceRowPatchInput,
} from "./circuit-device-row.persistence.js"; } from "./circuit-device-row.persistence.js";
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -117,6 +118,13 @@ export class CircuitDeviceRowProjectCommandRepository
if (update.changes !== 1) { if (update.changes !== 1) {
throw new Error("Circuit device row changed before command execution."); throw new Error("Circuit device row changed before command execution.");
} }
if (patch.phaseType !== undefined) {
updateDerivedCircuitVoltage(
tx,
input.projectId,
current.circuitId
);
}
return { return {
forward: appliedForward, forward: appliedForward,
@@ -22,6 +22,7 @@ import {
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
export class CircuitDeviceRowStructureProjectCommandRepository export class CircuitDeviceRowStructureProjectCommandRepository
implements CircuitDeviceRowStructureProjectCommandStore implements CircuitDeviceRowStructureProjectCommandStore
@@ -101,6 +102,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
if (circuitUpdate.changes !== 1) { if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row insertion."); throw new Error("Circuit changed before device-row insertion.");
} }
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
return createCircuitDeviceRowDeleteProjectCommand( return createCircuitDeviceRowDeleteProjectCommand(
row.id, row.id,
@@ -153,6 +155,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
if (circuitUpdate.changes !== 1) { if (circuitUpdate.changes !== 1) {
throw new Error("Circuit changed before device-row deletion."); throw new Error("Circuit changed before device-row deletion.");
} }
updateDerivedCircuitVoltage(
database,
projectId,
expectedCircuitId
);
return createCircuitDeviceRowInsertProjectCommand( return createCircuitDeviceRowInsertProjectCommand(
toCircuitDeviceRowSnapshot(row) toCircuitDeviceRowSnapshot(row)
@@ -18,7 +18,9 @@ import {
toCircuitPatchValues, toCircuitPatchValues,
type CircuitPatchPersistenceInput, type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js"; } from "./circuit.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
type CircuitRow = typeof circuits.$inferSelect; type CircuitRow = typeof circuits.$inferSelect;
@@ -30,7 +32,7 @@ export class CircuitProjectCommandRepository
executeUpdate(input: ExecuteCircuitUpdateCommandInput) { executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
assertCircuitUpdateProjectCommand(input.command); assertCircuitUpdateProjectCommand(input.command);
return executeProjectCommandTransaction( return executeProjectCommandTransactionWithAppliedForward(
this.database, this.database,
input, input,
(tx) => this.applyCommand(tx, input) (tx) => this.applyCommand(tx, input)
@@ -71,14 +73,44 @@ export class CircuitProjectCommandRepository
]) ])
) as CircuitPatchPersistenceInput; ) as CircuitPatchPersistenceInput;
this.assertSectionInCircuitList(tx, current, patch.sectionId); this.assertSectionInCircuitList(tx, current, patch.sectionId);
if (input.source === "user") {
if (
input.command.payload.changes.every(
(change) => change.field === "voltage"
)
) {
throw new Error("Circuit voltage is derived and cannot be edited.");
}
const devicePhaseTypes = tx
.select({ phaseType: circuitDeviceRows.phaseType })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, current.id))
.all()
.map((row) => row.phaseType);
const derivedVoltage = resolveCircuitVoltage(
tx,
input.projectId,
patch.sectionId ?? current.sectionId,
devicePhaseTypes
);
if (derivedVoltage === current.voltage) {
delete patch.voltage;
} else {
patch.voltage = derivedVoltage;
}
}
this.assertUniqueEquipmentIdentifier( this.assertUniqueEquipmentIdentifier(
tx, tx,
current, current,
patch.equipmentIdentifier patch.equipmentIdentifier
); );
const appliedForward = createCircuitUpdateProjectCommand(
current.id,
patch as CircuitUpdatePatch
);
const inversePatch = Object.fromEntries( const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [ appliedForward.payload.changes.map((change) => [
change.field, change.field,
getCircuitFieldValue(current, change.field), getCircuitFieldValue(current, change.field),
]) ])
@@ -97,7 +129,7 @@ export class CircuitProjectCommandRepository
throw new Error("Circuit changed before command execution."); throw new Error("Circuit changed before command execution.");
} }
return inverse; return { forward: appliedForward, inverse };
} }
private assertSectionInCircuitList( private assertSectionInCircuitList(
@@ -23,6 +23,7 @@ import {
toCircuitDeviceRowSnapshot, toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js"; } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
export class CircuitStructureProjectCommandRepository export class CircuitStructureProjectCommandRepository
implements CircuitStructureProjectCommandStore implements CircuitStructureProjectCommandStore
@@ -77,6 +78,19 @@ export class CircuitStructureProjectCommandRepository
snapshot: CircuitSnapshot snapshot: CircuitSnapshot
) { ) {
this.assertCircuitLocation(database, projectId, snapshot); this.assertCircuitLocation(database, projectId, snapshot);
if (source === "user") {
const derivedVoltage = resolveCircuitVoltage(
database,
projectId,
snapshot.sectionId,
snapshot.deviceRows.map((row) => row.phaseType)
);
if (snapshot.voltage !== derivedVoltage) {
throw new Error(
"Circuit voltage must match the project phase voltage."
);
}
}
const existingCircuit = database const existingCircuit = database
.select({ id: circuits.id }) .select({ id: circuits.id })
@@ -26,7 +26,7 @@ export class GlobalDeviceRepository {
quantity: input.quantity, quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw, installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor, demandFactor: input.demandFactor,
voltageV: input.voltageV ?? null, voltageV: null,
phaseCount: input.phaseCount ?? null, phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null, powerFactor: input.powerFactor ?? null,
note: input.note ?? null, note: input.note ?? null,
@@ -45,7 +45,7 @@ export class GlobalDeviceRepository {
quantity: input.quantity, quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw, installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor, demandFactor: input.demandFactor,
voltageV: input.voltageV ?? null, voltageV: null,
phaseCount: input.phaseCount ?? null, phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null, powerFactor: input.powerFactor ?? null,
note: input.note ?? null, note: input.note ?? null,
@@ -12,7 +12,8 @@ import type {
} from "../../domain/ports/project-device-project-command.store.js"; } from "../../domain/ports/project-device-project-command.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
type ProjectDeviceRow = typeof projectDevices.$inferSelect; type ProjectDeviceRow = typeof projectDevices.$inferSelect;
@@ -24,7 +25,7 @@ export class ProjectDeviceProjectCommandRepository
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) { executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
assertProjectDeviceUpdateProjectCommand(input.command); assertProjectDeviceUpdateProjectCommand(input.command);
return executeProjectCommandTransaction( return executeProjectCommandTransactionWithAppliedForward(
this.database, this.database,
input, input,
(tx) => this.applyCommand(tx, input) (tx) => this.applyCommand(tx, input)
@@ -60,8 +61,40 @@ export class ProjectDeviceProjectCommandRepository
change.value, change.value,
]) ])
) as ProjectDeviceUpdatePatch; ) as ProjectDeviceUpdatePatch;
if (input.source === "user") {
if (
input.command.payload.changes.every(
(change) => change.field === "voltageV"
)
) {
throw new Error(
"Project-device voltage is derived and cannot be edited."
);
}
const targetPhaseType = patch.phaseType ?? current.phaseType;
if (
targetPhaseType !== "single_phase" &&
targetPhaseType !== "three_phase"
) {
throw new Error("Project-device phase type is invalid.");
}
const derivedVoltage = resolveProjectDeviceVoltage(
tx,
input.projectId,
targetPhaseType
);
if (derivedVoltage === current.voltageV) {
delete patch.voltageV;
} else {
patch.voltageV = derivedVoltage;
}
}
const appliedForward = createProjectDeviceUpdateProjectCommand(
current.id,
patch
);
const inversePatch = Object.fromEntries( const inversePatch = Object.fromEntries(
input.command.payload.changes.map((change) => [ appliedForward.payload.changes.map((change) => [
change.field, change.field,
getProjectDeviceFieldValue(current, change.field), getProjectDeviceFieldValue(current, change.field),
]) ])
@@ -87,7 +120,7 @@ export class ProjectDeviceProjectCommandRepository
); );
} }
return inverse; return { forward: appliedForward, inverse };
} }
} }
@@ -16,6 +16,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.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";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
@@ -129,6 +130,25 @@ export class ProjectDeviceRowSyncProjectCommandRepository
); );
} }
} }
const affectedCircuitIds = new Set(
input.command.payload.rows
.filter(
(assignment) =>
assignment.expected.phaseType !==
assignment.target.phaseType
)
.map(
(assignment) =>
persistedById.get(assignment.rowId)!.circuitId
)
);
for (const circuitId of affectedCircuitIds) {
updateDerivedCircuitVoltage(
tx,
input.projectId,
circuitId
);
}
return inverse; return inverse;
} }
@@ -28,6 +28,7 @@ import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
const circuitDeviceRowSnapshotFields = [ const circuitDeviceRowSnapshotFields = [
"id", "id",
@@ -125,6 +126,19 @@ export class ProjectDeviceStructureProjectCommandRepository
if (!project) { if (!project) {
throw new Error("Project does not exist."); throw new Error("Project does not exist.");
} }
if (
source === "user" &&
snapshot.voltageV !==
resolveProjectDeviceVoltage(
database,
projectId,
snapshot.phaseType
)
) {
throw new Error(
"Project-device voltage must match the project phase voltage."
);
}
const existing = database const existing = database
.select({ id: projectDevices.id }) .select({ id: projectDevices.id })
.from(projectDevices) .from(projectDevices)
@@ -15,6 +15,7 @@ import type { AppDatabase } from "../database-context.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateAllDerivedProjectVoltages } from "./project-voltage.persistence.js";
export class ProjectSettingsProjectCommandRepository export class ProjectSettingsProjectCommandRepository
implements ProjectSettingsProjectCommandStore implements ProjectSettingsProjectCommandStore
@@ -94,6 +95,7 @@ export class ProjectSettingsProjectCommandRepository
if (updated.changes !== 1) { if (updated.changes !== 1) {
throw new Error("Project changed before settings update."); throw new Error("Project changed before settings update.");
} }
updateAllDerivedProjectVoltages(tx, input.projectId);
return inverse; return inverse;
} }
@@ -9,6 +9,7 @@ import {
legacyProjectStateSnapshotSchemaVersion, legacyProjectStateSnapshotSchemaVersion,
previousProjectStateSnapshotSchemaVersion, previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion, projectStateSnapshotSchemaVersion,
supplyTypesProjectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js"; } from "../../domain/models/project-state-snapshot.model.js";
import type { import type {
CreateNamedProjectSnapshotInput, CreateNamedProjectSnapshotInput,
@@ -146,6 +147,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion && stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== stored.schemaVersion !==
distributionBoardProjectStateSnapshotSchemaVersion && distributionBoardProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
supplyTypesProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion stored.schemaVersion !== projectStateSnapshotSchemaVersion
) { ) {
throw new Error("Project snapshot schema version is not supported."); throw new Error("Project snapshot schema version is not supported.");
@@ -0,0 +1,154 @@
import { and, eq } from "drizzle-orm";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
type ElectricalPhaseType,
type ProjectVoltageSettings,
} from "../../domain/services/project-voltage.service.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
export function readProjectVoltageSettings(
database: AppDatabase,
projectId: string
): ProjectVoltageSettings {
const project = database
.select({
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
return project;
}
export function resolveProjectDeviceVoltage(
database: AppDatabase,
projectId: string,
phaseType: ElectricalPhaseType
) {
return resolveProjectVoltage(
phaseType,
readProjectVoltageSettings(database, projectId)
);
}
export function resolveCircuitVoltage(
database: AppDatabase,
projectId: string,
sectionId: string,
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
) {
const section = database
.select({ key: circuitSections.key })
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(
and(
eq(circuitSections.id, sectionId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!section) {
throw new Error("Circuit section does not belong to project.");
}
return resolveProjectVoltage(
resolveCircuitPhaseType(section.key, devicePhaseTypes),
readProjectVoltageSettings(database, projectId)
);
}
export function updateDerivedCircuitVoltage(
database: AppDatabase,
projectId: string,
circuitId: string
) {
const circuit = database
.select({
id: circuits.id,
sectionId: circuits.sectionId,
})
.from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(
and(
eq(circuits.id, circuitId),
eq(circuitLists.projectId, projectId)
)
)
.get();
if (!circuit) {
throw new Error("Circuit does not belong to project.");
}
const devicePhaseTypes = database
.select({ phaseType: circuitDeviceRows.phaseType })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.all()
.map((row) => row.phaseType);
const voltage = resolveCircuitVoltage(
database,
projectId,
circuit.sectionId,
devicePhaseTypes
);
database
.update(circuits)
.set({ voltage })
.where(eq(circuits.id, circuitId))
.run();
return voltage;
}
export function updateAllDerivedProjectVoltages(
database: AppDatabase,
projectId: string
) {
const settings = readProjectVoltageSettings(database, projectId);
const devices = database
.select({
id: projectDevices.id,
phaseType: projectDevices.phaseType,
})
.from(projectDevices)
.where(eq(projectDevices.projectId, projectId))
.all();
for (const device of devices) {
if (
device.phaseType !== "single_phase" &&
device.phaseType !== "three_phase"
) {
throw new Error("Persisted project-device phase type is invalid.");
}
database
.update(projectDevices)
.set({
voltageV: resolveProjectVoltage(device.phaseType, settings),
})
.where(eq(projectDevices.id, device.id))
.run();
}
const projectCircuits = database
.select({ id: circuits.id })
.from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.all();
for (const circuit of projectCircuits) {
updateDerivedCircuitVoltage(database, projectId, circuit.id);
}
}
+2
View File
@@ -58,6 +58,8 @@ export interface CircuitTreeSectionBlock {
export interface CircuitTreeResponse { export interface CircuitTreeResponse {
circuitListId: string; circuitListId: string;
currentRevision: number; currentRevision: number;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
sections: CircuitTreeSectionBlock[]; sections: CircuitTreeSectionBlock[];
} }
@@ -3,11 +3,16 @@ import {
defaultDistributionBoardSupplyTypes, defaultDistributionBoardSupplyTypes,
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
} from "../../shared/constants/distribution-board.js"; } from "../../shared/constants/distribution-board.js";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
} from "../services/project-voltage.service.js";
export const legacyProjectStateSnapshotSchemaVersion = 1 as const; export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
export const previousProjectStateSnapshotSchemaVersion = 2 as const; export const previousProjectStateSnapshotSchemaVersion = 2 as const;
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const; export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
export const projectStateSnapshotSchemaVersion = 4 as const; export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
export const projectStateSnapshotSchemaVersion = 5 as const;
const idSchema = z.string().trim().min(1); const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable(); const nullableStringSchema = z.string().nullable();
@@ -198,15 +203,20 @@ const distributionBoardProjectStateSnapshotSchema = z
}) })
.strict(); .strict();
export const projectStateSnapshotSchema = z const supplyTypesProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion), schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
project: currentProjectSchema, project: currentProjectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents, ...commonProjectStateSnapshotContents,
}) })
.strict(); .strict();
export const projectStateSnapshotSchema =
supplyTypesProjectStateSnapshotSchema.extend({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
});
export type ProjectStateSnapshot = z.infer< export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema typeof projectStateSnapshotSchema
>; >;
@@ -217,23 +227,33 @@ export function parseProjectStateSnapshot(
const version = isPlainObject(value) ? value.schemaVersion : undefined; const version = isPlainObject(value) ? value.schemaVersion : undefined;
const snapshot = const snapshot =
version === legacyProjectStateSnapshotSchemaVersion version === legacyProjectStateSnapshotSchemaVersion
? upgradeDistributionBoardProjectStateSnapshot( ? upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot( upgradePreviousProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot( upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value) legacyProjectStateSnapshotSchema.parse(value)
) )
) )
) )
)
: version === previousProjectStateSnapshotSchemaVersion : version === previousProjectStateSnapshotSchemaVersion
? upgradeDistributionBoardProjectStateSnapshot( ? upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot( upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value) previousProjectStateSnapshotSchema.parse(value)
) )
) )
)
: version === distributionBoardProjectStateSnapshotSchemaVersion : version === distributionBoardProjectStateSnapshotSchemaVersion
? upgradeDistributionBoardProjectStateSnapshot( ? upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(value) distributionBoardProjectStateSnapshotSchema.parse(value)
) )
)
: version === supplyTypesProjectStateSnapshotSchemaVersion
? upgradeSupplyTypesProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value)
)
: projectStateSnapshotSchema.parse(value); : projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot); assertProjectStateSnapshotRelations(snapshot);
return snapshot; return snapshot;
@@ -271,10 +291,10 @@ function upgradePreviousProjectStateSnapshot(
function upgradeDistributionBoardProjectStateSnapshot( function upgradeDistributionBoardProjectStateSnapshot(
snapshot: z.infer<typeof distributionBoardProjectStateSnapshotSchema> snapshot: z.infer<typeof distributionBoardProjectStateSnapshotSchema>
): ProjectStateSnapshot { ): z.infer<typeof supplyTypesProjectStateSnapshotSchema> {
return { return {
...snapshot, ...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: supplyTypesProjectStateSnapshotSchemaVersion,
project: { project: {
...snapshot.project, ...snapshot.project,
enabledDistributionBoardSupplyTypes: [ enabledDistributionBoardSupplyTypes: [
@@ -284,6 +304,37 @@ function upgradeDistributionBoardProjectStateSnapshot(
}; };
} }
function upgradeSupplyTypesProjectStateSnapshot(
snapshot: z.infer<typeof supplyTypesProjectStateSnapshotSchema>
): ProjectStateSnapshot {
const settings = snapshot.project;
const sectionById = new Map(
snapshot.circuitSections.map((section) => [section.id, section])
);
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
projectDevices: snapshot.projectDevices.map((device) => ({
...device,
voltageV: resolveProjectVoltage(device.phaseType, settings),
})),
circuits: snapshot.circuits.map((circuit) => {
const section = sectionById.get(circuit.sectionId);
if (!section) {
return circuit;
}
const phaseType = resolveCircuitPhaseType(
section.key,
circuit.deviceRows.map((row) => row.phaseType)
);
return {
...circuit,
voltage: resolveProjectVoltage(phaseType, settings),
};
}),
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value); return value !== null && typeof value === "object" && !Array.isArray(value);
} }
@@ -364,6 +415,14 @@ function assertProjectStateSnapshotRelations(
} }
for (const device of snapshot.projectDevices) { for (const device of snapshot.projectDevices) {
assertProjectOwnership(device.projectId, projectId, "project device"); assertProjectOwnership(device.projectId, projectId, "project device");
if (
device.voltageV !==
resolveProjectVoltage(device.phaseType, snapshot.project)
) {
throw new Error(
"Snapshot project device voltage must match project settings."
);
}
} }
for (const floor of snapshot.floors) { for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor"); assertProjectOwnership(floor.projectId, projectId, "floor");
@@ -394,6 +453,18 @@ function assertProjectStateSnapshotRelations(
"Snapshot circuit reserve state must match its device rows." "Snapshot circuit reserve state must match its device rows."
); );
} }
const expectedVoltage = resolveProjectVoltage(
resolveCircuitPhaseType(
section.key,
circuit.deviceRows.map((row) => row.phaseType)
),
snapshot.project
);
if (circuit.voltage !== expectedVoltage) {
throw new Error(
"Snapshot circuit voltage must match project settings."
);
}
for (const row of circuit.deviceRows) { for (const row of circuit.deviceRows) {
if (row.circuitId !== circuit.id) { if (row.circuitId !== circuit.id) {
throw new Error( throw new Error(
@@ -0,0 +1,35 @@
export type ElectricalPhaseType = "single_phase" | "three_phase";
export interface ProjectVoltageSettings {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
export function resolveProjectVoltage(
phaseType: ElectricalPhaseType,
settings: ProjectVoltageSettings
) {
return phaseType === "three_phase"
? settings.threePhaseVoltageV
: settings.singlePhaseVoltageV;
}
export function resolveCircuitPhaseType(
sectionKey: string,
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
): ElectricalPhaseType {
if (sectionKey === "three_phase") {
return "three_phase";
}
if (sectionKey === "lighting" || sectionKey === "single_phase") {
return "single_phase";
}
const assignedPhaseTypes = devicePhaseTypes.filter(
(phaseType): phaseType is ElectricalPhaseType =>
phaseType === "single_phase" || phaseType === "three_phase"
);
return assignedPhaseTypes.length > 0 &&
assignedPhaseTypes.every((phaseType) => phaseType === "three_phase")
? "three_phase"
: "single_phase";
}
@@ -5,6 +5,10 @@ import {
inferProjectDeviceSectionKey, inferProjectDeviceSectionKey,
isProjectDevicePlacementValid, isProjectDevicePlacementValid,
} from "../../domain/services/project-device-placement.service"; } from "../../domain/services/project-device-placement.service";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
} from "../../domain/services/project-voltage.service";
import { import {
getInsertionSortOrder, getInsertionSortOrder,
resolveGridInsertionIntent, resolveGridInsertionIntent,
@@ -819,6 +823,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
values: CreateCircuitInputDto, values: CreateCircuitInputDto,
deviceRowValues: CreateCircuitDeviceRowInputDto[] = [] deviceRowValues: CreateCircuitDeviceRowInputDto[] = []
) { ) {
const section = data?.sections.find(
(entry) => entry.id === values.sectionId
);
if (!section || !data) {
throw new Error("Bereich wurde nicht gefunden.");
}
const voltage = resolveProjectVoltage(
resolveCircuitPhaseType(
section.key,
deviceRowValues.map((row) => row.phaseType)
),
data
);
const circuitId = crypto.randomUUID(); const circuitId = crypto.randomUUID();
const deviceRows = deviceRowValues.map((row, index) => const deviceRows = deviceRowValues.map((row, index) =>
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10) createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
@@ -826,7 +843,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return buildCircuitInsertSnapshot({ return buildCircuitInsertSnapshot({
id: circuitId, id: circuitId,
circuitListId, circuitListId,
values, values: { ...values, voltage },
deviceRows, deviceRows,
}); });
} }
@@ -47,7 +47,6 @@ export function ProjectDeviceModal({
simultaneityFactor: Number(values.simultaneityFactor), simultaneityFactor: Number(values.simultaneityFactor),
cosPhi: optionalNumber(values.cosPhi), cosPhi: optionalNumber(values.cosPhi),
remark: optionalString(values.remark), remark: optionalString(values.remark),
voltageV: optionalNumber(values.voltageV),
}); });
} }
@@ -182,13 +181,6 @@ export function ProjectDeviceModal({
step="0.01" step="0.01"
value={values.cosPhi} value={values.cosPhi}
/> />
<NumberField
id="device-voltage"
label="Spannung [V]"
min="1"
onChange={(value) => update("voltageV", value)}
value={values.voltageV}
/>
<div className="col-12"> <div className="col-12">
<label className="form-label" htmlFor="device-remark"> <label className="form-label" htmlFor="device-remark">
Bemerkung Bemerkung
@@ -272,7 +264,6 @@ function toFormValues(device?: ProjectDeviceDto) {
simultaneityFactor: String(device?.simultaneityFactor ?? 1), simultaneityFactor: String(device?.simultaneityFactor ?? 1),
cosPhi: String(device?.cosPhi ?? 1), cosPhi: String(device?.cosPhi ?? 1),
remark: device?.remark ?? "", remark: device?.remark ?? "",
voltageV: String(device?.voltageV ?? ""),
}; };
} }
+2 -2
View File
@@ -187,7 +187,6 @@ export interface CreateGlobalDeviceInput {
quantity: number; quantity: number;
installedPowerPerUnitKw: number; installedPowerPerUnitKw: number;
demandFactor: number; demandFactor: number;
voltageV?: number;
phaseCount?: 1 | 3; phaseCount?: 1 | 3;
powerFactor?: number; powerFactor?: number;
note?: string; note?: string;
@@ -205,7 +204,6 @@ export interface CreateProjectDeviceInput {
simultaneityFactor: number; simultaneityFactor: number;
cosPhi?: number; cosPhi?: number;
remark?: string; remark?: string;
voltageV?: number;
} }
export interface ProjectDeviceSyncDifferenceDto { export interface ProjectDeviceSyncDifferenceDto {
@@ -314,6 +312,8 @@ export interface CircuitTreeMigrationReportDto {
export interface CircuitTreeResponseDto { export interface CircuitTreeResponseDto {
circuitListId: string; circuitListId: string;
currentRevision: number; currentRevision: number;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
sections: CircuitTreeSectionDto[]; sections: CircuitTreeSectionDto[];
migrationReport?: CircuitTreeMigrationReportDto; migrationReport?: CircuitTreeMigrationReportDto;
} }
+1 -1
View File
@@ -161,7 +161,6 @@ const circuitFieldKeys = new Set<CellKey>([
"cableSummary", "cableSummary",
"rcdAssignment", "rcdAssignment",
"terminalDesignation", "terminalDesignation",
"voltage",
"controlRequirement", "controlRequirement",
"status", "status",
"isReserve", "isReserve",
@@ -393,6 +392,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 (key === "rowTotalPower" || key === "circuitTotalPower") return "computed"; if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
if (key === "voltage") return "readonly";
if (rowType === "section") return "readonly"; if (rowType === "section") return "readonly";
if (rowType === "placeholder") { if (rowType === "placeholder") {
if (deviceFieldKeys.has(key)) return "deviceField"; if (deviceFieldKeys.has(key)) return "deviceField";
@@ -149,6 +149,8 @@ function makeVisibleGridRow(
value = circuit.circuitTotalPower; value = circuit.circuitTotalPower;
} else if (column.key === "circuitTotalPower" && circuit) { } else if (column.key === "circuitTotalPower" && circuit) {
value = circuit.circuitTotalPower; value = circuit.circuitTotalPower;
} else if (column.key === "voltage" && circuit) {
value = circuit.voltage;
} }
return { cellKey: column.key, editable, kind, value }; return { cellKey: column.key, editable, kind, value };
@@ -55,6 +55,8 @@ export async function getCircuitTree(req: Request, res: Response) {
const tree: CircuitTreeResponse = { const tree: CircuitTreeResponse = {
circuitListId, circuitListId,
currentRevision: project.currentRevision, currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
sections: sections.map((section) => ({ sections: sections.map((section) => ({
id: section.id, id: section.id,
key: section.key, key: section.key,
@@ -127,6 +129,8 @@ export async function getCircuitTree(req: Request, res: Response) {
return res.json({ return res.json({
circuitListId, circuitListId,
currentRevision: project.currentRevision, currentRevision: project.currentRevision,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
sections: [], sections: [],
warning: warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
@@ -70,7 +70,6 @@ export async function copyProjectDeviceToGlobal(req: Request, res: Response) {
quantity: source.quantity, quantity: source.quantity,
installedPowerPerUnitKw: source.powerPerUnit, installedPowerPerUnitKw: source.powerPerUnit,
demandFactor: source.simultaneityFactor, demandFactor: source.simultaneityFactor,
voltageV: source.voltageV ?? undefined,
phaseCount: source.phaseType === "three_phase" ? 3 : 1, phaseCount: source.phaseType === "three_phase" ? 3 : 1,
powerFactor: source.cosPhi ?? undefined, powerFactor: source.cosPhi ?? undefined,
note: source.remark ?? undefined, note: source.remark ?? undefined,
@@ -11,6 +11,7 @@ import { projectCommandService } from "../composition/project-command-stores.js"
import { import {
globalDeviceRepository, globalDeviceRepository,
projectDeviceRepository, projectDeviceRepository,
projectRepository,
} from "../composition/application-repositories.js"; } from "../composition/application-repositories.js";
import { import {
createProjectDeviceCommandSchema, createProjectDeviceCommandSchema,
@@ -21,6 +22,7 @@ import {
} from "../../shared/validation/project-device.schemas.js"; } from "../../shared/validation/project-device.schemas.js";
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js"; import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
import { resolveProjectVoltage } from "../../domain/services/project-voltage.service.js";
export async function listProjectDevicesByProject(req: Request, res: Response) { export async function listProjectDevicesByProject(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
@@ -41,9 +43,18 @@ export async function createProjectDevice(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
const { expectedRevision, ...input } = parsed.data; const { expectedRevision, ...input } = parsed.data;
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), input);
try { try {
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
const projectDevice = toProjectDeviceSnapshot(
projectId,
randomUUID(),
input,
project
);
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
projectId, projectId,
expectedRevision, expectedRevision,
@@ -73,13 +84,17 @@ export async function updateProjectDevice(req: Request, res: Response) {
const { expectedRevision, ...input } = parsed.data; const { expectedRevision, ...input } = parsed.data;
try { try {
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
projectId, projectId,
expectedRevision, expectedRevision,
description: "Projektgerät bearbeiten", description: "Projektgerät bearbeiten",
command: createProjectDeviceUpdateProjectCommand( command: createProjectDeviceUpdateProjectCommand(
projectDeviceId, projectDeviceId,
toProjectDeviceValues(input) toProjectDeviceValues(input, project)
), ),
}); });
// Linked rows are synchronized only through the explicit review flow. // Linked rows are synchronized only through the explicit review flow.
@@ -133,6 +148,10 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
if (!source) { if (!source) {
return res.status(404).json({ error: "Global device not found" }); return res.status(404).json({ error: "Global device not found" });
} }
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), { const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
name: source.name, name: source.name,
@@ -144,8 +163,7 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
simultaneityFactor: source.demandFactor, simultaneityFactor: source.demandFactor,
cosPhi: source.powerFactor ?? undefined, cosPhi: source.powerFactor ?? undefined,
remark: source.note ?? undefined, remark: source.note ?? undefined,
voltageV: source.voltageV ?? undefined, }, project);
});
try { try {
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
@@ -167,16 +185,26 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
function toProjectDeviceSnapshot( function toProjectDeviceSnapshot(
projectId: string, projectId: string,
id: string, id: string,
input: CreateProjectDeviceInput input: CreateProjectDeviceInput,
project: {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
): ProjectDeviceSnapshot { ): ProjectDeviceSnapshot {
return { return {
id, id,
projectId, projectId,
...toProjectDeviceValues(input), ...toProjectDeviceValues(input, project),
}; };
} }
function toProjectDeviceValues(input: CreateProjectDeviceInput) { function toProjectDeviceValues(
input: CreateProjectDeviceInput,
project: {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
}
) {
return { return {
name: input.name, name: input.name,
displayName: input.displayName, displayName: input.displayName,
@@ -189,7 +217,7 @@ function toProjectDeviceValues(input: CreateProjectDeviceInput) {
simultaneityFactor: input.simultaneityFactor, simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null, cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null, remark: input.remark ?? null,
voltageV: input.voltageV ?? null, voltageV: resolveProjectVoltage(input.phaseType, project),
}; };
} }
@@ -7,11 +7,10 @@ export const createGlobalDeviceSchema = z.object({
quantity: z.number().min(0), quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0), installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1), demandFactor: z.number().min(0).max(1),
voltageV: z.number().positive().optional(),
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(), phaseCount: z.union([z.literal(1), z.literal(3)]).optional(),
powerFactor: z.number().min(0).max(1).optional(), powerFactor: z.number().min(0).max(1).optional(),
note: z.string().optional(), note: z.string().optional(),
}); }).strict();
export const updateGlobalDeviceSchema = createGlobalDeviceSchema; export const updateGlobalDeviceSchema = createGlobalDeviceSchema;
@@ -14,9 +14,7 @@ export const createProjectDeviceSchema = z.object({
simultaneityFactor: z.number().min(0).max(1), simultaneityFactor: z.number().min(0).max(1),
cosPhi: z.number().min(0).max(1).optional(), cosPhi: z.number().min(0).max(1).optional(),
remark: z.string().optional(), remark: z.string().optional(),
// Transitional metadata used when importing from the legacy global-device library. }).strict();
voltageV: z.number().positive().optional(),
});
export const updateProjectDeviceSchema = createProjectDeviceSchema; export const updateProjectDeviceSchema = createProjectDeviceSchema;
+29
View File
@@ -4,6 +4,10 @@ import {
calculateCircuitTotalPower, calculateCircuitTotalPower,
calculateRowTotalPower, calculateRowTotalPower,
} from "../src/domain/calculations/circuit-power-calculation.js"; } from "../src/domain/calculations/circuit-power-calculation.js";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
} from "../src/domain/services/project-voltage.service.js";
describe("circuit power calculation", () => { describe("circuit power calculation", () => {
it("calculates row and circuit totals from device rows", () => { it("calculates row and circuit totals from device rows", () => {
@@ -16,3 +20,28 @@ describe("circuit power calculation", () => {
}); });
}); });
describe("project voltage derivation", () => {
const settings = {
singlePhaseVoltageV: 240,
threePhaseVoltageV: 415,
};
it("derives device voltage exclusively from phase and project settings", () => {
assert.equal(resolveProjectVoltage("single_phase", settings), 240);
assert.equal(resolveProjectVoltage("three_phase", settings), 415);
});
it("uses section phase and handles unassigned circuits conservatively", () => {
assert.equal(resolveCircuitPhaseType("lighting", ["three_phase"]), "single_phase");
assert.equal(resolveCircuitPhaseType("single_phase", ["three_phase"]), "single_phase");
assert.equal(resolveCircuitPhaseType("three_phase", ["single_phase"]), "three_phase");
assert.equal(resolveCircuitPhaseType("unassigned", ["three_phase"]), "three_phase");
assert.equal(
resolveCircuitPhaseType("unassigned", ["three_phase", "single_phase"]),
"single_phase"
);
assert.equal(resolveCircuitPhaseType("unassigned", []), "single_phase");
assert.equal(resolveCircuitPhaseType("unassigned", [null]), "single_phase");
});
});
@@ -129,7 +129,7 @@ describe("circuit project-command repository", () => {
const circuit = getCircuit(fixture.context); const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, null); assert.equal(circuit.displayName, null);
assert.equal(circuit.protectionRatedCurrent, 16); assert.equal(circuit.protectionRatedCurrent, 16);
assert.equal(circuit.voltage, 400); assert.equal(circuit.voltage, 230);
assert.equal(circuit.controlRequirement, "DALI"); assert.equal(circuit.controlRequirement, "DALI");
assert.equal(circuit.isReserve, 1); assert.equal(circuit.isReserve, 1);
assert.equal(executed.revision.revisionNumber, 1); assert.equal(executed.revision.revisionNumber, 1);
@@ -138,7 +138,6 @@ describe("circuit project-command repository", () => {
changes: [ changes: [
{ field: "displayName", value: "Licht Bestand" }, { field: "displayName", value: "Licht Bestand" },
{ field: "protectionRatedCurrent", value: 10 }, { field: "protectionRatedCurrent", value: 10 },
{ field: "voltage", value: 230 },
{ field: "controlRequirement", value: "none" }, { field: "controlRequirement", value: "none" },
{ field: "isReserve", value: false }, { field: "isReserve", value: false },
], ],
@@ -149,7 +148,15 @@ describe("circuit project-command repository", () => {
.from(projectChangeSets) .from(projectChangeSets)
.get(); .get();
assert.ok(changeSet); assert.ok(changeSet);
assert.deepEqual(deserializeProjectCommand(changeSet.forwardPayloadJson), command); assert.deepEqual(
deserializeProjectCommand(changeSet.forwardPayloadJson),
createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
protectionRatedCurrent: 16,
controlRequirement: "DALI",
isReserve: true,
})
);
assert.deepEqual( assert.deepEqual(
deserializeProjectCommand(changeSet.inversePayloadJson), deserializeProjectCommand(changeSet.inversePayloadJson),
executed.inverse executed.inverse
+14
View File
@@ -41,6 +41,20 @@ describe("project device circuit-first schema", () => {
assert.equal(result.success, false); assert.equal(result.success, false);
}); });
it("rejects manually supplied device voltages", () => {
const result = createProjectDeviceSchema.safeParse({
name: "Pumpe",
displayName: "Pumpe",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 2,
simultaneityFactor: 1,
voltageV: 500,
});
assert.equal(result.success, false);
});
it("requires a project revision for create and update commands", () => { it("requires a project revision for create and update commands", () => {
const device = { const device = {
name: "E-Line Pro", name: "E-Line Pro",
@@ -12,6 +12,11 @@ import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/
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 { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js"; import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
import { updateProjectSettings } from "../src/frontend/utils/api.js"; import { updateProjectSettings } from "../src/frontend/utils/api.js";
@@ -78,6 +83,86 @@ function settings(
}; };
} }
function seedDerivedVoltageRecords(context: DatabaseContext) {
context.db.insert(projectDevices).values([
{
id: "device-single",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
voltageV: 230,
},
{
id: "device-three",
projectId: "project-1",
name: "Pumpe",
displayName: "Pumpe",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 2,
simultaneityFactor: 1,
voltageV: 400,
},
]).run();
const board = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const list = context.db
.select()
.from(circuitLists)
.where(eq(circuitLists.distributionBoardId, board.id))
.get();
assert.ok(list);
const sections = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, list.id))
.all();
const singleSection = sections.find((section) => section.key === "single_phase");
const threeSection = sections.find((section) => section.key === "three_phase");
assert.ok(singleSection);
assert.ok(threeSection);
context.db.insert(circuits).values([
{
id: "circuit-single",
circuitListId: list.id,
sectionId: singleSection.id,
equipmentIdentifier: "-2F1",
sortOrder: 10,
voltage: 230,
isReserve: 1,
},
{
id: "circuit-three",
circuitListId: list.id,
sectionId: threeSection.id,
equipmentIdentifier: "-3F1",
sortOrder: 10,
voltage: 400,
isReserve: 1,
},
]).run();
}
function getDerivedVoltages(context: DatabaseContext) {
return {
devices: context.db
.select({ id: projectDevices.id, voltageV: projectDevices.voltageV })
.from(projectDevices)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
circuits: context.db
.select({ id: circuits.id, voltage: circuits.voltage })
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
};
}
describe("project settings project-command repository", () => { describe("project settings project-command repository", () => {
it("validates settings commands and sends the expected revision from the frontend", async () => { it("validates settings commands and sends the expected revision from the frontend", async () => {
assert.throws( assert.throws(
@@ -137,6 +222,7 @@ describe("project settings project-command repository", () => {
it("updates both voltages and supports persisted undo and redo", () => { it("updates both voltages and supports persisted undo and redo", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
seedDerivedVoltageRecords(context);
const repository = new ProjectSettingsProjectCommandRepository( const repository = new ProjectSettingsProjectCommandRepository(
context.db context.db
); );
@@ -171,6 +257,16 @@ describe("project settings project-command repository", () => {
}), }),
currentRevision: 1, currentRevision: 1,
}); });
assert.deepEqual(getDerivedVoltages(context), {
devices: [
{ id: "device-single", voltageV: 240 },
{ id: "device-three", voltageV: 415 },
],
circuits: [
{ id: "circuit-single", voltage: 240 },
{ id: "circuit-three", voltage: 415 },
],
});
assert.deepEqual(forward.inverse.payload, { assert.deepEqual(forward.inverse.payload, {
name: "Testprojekt", name: "Testprojekt",
internalProjectNumber: "INT-1", internalProjectNumber: "INT-1",
@@ -216,6 +312,16 @@ describe("project settings project-command repository", () => {
], ],
currentRevision: 2, currentRevision: 2,
}); });
assert.deepEqual(getDerivedVoltages(context), {
devices: [
{ id: "device-single", voltageV: 230 },
{ id: "device-three", voltageV: 400 },
],
circuits: [
{ id: "circuit-single", voltage: 230 },
{ id: "circuit-three", voltage: 400 },
],
});
const redoStep = history.getNextCommand("project-1", "redo"); const redoStep = history.getNextCommand("project-1", "redo");
assert.ok(redoStep); assert.ok(redoStep);
@@ -244,6 +350,16 @@ describe("project settings project-command repository", () => {
], ],
currentRevision: 3, currentRevision: 3,
}); });
assert.deepEqual(getDerivedVoltages(context), {
devices: [
{ id: "device-single", voltageV: 240 },
{ id: "device-three", voltageV: 415 },
],
circuits: [
{ id: "circuit-single", voltage: 240 },
{ id: "circuit-three", voltage: 415 },
],
});
} finally { } finally {
context.close(); context.close();
} }
@@ -101,6 +101,7 @@ function createTestDatabase(): DatabaseContext {
quantity: 1, quantity: 1,
powerPerUnit: 2.5, powerPerUnit: 2.5,
simultaneityFactor: 0.8, simultaneityFactor: 0.8,
voltageV: 400,
}) })
.run(); .run();
context.db context.db
@@ -112,6 +113,7 @@ function createTestDatabase(): DatabaseContext {
equipmentIdentifier: "-1F1", equipmentIdentifier: "-1F1",
displayName: "Ausgang", displayName: "Ausgang",
sortOrder: 10, sortOrder: 10,
voltage: 230,
isReserve: 0, isReserve: 0,
}) })
.run(); .run();
@@ -165,7 +167,10 @@ function changeCompleteProjectState(context: DatabaseContext) {
.run(); .run();
context.db context.db
.update(circuits) .update(circuits)
.set({ displayName: "Geänderter Stromkreis" }) .set({
displayName: "Geänderter Stromkreis",
voltage: 240,
})
.where(eq(circuits.id, "circuit-1")) .where(eq(circuits.id, "circuit-1"))
.run(); .run();
context.db context.db
@@ -198,8 +203,14 @@ function changeCompleteProjectState(context: DatabaseContext) {
quantity: 2, quantity: 2,
powerPerUnit: 0.05, powerPerUnit: 0.05,
simultaneityFactor: 1, simultaneityFactor: 1,
voltageV: 240,
}) })
.run(); .run();
context.db
.update(projectDevices)
.set({ voltageV: 415 })
.where(eq(projectDevices.id, "device-1"))
.run();
new DistributionBoardFixtureRepository( new DistributionBoardFixtureRepository(
context.db context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-02"); ).createWithCircuitListAndDefaultSections("project-1", "UV-02");
+30
View File
@@ -128,6 +128,36 @@ describe("project state snapshot model", () => {
]); ]);
}); });
it("normalizes version-four device voltages to project settings", () => {
const previous = {
...minimalSnapshot(),
schemaVersion: 4 as const,
projectDevices: [
{
id: "device-1",
projectId: "project-1",
name: "Pumpe",
displayName: "Pumpe",
phaseType: "three_phase" as const,
connectionKind: null,
costGroup: null,
category: null,
quantity: 1,
powerPerUnit: 2,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
voltageV: 500,
},
],
};
const snapshot = parseProjectStateSnapshot(previous);
assert.equal(snapshot.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(snapshot.projectDevices[0]?.voltageV, 400);
});
it("rejects duplicate ids and cross-project ownership", () => { it("rejects duplicate ids and cross-project ownership", () => {
assert.throws( assert.throws(
() => () =>
+1 -1
View File
@@ -156,11 +156,11 @@ describe("project device modal presentation", () => {
"Anzahl", "Anzahl",
"Leistung je Stück [kW]", "Leistung je Stück [kW]",
"Gleichzeitigkeitsfaktor", "Gleichzeitigkeitsfaktor",
"Spannung [V]",
"Bemerkung", "Bemerkung",
]) { ]) {
assert.match(markup, new RegExp(label.replace("[", "\\[").replace("]", "\\]"))); assert.match(markup, new RegExp(label.replace("[", "\\[").replace("]", "\\]")));
} }
assert.doesNotMatch(markup, /Spannung \[V\]/);
}); });
}); });