Add external row quantity foundation

This commit is contained in:
2026-08-02 18:26:22 +02:00
parent bc2bc7ece5
commit dac19b093c
30 changed files with 2750 additions and 22 deletions
+10 -3
View File
@@ -250,7 +250,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart. inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 4 additionally stores the complete confirmed Current snapshot schema version 5 additionally stores the manual quantity share
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
`manualQuantity = quantity`; they already contain the complete confirmed
external-model source, import batches, room mappings and objects. Version 3 external-model source, import batches, room mappings and objects. Version 3
snapshots are upgraded with an empty external-model state; version 2 snapshots snapshots are upgraded with an empty external-model state; version 2 snapshots
are additionally upgraded with an empty external CSV configuration. Supported are additionally upgraded with an empty external CSV configuration. Supported
@@ -263,7 +265,7 @@ project graph.
Project-wide Revit CSV configuration uses Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is `external-csv-configuration.update`. Its complete expected/target snapshot is
persisted through the shared project-command transaction, so create, update, persisted through the shared project-command transaction, so create, update,
delete, Undo and Redo are revision-safe. Snapshot schema 4, restore and portable delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
project duplication preserve the configuration; duplication remaps its internal project duplication preserve the configuration; duplication remaps its internal
id and project link. id and project link.
The project page exposes the Revit CSV modal. It edits the complete The project page exposes the Revit CSV modal. It edits the complete
@@ -277,12 +279,17 @@ immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The general external-model repository is read-only; runtime writes belong only The general external-model repository is read-only; runtime writes belong only
to typed command adapters. Snapshot/restore and portable project transfer schema version 4 to typed command adapters. Snapshot/restore and portable project transfer schema version 5
capture the complete external state and remap every internal UUID and link while capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix. preserving IFCGUIDs, source values, original bytes and the classified matrix.
Import batches additionally store the monotonic project CSV configuration Import batches additionally store the monotonic project CSV configuration
version introduced by additive migration `0004`; it is distinct from the CSV version introduced by additive migration `0004`; it is distinct from the CSV
configuration JSON schema version. configuration JSON schema version.
Phase 14.3 migration `0005` adds `CircuitDeviceRow.manualQuantity`, backfills
existing rows from `quantity` and keeps `quantity` as the materialized total.
Normal manual row inserts and quantity edits update both values together. Future
external-object assignment commands must set the total to `manualQuantity` plus
the sum of linked objects' effective quantities in the same transaction.
Confirmed initial state is written only through Confirmed initial state is written only through
`external-import.apply-initial`. The command rechecks configuration version, `external-import.apply-initial`. The command rechecks configuration version,
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
+15 -4
View File
@@ -416,7 +416,9 @@ wieder her.
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 4 enthält zusätzlich den vollständigen externen Modellzustand Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
@@ -477,7 +479,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
Ein triggergeführtes Register erzwingt bereits eine normalisierte, Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
Snapshot-Schema 4. Persistente Insert/Delete/Update-Commands für Snapshot-Schema 5. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
@@ -500,7 +502,7 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration `external_csv_configurations` enthält höchstens eine versionierte Konfiguration
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 4, Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped. werden Konfigurations-UUID und Projektlink remapped.
@@ -516,7 +518,9 @@ und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert. Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links, Snapshot/Transfer v5 erfasst zusätzlich `manualQuantity`; Version 4 wird mit
dem bisherigen Gesamtwert als manuellem Anteil hochgestuft. Originalbytes,
Matrix und alle internen Links werden weiterhin vollständig erfasst,
stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für
@@ -529,6 +533,13 @@ Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand, CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung Redo verwendet dieselben UUIDs und Bytes. Bloße Vorschau und Importplanung
erzeugen weiterhin keine dieser Zeilen. erzeugen weiterhin keine dieser Zeilen.
Migration `0005` trennt bei `CircuitDeviceRow` den manuellen Mengenanteil vom
weiterhin materialisierten Gesamtwert. Bestehende Daten werden mit
`manualQuantity = quantity` übernommen. Die Fachregel für spätere externe
Zuordnungs-Commands lautet `quantity = manualQuantity + Summe der
effectiveQuantity aller verknüpften ExternalModelObjects`; Link und beide
Mengenwerte müssen innerhalb desselben Projekt-Commands wechseln.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command. Konfigurationsstand serverseitig und verwendet den typisierten Command.
@@ -313,6 +313,11 @@ Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
### 14.3 Editor und explizite Zuordnung ### 14.3 Editor und explizite Zuordnung
1. `manualQuantity` und Mengeninvarianten migrieren und testen. 1. `manualQuantity` und Mengeninvarianten migrieren und testen.
**Erledigt:** Migration `0005` übernimmt für jede bestehende Gerätezeile
`manualQuantity = quantity`. Neue normale Zeilen und Mengenänderungen führen
beide Werte gemeinsam; Snapshot/Transfer v5 erhält den Anteil und stuft v4
verlustfrei hoch. Die reine Mengenregel validiert den materialisierten Wert
als manuellen Anteil plus Summe der unteilbaren externen Objektmengen.
2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen. 2. Commands für vorhandene Row, neue Row, neuen Circuit und Lösen ergänzen.
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. 3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload 4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
+16
View File
@@ -40,6 +40,13 @@ const projectDeviceColumns = new Set(
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter( const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
(name) => !projectDeviceColumns.has(name) (name) => !projectDeviceColumns.has(name)
); );
const requiredCircuitDeviceRowColumns = ["manual_quantity"];
const circuitDeviceRowColumns = new Set(
db.prepare("PRAGMA table_info(circuit_device_rows)").all().map((column) => column.name)
);
const missingCircuitDeviceRowColumns = requiredCircuitDeviceRowColumns.filter(
(name) => !circuitDeviceRowColumns.has(name)
);
const removedProjectDeviceColumns = [ const removedProjectDeviceColumns = [
"installed_power_per_unit_kw", "installed_power_per_unit_kw",
"demand_factor", "demand_factor",
@@ -131,6 +138,7 @@ console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", ")); console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)"); console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", ")); console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
console.log("Required circuit-device-row columns:", requiredCircuitDeviceRowColumns.join(", "));
console.log("Required circuit columns:", requiredCircuitColumns.join(", ")); console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
console.log( console.log(
"Required circuit-section columns:", "Required circuit-section columns:",
@@ -171,6 +179,14 @@ if (missingCircuitColumns.length > 0) {
process.exit(1); process.exit(1);
} }
if (missingCircuitDeviceRowColumns.length > 0) {
console.error(
"Missing circuit-device-row columns:",
missingCircuitDeviceRowColumns.join(", ")
);
process.exit(1);
}
if (remainingRemovedCircuitColumns.length > 0) { if (remainingRemovedCircuitColumns.length > 0) {
console.error( console.error(
"Transitional circuit protection columns still present:", "Transitional circuit protection columns still present:",
@@ -0,0 +1,3 @@
ALTER TABLE `circuit_device_rows` ADD `manual_quantity` integer DEFAULT 0 NOT NULL;
--> statement-breakpoint
UPDATE `circuit_device_rows` SET `manual_quantity` = `quantity`;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1785684457208, "when": 1785684457208,
"tag": "0004_illegal_the_stranger", "tag": "0004_illegal_the_stranger",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1785687503453,
"tag": "0005_stale_gorilla_man",
"breakpoints": true
} }
] ]
} }
@@ -82,6 +82,14 @@ export class CircuitDeviceRowProjectCommandRepository
change.value, change.value,
]) ])
) as CircuitDeviceRowPatchInput; ) as CircuitDeviceRowPatchInput;
const targetQuantity = patch.quantity ?? current.quantity;
const targetManualQuantity =
patch.manualQuantity ?? current.manualQuantity;
if (targetManualQuantity > targetQuantity) {
throw new Error(
"Device-row manual quantity must not exceed total quantity."
);
}
if ( if (
input.source === "user" && input.source === "user" &&
patch.phaseType !== undefined && patch.phaseType !== undefined &&
@@ -20,6 +20,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { import {
assertCircuitDeviceRowReferencesInProject, assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
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";
@@ -92,7 +93,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository
throw new Error("Circuit device-row id already exists."); throw new Error("Circuit device-row id already exists.");
} }
database.insert(circuitDeviceRows).values(row).run(); database
.insert(circuitDeviceRows)
.values(toCircuitDeviceRowInsertValues(row))
.run();
const circuitUpdate = database const circuitUpdate = database
.update(circuits) .update(circuits)
.set({ isReserve: 0 }) .set({ isReserve: 0 })
@@ -61,6 +61,7 @@ export function toCircuitDeviceRowSnapshot(
roomNumberSnapshot: row.roomNumberSnapshot, roomNumberSnapshot: row.roomNumberSnapshot,
roomNameSnapshot: row.roomNameSnapshot, roomNameSnapshot: row.roomNameSnapshot,
quantity: row.quantity, quantity: row.quantity,
manualQuantity: row.manualQuantity,
powerPerUnit: row.powerPerUnit, powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor, simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi, cosPhi: row.cosPhi,
@@ -68,3 +69,10 @@ export function toCircuitDeviceRowSnapshot(
overriddenFields: row.overriddenFields, overriddenFields: row.overriddenFields,
}; };
} }
export function toCircuitDeviceRowInsertValues(row: CircuitDeviceRowSnapshot) {
return {
...row,
manualQuantity: row.manualQuantity ?? row.quantity,
};
}
@@ -13,6 +13,7 @@ export interface CircuitDeviceRowUpdateInput {
roomNumberSnapshot?: string; roomNumberSnapshot?: string;
roomNameSnapshot?: string; roomNameSnapshot?: string;
quantity: number; quantity: number;
manualQuantity?: number;
powerPerUnit: number; powerPerUnit: number;
simultaneityFactor: number; simultaneityFactor: number;
cosPhi?: number; cosPhi?: number;
@@ -34,6 +35,7 @@ export interface CircuitDeviceRowPatchInput {
roomNumberSnapshot?: string | null; roomNumberSnapshot?: string | null;
roomNameSnapshot?: string | null; roomNameSnapshot?: string | null;
quantity?: number; quantity?: number;
manualQuantity?: number;
powerPerUnit?: number; powerPerUnit?: number;
simultaneityFactor?: number; simultaneityFactor?: number;
cosPhi?: number | null; cosPhi?: number | null;
@@ -61,6 +63,7 @@ export function toCircuitDeviceRowUpdateValues(input: CircuitDeviceRowUpdateInpu
roomNumberSnapshot: input.roomNumberSnapshot ?? null, roomNumberSnapshot: input.roomNumberSnapshot ?? null,
roomNameSnapshot: input.roomNameSnapshot ?? null, roomNameSnapshot: input.roomNameSnapshot ?? null,
quantity: input.quantity, quantity: input.quantity,
manualQuantity: input.manualQuantity ?? input.quantity,
powerPerUnit: input.powerPerUnit, powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor, simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null, cosPhi: input.cosPhi ?? null,
@@ -90,6 +93,7 @@ export function toCircuitDeviceRowPatchValues(input: CircuitDeviceRowPatchInput)
} }
if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null; if (has("roomNameSnapshot")) values.roomNameSnapshot = input.roomNameSnapshot ?? null;
if (input.quantity !== undefined) values.quantity = input.quantity; if (input.quantity !== undefined) values.quantity = input.quantity;
if (input.manualQuantity !== undefined) values.manualQuantity = input.manualQuantity;
if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit; if (input.powerPerUnit !== undefined) values.powerPerUnit = input.powerPerUnit;
if (input.simultaneityFactor !== undefined) { if (input.simultaneityFactor !== undefined) {
values.simultaneityFactor = input.simultaneityFactor; values.simultaneityFactor = input.simultaneityFactor;
@@ -37,6 +37,7 @@ export class CircuitDeviceRowRepository {
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot, roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot, roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity, quantity: circuitDeviceRows.quantity,
manualQuantity: circuitDeviceRows.manualQuantity,
powerPerUnit: circuitDeviceRows.powerPerUnit, powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor, simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi, cosPhi: circuitDeviceRows.cosPhi,
@@ -24,6 +24,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupSubtreeProjectCommandRepository export class CircuitGroupSubtreeProjectCommandRepository
@@ -212,7 +213,10 @@ export class CircuitGroupSubtreeProjectCommandRepository
.run(); .run();
} }
if (deviceRows.length > 0) { if (deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(deviceRows).run(); database
.insert(circuitDeviceRows)
.values(deviceRows.map(toCircuitDeviceRowInsertValues))
.run();
} }
} }
} }
@@ -22,6 +22,7 @@ import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { import {
assertCircuitDeviceRowReferencesInProject, assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
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";
@@ -166,7 +167,10 @@ export class CircuitStructureProjectCommandRepository
}) })
.run(); .run();
if (snapshot.deviceRows.length > 0) { if (snapshot.deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run(); database
.insert(circuitDeviceRows)
.values(snapshot.deviceRows.map(toCircuitDeviceRowInsertValues))
.run();
} }
if (snapshot.protectionDevice) { if (snapshot.protectionDevice) {
database database
@@ -29,6 +29,7 @@ import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardSubtreeProjectCommandRepository export class DistributionBoardSubtreeProjectCommandRepository
@@ -253,7 +254,10 @@ export class DistributionBoardSubtreeProjectCommandRepository
.run(); .run();
} }
if (deviceRows.length > 0) { if (deviceRows.length > 0) {
database.insert(circuitDeviceRows).values(deviceRows).run(); database
.insert(circuitDeviceRows)
.values(deviceRows.map(toCircuitDeviceRowInsertValues))
.run();
} }
} }
} }
@@ -192,6 +192,7 @@ export function readProjectStateSnapshot(
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot, roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot, roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity, quantity: circuitDeviceRows.quantity,
manualQuantity: circuitDeviceRows.manualQuantity,
powerPerUnit: circuitDeviceRows.powerPerUnit, powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor, simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi, cosPhi: circuitDeviceRows.cosPhi,
+1
View File
@@ -25,6 +25,7 @@ export const circuitDeviceRows = sqliteTable("circuit_device_rows", {
roomNumberSnapshot: text("room_number_snapshot"), roomNumberSnapshot: text("room_number_snapshot"),
roomNameSnapshot: text("room_name_snapshot"), roomNameSnapshot: text("room_name_snapshot"),
quantity: integer("quantity").notNull(), quantity: integer("quantity").notNull(),
manualQuantity: integer("manual_quantity").notNull().default(0),
powerPerUnit: real("power_per_unit").notNull(), powerPerUnit: real("power_per_unit").notNull(),
simultaneityFactor: real("simultaneity_factor").notNull(), simultaneityFactor: real("simultaneity_factor").notNull(),
cosPhi: real("cos_phi"), cosPhi: real("cos_phi"),
@@ -0,0 +1,43 @@
export interface ExternalQuantityContribution {
effectiveQuantity: number;
}
export function calculateCircuitDeviceRowQuantity(
manualQuantity: number,
externalObjects: ExternalQuantityContribution[]
) {
assertNonNegativeFinite(manualQuantity, "Manual quantity");
return externalObjects.reduce((quantity, object) => {
assertPositiveFinite(object.effectiveQuantity, "External effective quantity");
return quantity + object.effectiveQuantity;
}, manualQuantity);
}
export function assertCircuitDeviceRowQuantity(input: {
quantity: number;
manualQuantity: number;
externalObjects: ExternalQuantityContribution[];
}) {
assertNonNegativeFinite(input.quantity, "Materialized quantity");
const expected = calculateCircuitDeviceRowQuantity(
input.manualQuantity,
input.externalObjects
);
if (input.quantity !== expected) {
throw new Error(
`Materialized quantity ${input.quantity} does not match manual and external quantity ${expected}.`
);
}
}
function assertNonNegativeFinite(value: number, label: string) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${label} must be a non-negative finite number.`);
}
}
function assertPositiveFinite(value: number, label: string) {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${label} must be a positive finite number.`);
}
}
@@ -18,6 +18,7 @@ export interface CircuitDeviceRowUpdateValues {
roomNumberSnapshot: string | null; roomNumberSnapshot: string | null;
roomNameSnapshot: string | null; roomNameSnapshot: string | null;
quantity: number; quantity: number;
manualQuantity: number;
powerPerUnit: number; powerPerUnit: number;
simultaneityFactor: number; simultaneityFactor: number;
cosPhi: number | null; cosPhi: number | null;
@@ -51,7 +52,18 @@ export function createCircuitDeviceRowUpdateProjectCommand(
rowId: string, rowId: string,
patch: CircuitDeviceRowUpdatePatch patch: CircuitDeviceRowUpdatePatch
): CircuitDeviceRowUpdateProjectCommand { ): CircuitDeviceRowUpdateProjectCommand {
const changes = Object.entries(patch).map(([field, value]) => ({ const normalizedPatch =
patch.quantity !== undefined && patch.manualQuantity === undefined
? { ...patch, manualQuantity: patch.quantity }
: patch;
if (
normalizedPatch.quantity !== undefined &&
normalizedPatch.manualQuantity !== undefined &&
normalizedPatch.manualQuantity > normalizedPatch.quantity
) {
throw new Error("manualQuantity must not exceed quantity.");
}
const changes = Object.entries(normalizedPatch).map(([field, value]) => ({
field: field as CircuitDeviceRowUpdateField, field: field as CircuitDeviceRowUpdateField,
value, value,
})) as CircuitDeviceRowUpdateFieldChange[]; })) as CircuitDeviceRowUpdateFieldChange[];
@@ -125,6 +137,7 @@ function assertCircuitDeviceRowUpdateFieldValue(
} }
if ( if (
field === "quantity" || field === "quantity" ||
field === "manualQuantity" ||
field === "powerPerUnit" || field === "powerPerUnit" ||
field === "simultaneityFactor" field === "simultaneityFactor"
) { ) {
@@ -164,6 +177,7 @@ function isCircuitDeviceRowUpdateField(
"roomNumberSnapshot", "roomNumberSnapshot",
"roomNameSnapshot", "roomNameSnapshot",
"quantity", "quantity",
"manualQuantity",
"powerPerUnit", "powerPerUnit",
"simultaneityFactor", "simultaneityFactor",
"cosPhi", "cosPhi",
@@ -22,6 +22,7 @@ export interface CircuitDeviceRowSnapshot {
roomNumberSnapshot: string | null; roomNumberSnapshot: string | null;
roomNameSnapshot: string | null; roomNameSnapshot: string | null;
quantity: number; quantity: number;
manualQuantity?: number;
powerPerUnit: number; powerPerUnit: number;
simultaneityFactor: number; simultaneityFactor: number;
cosPhi: number | null; cosPhi: number | null;
@@ -57,10 +58,14 @@ export type CircuitDeviceRowStructureProjectCommand =
export function createCircuitDeviceRowInsertProjectCommand( export function createCircuitDeviceRowInsertProjectCommand(
row: CircuitDeviceRowSnapshot row: CircuitDeviceRowSnapshot
): CircuitDeviceRowInsertProjectCommand { ): CircuitDeviceRowInsertProjectCommand {
const normalizedRow = {
...row,
manualQuantity: row.manualQuantity ?? row.quantity,
};
const command: CircuitDeviceRowInsertProjectCommand = { const command: CircuitDeviceRowInsertProjectCommand = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType, type: circuitDeviceRowInsertCommandType,
payload: { row }, payload: { row: normalizedRow },
}; };
assertCircuitDeviceRowInsertProjectCommand(command); assertCircuitDeviceRowInsertProjectCommand(command);
return command; return command;
@@ -117,7 +122,13 @@ export function assertCircuitDeviceRowInsertProjectCommand(
] as const) { ] as const) {
assertNullableString(row[field], `row.${field}`); assertNullableString(row[field], `row.${field}`);
} }
assertNonNegativeNumber(row.quantity, "row.quantity"); const quantity = row.quantity;
assertNonNegativeNumber(quantity, "row.quantity");
const manualQuantity = row.manualQuantity ?? quantity;
assertNonNegativeNumber(manualQuantity, "row.manualQuantity");
if (manualQuantity > quantity) {
throw new Error("row.manualQuantity must not exceed row.quantity.");
}
assertNonNegativeNumber(row.powerPerUnit, "row.powerPerUnit"); assertNonNegativeNumber(row.powerPerUnit, "row.powerPerUnit");
assertNonNegativeNumber( assertNonNegativeNumber(
row.simultaneityFactor, row.simultaneityFactor,
@@ -172,7 +183,10 @@ function assertFiniteNumber(
} }
} }
function assertNonNegativeNumber(value: unknown, field: string) { function assertNonNegativeNumber(
value: unknown,
field: string
): asserts value is number {
assertFiniteNumber(value, field); assertFiniteNumber(value, field);
if (value < 0) { if (value < 0) {
throw new Error(`${field} must not be negative.`); throw new Error(`${field} must not be negative.`);
@@ -14,6 +14,7 @@ export interface CircuitDeviceRow {
roomNumberSnapshot?: string; roomNumberSnapshot?: string;
roomNameSnapshot?: string; roomNameSnapshot?: string;
quantity: number; quantity: number;
manualQuantity: number;
powerPerUnit: number; powerPerUnit: number;
simultaneityFactor: number; simultaneityFactor: number;
cosPhi?: number; cosPhi?: number;
+1
View File
@@ -13,6 +13,7 @@ export interface CircuitTreeDeviceRow {
roomNumberSnapshot?: string; roomNumberSnapshot?: string;
roomNameSnapshot?: string; roomNameSnapshot?: string;
quantity: number; quantity: number;
manualQuantity: number;
powerPerUnit: number; powerPerUnit: number;
simultaneityFactor: number; simultaneityFactor: number;
cosPhi?: number; cosPhi?: number;
@@ -27,8 +27,9 @@ import {
type ExternalModelStateSnapshot, type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js"; } from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 4 as const; export const projectStateSnapshotSchemaVersion = 5 as const;
const previousProjectStateSnapshotSchemaVersion = 3 as const; const previousProjectStateSnapshotSchemaVersion = 4 as const;
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const; const legacyProjectStateSnapshotSchemaVersion = 2 as const;
const baselineProjectStateSnapshotSchemaVersion = 1 as const; const baselineProjectStateSnapshotSchemaVersion = 1 as const;
@@ -103,8 +104,15 @@ const circuitSectionSchema = z
} }
}); });
const circuitDeviceRowSchema = z const circuitDeviceRowSchema = z.preprocess(
.object({ (value) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
const row = value as Record<string, unknown>;
return Object.prototype.hasOwnProperty.call(row, "manualQuantity")
? row
: { ...row, manualQuantity: row.quantity };
},
z.object({
id: idSchema, id: idSchema,
circuitId: idSchema, circuitId: idSchema,
linkedProjectDeviceId: idSchema.nullable(), linkedProjectDeviceId: idSchema.nullable(),
@@ -120,13 +128,15 @@ const circuitDeviceRowSchema = z
roomNumberSnapshot: nullableStringSchema, roomNumberSnapshot: nullableStringSchema,
roomNameSnapshot: nullableStringSchema, roomNameSnapshot: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(), quantity: finiteNumberSchema.nonnegative(),
manualQuantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(), powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(), simultaneityFactor: finiteNumberSchema.nonnegative(),
cosPhi: finiteNumberSchema.positive().nullable(), cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema, remark: nullableStringSchema,
overriddenFields: nullableStringSchema, overriddenFields: nullableStringSchema,
}) })
.strict(); .strict()
);
const circuitSchema = z const circuitSchema = z
.object({ .object({
@@ -453,6 +463,17 @@ const previousProjectStateSnapshotSchema = z
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const externalModelProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents, ...projectStateSnapshotContents,
}) })
.strict(); .strict();
@@ -502,6 +523,13 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const schemaVersion = (value as { schemaVersion: number }).schemaVersion; const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) { if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
const previous = previousProjectStateSnapshotSchema.parse(value); const previous = previousProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
};
}
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
const previous = externalModelProjectStateSnapshotSchema.parse(value);
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
@@ -765,6 +793,11 @@ function assertProjectStateSnapshotRelations(
if (row.roomId !== null) { if (row.roomId !== null) {
assertReference(roomIds, row.roomId, "device row room"); assertReference(roomIds, row.roomId, "device row room");
} }
if (row.manualQuantity > row.quantity) {
throw new Error(
"Snapshot device row manual quantity must not exceed total quantity."
);
}
} }
} }
+1
View File
@@ -350,6 +350,7 @@ export interface CircuitTreeDeviceRowDto {
roomNumberSnapshot?: string; roomNumberSnapshot?: string;
roomNameSnapshot?: string; roomNameSnapshot?: string;
quantity: number; quantity: number;
manualQuantity: number;
powerPerUnit: number; powerPerUnit: number;
simultaneityFactor: number; simultaneityFactor: number;
cosPhi?: number; cosPhi?: number;
@@ -160,6 +160,7 @@ export async function getCircuitTree(req: Request, res: Response) {
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined, roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
roomNameSnapshot: row.roomNameSnapshot ?? undefined, roomNameSnapshot: row.roomNameSnapshot ?? undefined,
quantity: row.quantity, quantity: row.quantity,
manualQuantity: row.manualQuantity,
powerPerUnit: row.powerPerUnit, powerPerUnit: row.powerPerUnit,
simultaneityFactor: row.simultaneityFactor, simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi ?? undefined, cosPhi: row.cosPhi ?? undefined,
@@ -128,6 +128,7 @@ function createTestDatabase(): DatabaseContext {
name: "Leuchte", name: "Leuchte",
displayName: "Leuchte lokal", displayName: "Leuchte lokal",
quantity: 1, quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.1, powerPerUnit: 0.1,
simultaneityFactor: 1, simultaneityFactor: 1,
}, },
@@ -138,6 +139,7 @@ function createTestDatabase(): DatabaseContext {
name: "Fremdzeile", name: "Fremdzeile",
displayName: "Fremdzeile", displayName: "Fremdzeile",
quantity: 1, quantity: 1,
manualQuantity: 1,
powerPerUnit: 0.2, powerPerUnit: 0.2,
simultaneityFactor: 1, simultaneityFactor: 1,
}, },
@@ -175,6 +177,7 @@ describe("circuit device-row project-command repository", () => {
const row = getRow(context); const row = getRow(context);
assert.equal(row.quantity, 2); assert.equal(row.quantity, 2);
assert.equal(row.manualQuantity, 2);
assert.equal(row.roomId, "room-1"); assert.equal(row.roomId, "room-1");
assert.equal(row.remark, null); assert.equal(row.remark, null);
assert.equal(row.overriddenFields, "[\"quantity\"]"); assert.equal(row.overriddenFields, "[\"quantity\"]");
@@ -185,6 +188,7 @@ describe("circuit device-row project-command repository", () => {
{ field: "quantity", value: 1 }, { field: "quantity", value: 1 },
{ field: "roomId", value: null }, { field: "roomId", value: null },
{ field: "remark", value: null }, { field: "remark", value: null },
{ field: "manualQuantity", value: 1 },
{ field: "overriddenFields", value: null }, { field: "overriddenFields", value: null },
], ],
}); });
@@ -203,6 +207,7 @@ describe("circuit device-row project-command repository", () => {
{ field: "quantity", value: 2 }, { field: "quantity", value: 2 },
{ field: "roomId", value: "room-1" }, { field: "roomId", value: "room-1" },
{ field: "remark", value: null }, { field: "remark", value: null },
{ field: "manualQuantity", value: 2 },
{ field: "overriddenFields", value: "[\"quantity\"]" }, { field: "overriddenFields", value: "[\"quantity\"]" },
], ],
}, },
@@ -234,6 +239,7 @@ describe("circuit device-row project-command repository", () => {
const row = getRow(context); const row = getRow(context);
assert.equal(row.quantity, 1); assert.equal(row.quantity, 1);
assert.equal(row.manualQuantity, 1);
assert.equal(row.overriddenFields, null); assert.equal(row.overriddenFields, null);
assert.equal(undone.revision.revisionNumber, 2); assert.equal(undone.revision.revisionNumber, 2);
assert.deepEqual( assert.deepEqual(
@@ -377,4 +383,22 @@ describe("circuit device-row project-command repository", () => {
context.close(); context.close();
} }
}); });
it("rejects a manual share above the materialized total", () => {
const context = createTestDatabase();
try {
const store = new CircuitDeviceRowProjectCommandRepository(context.db);
assert.throws(() => store.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
manualQuantity: 2,
}),
}), /manual quantity/);
assert.equal(getRow(context).manualQuantity, 1);
} finally {
context.close();
}
});
}); });
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
import {
assertCircuitDeviceRowQuantity,
calculateCircuitDeviceRowQuantity,
} from "../src/domain/calculations/circuit-device-row-quantity.js";
describe("circuit device-row quantity", () => {
it("adds the manual share and every indivisible external quantity", () => {
assert.equal(
calculateCircuitDeviceRowQuantity(2, [
{ effectiveQuantity: 2 },
{ effectiveQuantity: 1 },
]),
5
);
assert.doesNotThrow(() => assertCircuitDeviceRowQuantity({
quantity: 5,
manualQuantity: 2,
externalObjects: [{ effectiveQuantity: 2 }, { effectiveQuantity: 1 }],
}));
});
it("rejects invalid shares and a stale materialized total", () => {
assert.throws(
() => calculateCircuitDeviceRowQuantity(-1, []),
/Manual quantity/
);
assert.throws(
() => calculateCircuitDeviceRowQuantity(0, [{ effectiveQuantity: 0 }]),
/External effective quantity/
);
assert.throws(
() => assertCircuitDeviceRowQuantity({
quantity: 2,
manualQuantity: 1,
externalObjects: [{ effectiveQuantity: 2 }],
}),
/does not match/
);
});
it("preserves existing row quantities in migration 0005", () => {
const database = new Database(":memory:");
try {
database.exec("CREATE TABLE circuit_device_rows (id text PRIMARY KEY, quantity integer NOT NULL)");
database.exec("INSERT INTO circuit_device_rows (id, quantity) VALUES ('row-1', 7)");
const migration = fs
.readFileSync("src/db/migrations/0005_stale_gorilla_man.sql", "utf8")
.replaceAll("--> statement-breakpoint", "");
database.exec(migration);
assert.deepEqual(
database.prepare("SELECT quantity, manual_quantity FROM circuit_device_rows").get(),
{ quantity: 7, manual_quantity: 7 }
);
} finally {
database.close();
}
});
});
+2 -1
View File
@@ -21,6 +21,7 @@ import { projectDevices } from "../src/db/schema/project-devices.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 { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import { import {
@@ -211,7 +212,7 @@ describe("external model snapshot and transfer", () => {
insertLinkedExternalFixture(context.db); insertLinkedExternalFixture(context.db);
const populated = readProjectStateSnapshot(context.db, "project-1"); const populated = readProjectStateSnapshot(context.db, "project-1");
assert.ok(populated); assert.ok(populated);
assert.equal(populated.state.schemaVersion, 4); assert.equal(populated.state.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1"); assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1");
context.db.delete(externalModelSources).run(); context.db.delete(externalModelSources).run();
+9 -1
View File
@@ -179,6 +179,7 @@ describe("circuit device-row update project commands", () => {
{ field: "roomId", value: null }, { field: "roomId", value: null },
{ field: "quantity", value: 2 }, { field: "quantity", value: 2 },
{ field: "cosPhi", value: 0.9 }, { field: "cosPhi", value: 0.9 },
{ field: "manualQuantity", value: 2 },
]); ]);
}); });
@@ -187,6 +188,13 @@ describe("circuit device-row update project commands", () => {
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}), () => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
/at least one change/ /at least one change/
); );
assert.throws(
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 1,
manualQuantity: 2,
}),
/must not exceed/
);
assert.throws( assert.throws(
() => () =>
createCircuitDeviceRowUpdateProjectCommand("row-1", { createCircuitDeviceRowUpdateProjectCommand("row-1", {
@@ -470,7 +478,7 @@ describe("circuit device-row structure project commands", () => {
row.circuitId row.circuitId
); );
assert.deepEqual(insert.payload.row, row); assert.deepEqual(insert.payload.row, { ...row, manualQuantity: 1 });
assert.deepEqual(remove.payload, { assert.deepEqual(remove.payload, {
rowId: "row-1", rowId: "row-1",
expectedCircuitId: "circuit-1", expectedCircuitId: "circuit-1",
+33
View File
@@ -199,6 +199,39 @@ describe("project state snapshot model", () => {
}); });
}); });
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
const previous = protectedSnapshot();
previous.schemaVersion = 4 as typeof previous.schemaVersion;
previous.circuits[0]!.isReserve = false;
previous.circuits[0]!.deviceRows = [{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 3,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 1,
remark: null,
overriddenFields: null,
}];
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.circuits[0]?.deviceRows[0]?.manualQuantity, 3);
});
it("rejects malformed baseline and unknown snapshot shapes", () => { it("rejects malformed baseline and unknown snapshot shapes", () => {
const outdated = structuredClone(minimalSnapshot()); const outdated = structuredClone(minimalSnapshot());
delete (outdated.project as Record<string, unknown>).description; delete (outdated.project as Record<string, unknown>).description;