Compare commits
9 Commits
30c7555c68
...
3fbf3ac622
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fbf3ac622 | |||
| 04c299e3f2 | |||
| d7ce135ac8 | |||
| 030f18aeb0 | |||
| 4d5eb22e66 | |||
| 9be94028b3 | |||
| 91baa6c76c | |||
| cf356bbedc | |||
| a6837d7242 |
@@ -148,14 +148,12 @@ Request sketch:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Legacy Endpoints (Temporary)
|
## Removed Legacy Endpoints
|
||||||
|
|
||||||
- `GET /consumers/projects/:projectId`
|
The former `/consumers` read/write endpoints were removed after every retained
|
||||||
- `POST /consumers`
|
consumer had a verified Circuit-First migration mapping. Database upgrade tooling
|
||||||
- `PUT /consumers/:consumerId`
|
reads retained legacy rows directly; application features must use the Circuit-First
|
||||||
- `DELETE /consumers/:consumerId`
|
endpoints above.
|
||||||
|
|
||||||
These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above.
|
|
||||||
|
|
||||||
## Linked Project Device Review
|
## Linked Project Device Review
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,4 @@
|
|||||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||||
- Sorting is view-only until users explicitly apply sorted order.
|
- Sorting is view-only until users explicitly apply sorted order.
|
||||||
- Cross-section circuit drag-reorder is intentionally blocked.
|
- Cross-section circuit drag-reorder is intentionally blocked.
|
||||||
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
|
- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability.
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ Run in this order for local database workflows:
|
|||||||
5. Migrate legacy consumers:
|
5. Migrate legacy consumers:
|
||||||
- `npm run db:migrate:legacy-consumers`
|
- `npm run db:migrate:legacy-consumers`
|
||||||
|
|
||||||
|
The migration command finishes with a cutover verification across the complete
|
||||||
|
database. It exits with an error while any legacy consumer lacks a migration
|
||||||
|
mapping, including consumers that cannot be migrated because they have no circuit
|
||||||
|
list assignment. The legacy multi-list UI was removed only after this check passed;
|
||||||
|
old `/projects/:projectId/circuit-lists` bookmarks redirect to the project page.
|
||||||
|
|
||||||
## Validation Checks
|
## Validation Checks
|
||||||
|
|
||||||
After migration, verify:
|
After migration, verify:
|
||||||
@@ -72,6 +78,6 @@ Actions:
|
|||||||
|
|
||||||
Do not delete legacy consumers yet.
|
Do not delete legacy consumers yet.
|
||||||
|
|
||||||
- legacy endpoints/views may still depend on them
|
- legacy read/write endpoints have been removed from the application server
|
||||||
- migration trace tables reference old/new mapping
|
- migration trace tables reference old/new mapping
|
||||||
- keeping legacy rows allows comparison and rollback validation during transition
|
- keeping legacy rows allows comparison and rollback validation during transition
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in
|
|||||||
npm run db:backup
|
npm run db:backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The command uses SQLite's online backup API, so committed WAL changes are included
|
||||||
|
even while the application is running. It opens the resulting standalone database
|
||||||
|
and requires both `PRAGMA integrity_check` and `PRAGMA foreign_key_check` to pass.
|
||||||
|
Database backups are operational recovery files and remain separate from the future
|
||||||
|
user-visible project version history.
|
||||||
|
|
||||||
2. Apply pending schema migrations (includes `0008_circuit_first_model` and the additive
|
2. Apply pending schema migrations (includes `0008_circuit_first_model` and the additive
|
||||||
`0009_project_device_circuit_fields` transition)
|
`0009_project_device_circuit_fields` transition)
|
||||||
|
|
||||||
|
|||||||
@@ -328,9 +328,22 @@ Implemented foundation:
|
|||||||
- SQLite foreign-key enforcement is enabled explicitly for every context
|
- SQLite foreign-key enforcement is enabled explicitly for every context
|
||||||
- the distribution-board repository receives its database dependency explicitly
|
- the distribution-board repository receives its database dependency explicitly
|
||||||
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations
|
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations
|
||||||
|
- database backups use SQLite's online backup API and include committed WAL data
|
||||||
|
- every backup is opened independently and checked for integrity and foreign-key violations
|
||||||
|
- an integration test restores the backup into a separate database and verifies its snapshot contents
|
||||||
- circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store
|
- circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store
|
||||||
|
- a new circuit and all of its initial device rows use the same transaction store
|
||||||
|
- device-row moves, reserve-state updates and optional target creation share one transaction
|
||||||
|
- project-device synchronization, disconnect and reconnect use a separate injected transaction store
|
||||||
|
- section renumbering and circuit reordering use a separate injected transaction store
|
||||||
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
- circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks
|
||||||
- persistence value mapping is separated from the general circuit-device-row repository
|
- project-device row synchronization has real multi-row SQLite commit and rollback coverage
|
||||||
|
- BMK swaps and section reorder have real SQLite commit and rollback coverage
|
||||||
|
- the legacy consumer migration receives its database dependency at the script entry point
|
||||||
|
- legacy circuit, row, mapping and report writes have real late-failure rollback coverage
|
||||||
|
- circuit and device-row persistence value mapping is separated from the general repositories
|
||||||
|
- the legacy consumer UI and application read/write endpoints are removed after verified data cutover
|
||||||
|
- retained legacy rows are accessible only through explicit database upgrade tooling
|
||||||
|
|
||||||
## Phase 12: Project Revisions and Persistent Undo / Redo
|
## Phase 12: Project Revisions and Persistent Undo / Redo
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -14,11 +14,11 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts",
|
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/database-backup.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts",
|
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/database-backup.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||||
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
||||||
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts",
|
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts",
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
const dataDir = path.resolve("data");
|
|
||||||
const source = path.join(dataDir, "leistungsbilanz.db");
|
|
||||||
|
|
||||||
if (!fs.existsSync(source)) {
|
|
||||||
console.error(`Database file not found: ${source}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const backupDir = path.join(dataDir, "backups");
|
|
||||||
fs.mkdirSync(backupDir, { recursive: true });
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
||||||
const target = path.join(backupDir, `leistungsbilanz-${timestamp}.db`);
|
|
||||||
fs.copyFileSync(source, target);
|
|
||||||
|
|
||||||
console.log(`Backup created: ${target}`);
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { createVerifiedDatabaseBackup } from "../src/db/database-backup.js";
|
||||||
|
|
||||||
|
const dataDirectory = path.resolve("data");
|
||||||
|
const source = path.join(dataDirectory, "leistungsbilanz.db");
|
||||||
|
const backupDirectory = path.join(dataDirectory, "backups");
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const destination = path.join(
|
||||||
|
backupDirectory,
|
||||||
|
`leistungsbilanz-${timestamp}.db`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const verification = await createVerifiedDatabaseBackup(source, destination);
|
||||||
|
console.log(`Backup created and verified: ${destination}`);
|
||||||
|
console.log(
|
||||||
|
`Integrity: ${verification.integrityCheck}; foreign-key violations: ${verification.foreignKeyViolations}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"Database backup failed:",
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { db } from "../src/db/client.js";
|
||||||
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js";
|
||||||
|
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
||||||
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
import { ProjectRepository } from "../src/db/repositories/project.repository.js";
|
||||||
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
|
import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js";
|
||||||
|
|
||||||
const projectRepository = new ProjectRepository();
|
const projectRepository = new ProjectRepository();
|
||||||
const circuitListRepository = new CircuitListRepository();
|
const circuitListRepository = new CircuitListRepository();
|
||||||
const migrationService = new LegacyConsumerMigrationService();
|
const migrationRepository = new LegacyConsumerMigrationRepository(db);
|
||||||
|
const migrationService = new LegacyConsumerMigrationService(
|
||||||
|
migrationRepository
|
||||||
|
);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const projects = await projectRepository.list();
|
const projects = await projectRepository.list();
|
||||||
@@ -26,8 +31,21 @@ async function run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unmigratedConsumers =
|
||||||
|
await migrationRepository.listUnmigratedConsumers();
|
||||||
|
if (unmigratedConsumers.length > 0) {
|
||||||
|
const withoutCircuitList = unmigratedConsumers.filter(
|
||||||
|
(consumer) => !consumer.circuitListId
|
||||||
|
).length;
|
||||||
|
throw new Error(
|
||||||
|
`Cutover verification failed: ${unmigratedConsumers.length} legacy consumer(s) remain unmigrated` +
|
||||||
|
` (${withoutCircuitList} without a circuit list).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Legacy consumer migration summary:");
|
console.log("Legacy consumer migration summary:");
|
||||||
console.table(reports);
|
console.table(reports);
|
||||||
|
console.log("Cutover verification passed: all legacy consumers are mapped.");
|
||||||
}
|
}
|
||||||
|
|
||||||
run().catch((error) => {
|
run().catch((error) => {
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ export default function CircuitTreeEditPage() {
|
|||||||
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
||||||
Zum Projekt
|
Zum Projekt
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/projects/${params.projectId}/circuit-lists`} className="btn btn-outline-secondary btn-sm">
|
|
||||||
Bisherige Listenansicht
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CircuitTreeEditor projectId={params.projectId} circuitListId={params.circuitListId} />
|
<CircuitTreeEditor projectId={params.projectId} circuitListId={params.circuitListId} />
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export default function CircuitTreePreviewPage() {
|
|||||||
Editor öffnen
|
Editor öffnen
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/projects/${projectId}/circuit-lists`}
|
href={`/projects/${projectId}`}
|
||||||
className="btn btn-outline-secondary btn-sm"
|
className="btn btn-outline-secondary btn-sm"
|
||||||
>
|
>
|
||||||
Zurück zur bisherigen Listenansicht
|
Zum Projekt
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -607,12 +607,6 @@ export default function ProjectDetailPage() {
|
|||||||
Keine Stromkreisliste vorhanden
|
Keine Stromkreisliste vorhanden
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Link
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
href={`/projects/${projectId}/circuit-lists?boardId=${board.id}`}
|
|
||||||
>
|
|
||||||
Bisherige Ansicht
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1124,11 +1118,6 @@ export default function ProjectDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Link className="btn btn-primary" href={`/projects/${projectId}/circuit-lists`}>
|
|
||||||
3 parallele Stromkreislisten öffnen
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
export interface DatabaseVerification {
|
||||||
|
integrityCheck: "ok";
|
||||||
|
foreignKeyViolations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDatabasePath(filename: string): string {
|
||||||
|
return path.resolve(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyDatabaseFile(filename: string): DatabaseVerification {
|
||||||
|
const databasePath = resolveDatabasePath(filename);
|
||||||
|
const sqlite = new Database(databasePath, {
|
||||||
|
readonly: true,
|
||||||
|
fileMustExist: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const integrityCheck = sqlite.pragma("integrity_check", {
|
||||||
|
simple: true,
|
||||||
|
});
|
||||||
|
if (integrityCheck !== "ok") {
|
||||||
|
throw new Error(
|
||||||
|
`SQLite integrity check failed for ${databasePath}: ${String(integrityCheck)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foreignKeyCheck = sqlite.pragma("foreign_key_check") as unknown[];
|
||||||
|
const foreignKeyViolations = foreignKeyCheck.length;
|
||||||
|
if (foreignKeyViolations > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`SQLite foreign-key check failed for ${databasePath}: ${foreignKeyViolations} violation(s)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
integrityCheck: "ok",
|
||||||
|
foreignKeyViolations,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVerifiedDatabaseBackup(
|
||||||
|
sourceFilename: string,
|
||||||
|
destinationFilename: string
|
||||||
|
): Promise<DatabaseVerification> {
|
||||||
|
const sourcePath = resolveDatabasePath(sourceFilename);
|
||||||
|
const destinationPath = resolveDatabasePath(destinationFilename);
|
||||||
|
|
||||||
|
if (sourcePath === destinationPath) {
|
||||||
|
throw new Error("Backup source and destination must be different files.");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Database file not found: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(destinationPath)) {
|
||||||
|
throw new Error(`Backup destination already exists: ${destinationPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||||
|
|
||||||
|
const source = new Database(sourcePath, {
|
||||||
|
readonly: true,
|
||||||
|
fileMustExist: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await source.backup(destinationPath);
|
||||||
|
} finally {
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyDatabaseFile(destinationPath);
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import type {
|
import type {
|
||||||
CircuitDeviceRowTransactionStore,
|
CircuitDeviceRowTransactionStore,
|
||||||
CreateCircuitDeviceRowTransactionInput,
|
CreateCircuitDeviceRowTransactionInput,
|
||||||
|
CreateCircuitWithDeviceRowsTransactionInput,
|
||||||
|
MoveCircuitDeviceRowsTransactionInput,
|
||||||
} from "../../domain/ports/circuit-device-row-transaction.store.js";
|
} from "../../domain/ports/circuit-device-row-transaction.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { toCircuitCreateValues } from "./circuit.persistence.js";
|
||||||
import {
|
import {
|
||||||
toCircuitDeviceRowCreateValues,
|
toCircuitDeviceRowCreateValues,
|
||||||
} from "./circuit-device-row.persistence.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
@@ -53,6 +56,42 @@ export class CircuitDeviceRowTransactionRepository
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
|
||||||
|
if (input.deviceRows.length === 0) {
|
||||||
|
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const circuitId = crypto.randomUUID();
|
||||||
|
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
tx
|
||||||
|
.insert(circuits)
|
||||||
|
.values(
|
||||||
|
toCircuitCreateValues(circuitId, {
|
||||||
|
...input.circuit,
|
||||||
|
isReserve: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
for (let index = 0; index < input.deviceRows.length; index += 1) {
|
||||||
|
const row = input.deviceRows[index];
|
||||||
|
tx
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values(
|
||||||
|
toCircuitDeviceRowCreateValues(rowIds[index], {
|
||||||
|
...row,
|
||||||
|
circuitId,
|
||||||
|
sortOrder: row.sortOrder ?? (index + 1) * 10,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { circuitId, rowIds };
|
||||||
|
}
|
||||||
|
|
||||||
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
|
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
|
||||||
this.database.transaction((tx) => {
|
this.database.transaction((tx) => {
|
||||||
const [row] = tx
|
const [row] = tx
|
||||||
@@ -90,4 +129,154 @@ export class CircuitDeviceRowTransactionRepository
|
|||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moveRows(input: MoveCircuitDeviceRowsTransactionInput) {
|
||||||
|
if (input.rows.length === 0) {
|
||||||
|
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
|
||||||
|
}
|
||||||
|
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
|
||||||
|
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIds = input.rows.map((row) => row.id);
|
||||||
|
if (new Set(rowIds).size !== rowIds.length) {
|
||||||
|
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
|
||||||
|
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
|
||||||
|
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
const persistedRows = tx
|
||||||
|
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||||
|
.all();
|
||||||
|
if (persistedRows.length !== input.rows.length) {
|
||||||
|
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
|
||||||
|
for (const expectedRow of input.rows) {
|
||||||
|
if (
|
||||||
|
persistedRowsById.get(expectedRow.id)?.circuitId !==
|
||||||
|
expectedRow.expectedCircuitId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetCircuit: { id: string; circuitListId: string };
|
||||||
|
if (input.createTargetCircuit) {
|
||||||
|
tx
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: targetCircuitId,
|
||||||
|
circuitListId: input.createTargetCircuit.circuitListId,
|
||||||
|
sectionId: input.createTargetCircuit.sectionId,
|
||||||
|
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
|
||||||
|
displayName: input.createTargetCircuit.displayName,
|
||||||
|
sortOrder: input.createTargetCircuit.sortOrder,
|
||||||
|
isReserve: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
targetCircuit = {
|
||||||
|
id: targetCircuitId,
|
||||||
|
circuitListId: input.createTargetCircuit.circuitListId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const [persistedTargetCircuit] = tx
|
||||||
|
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, targetCircuitId))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
if (!persistedTargetCircuit) {
|
||||||
|
throw new Error("Der Zielstromkreis ist ungültig.");
|
||||||
|
}
|
||||||
|
targetCircuit = persistedTargetCircuit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCircuitIds = [
|
||||||
|
...new Set(input.rows.map((row) => row.expectedCircuitId)),
|
||||||
|
];
|
||||||
|
const sourceCircuits = tx
|
||||||
|
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
||||||
|
.from(circuits)
|
||||||
|
.where(inArray(circuits.id, sourceCircuitIds))
|
||||||
|
.all();
|
||||||
|
if (sourceCircuits.length !== sourceCircuitIds.length) {
|
||||||
|
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
sourceCircuits.some(
|
||||||
|
(circuit) => circuit.circuitListId !== targetCircuit.circuitListId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedRowIdSet = new Set(rowIds);
|
||||||
|
const retainedTargetRows = tx
|
||||||
|
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
|
||||||
|
.all()
|
||||||
|
.filter((row) => !movedRowIdSet.has(row.id));
|
||||||
|
const lastTargetSortOrder = retainedTargetRows.reduce(
|
||||||
|
(highest, row) => Math.max(highest, row.sortOrder),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let index = 0; index < input.rows.length; index += 1) {
|
||||||
|
const row = input.rows[index];
|
||||||
|
const result = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({
|
||||||
|
circuitId: targetCircuitId,
|
||||||
|
sortOrder: lastTargetSortOrder + (index + 1) * 10,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, row.id),
|
||||||
|
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceCircuitId of sourceCircuitIds) {
|
||||||
|
const remainingRows = tx
|
||||||
|
.select({ id: circuitDeviceRows.id })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
|
||||||
|
.all();
|
||||||
|
tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
||||||
|
.where(eq(circuits.id, sourceCircuitId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ isReserve: 0 })
|
||||||
|
.where(eq(circuits.id, targetCircuitId))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
movedRowIds: rowIds,
|
||||||
|
targetCircuitId,
|
||||||
|
createdCircuitId,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
@@ -13,11 +13,6 @@ import {
|
|||||||
type CircuitDeviceRowPatchInput,
|
type CircuitDeviceRowPatchInput,
|
||||||
type CircuitDeviceRowUpdateInput,
|
type CircuitDeviceRowUpdateInput,
|
||||||
} from "./circuit-device-row.persistence.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
import {
|
|
||||||
toCircuitCreateValues,
|
|
||||||
type CircuitCreatePersistenceInput,
|
|
||||||
} from "./circuit.repository.js";
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CircuitDeviceRowCreateInput,
|
CircuitDeviceRowCreateInput,
|
||||||
CircuitDeviceRowPatchInput,
|
CircuitDeviceRowPatchInput,
|
||||||
@@ -25,25 +20,6 @@ export type {
|
|||||||
} from "./circuit-device-row.persistence.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
|
||||||
|
|
||||||
export interface CircuitDeviceRowsBulkMoveInput {
|
|
||||||
rows: Array<{ id: string; expectedCircuitId: string }>;
|
|
||||||
targetCircuitId?: string;
|
|
||||||
createTargetCircuit?: {
|
|
||||||
circuitListId: string;
|
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName: string;
|
|
||||||
sortOrder: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CircuitWithDeviceRowsCreateInput {
|
|
||||||
circuit: CircuitCreatePersistenceInput;
|
|
||||||
deviceRows: Array<
|
|
||||||
Omit<CircuitDeviceRowCreateInput, "circuitId" | "sortOrder"> & { sortOrder?: number }
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CircuitDeviceRowRepository {
|
export class CircuitDeviceRowRepository {
|
||||||
async findById(rowId: string) {
|
async findById(rowId: string) {
|
||||||
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
|
const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1);
|
||||||
@@ -131,42 +107,6 @@ export class CircuitDeviceRowRepository {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
createCircuitWithDeviceRowsTransactional(input: CircuitWithDeviceRowsCreateInput) {
|
|
||||||
if (input.deviceRows.length === 0) {
|
|
||||||
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const circuitId = crypto.randomUUID();
|
|
||||||
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
|
|
||||||
db.transaction((tx) => {
|
|
||||||
tx
|
|
||||||
.insert(circuits)
|
|
||||||
.values(
|
|
||||||
toCircuitCreateValues(circuitId, {
|
|
||||||
...input.circuit,
|
|
||||||
isReserve: false,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
for (let index = 0; index < input.deviceRows.length; index += 1) {
|
|
||||||
const row = input.deviceRows[index];
|
|
||||||
tx
|
|
||||||
.insert(circuitDeviceRows)
|
|
||||||
.values(
|
|
||||||
toCircuitDeviceRowCreateValues(rowIds[index], {
|
|
||||||
...row,
|
|
||||||
circuitId,
|
|
||||||
sortOrder: row.sortOrder ?? (index + 1) * 10,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { circuitId, rowIds };
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
rowId: string,
|
rowId: string,
|
||||||
input: CircuitDeviceRowUpdateInput
|
input: CircuitDeviceRowUpdateInput
|
||||||
@@ -185,64 +125,6 @@ export class CircuitDeviceRowRepository {
|
|||||||
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
|
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLinkedRowsTransactional(
|
|
||||||
projectDeviceId: string,
|
|
||||||
changes: Array<{ rowId: string; input: CircuitDeviceRowUpdateInput }>
|
|
||||||
) {
|
|
||||||
db.transaction((tx) => {
|
|
||||||
for (const change of changes) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set(toCircuitDeviceRowUpdateValues(change.input))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, change.rowId),
|
|
||||||
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error("A linked row changed before the operation could be completed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectLinkedRowsTransactional(projectDeviceId: string, rowIds: string[]) {
|
|
||||||
db.transaction((tx) => {
|
|
||||||
for (const rowId of rowIds) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({ linkedProjectDeviceId: null })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, rowId),
|
|
||||||
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error("A linked row changed before the operation could be completed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnectRowsTransactional(projectDeviceId: string, rowIds: string[]) {
|
|
||||||
db.transaction((tx) => {
|
|
||||||
for (const rowId of rowIds) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({ linkedProjectDeviceId: projectDeviceId })
|
|
||||||
.where(and(eq(circuitDeviceRows.id, rowId), isNull(circuitDeviceRows.linkedProjectDeviceId)))
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error("A disconnected row changed before the operation could be undone.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(rowId: string) {
|
async delete(rowId: string) {
|
||||||
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
@@ -257,137 +139,4 @@ export class CircuitDeviceRowRepository {
|
|||||||
.where(eq(circuitDeviceRows.id, rowId));
|
.where(eq(circuitDeviceRows.id, rowId));
|
||||||
}
|
}
|
||||||
|
|
||||||
moveRowsTransactional(input: CircuitDeviceRowsBulkMoveInput) {
|
|
||||||
if (input.rows.length === 0) {
|
|
||||||
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
|
|
||||||
}
|
|
||||||
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
|
|
||||||
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowIds = input.rows.map((row) => row.id);
|
|
||||||
if (new Set(rowIds).size !== rowIds.length) {
|
|
||||||
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
|
|
||||||
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
|
|
||||||
|
|
||||||
// better-sqlite3 transactions must remain synchronous. Every row assignment,
|
|
||||||
// reserve-state update and optional target creation is committed or rolled back
|
|
||||||
// as one operation.
|
|
||||||
db.transaction((tx) => {
|
|
||||||
const persistedRows = tx
|
|
||||||
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
|
||||||
.all();
|
|
||||||
if (persistedRows.length !== input.rows.length) {
|
|
||||||
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
|
|
||||||
for (const expectedRow of input.rows) {
|
|
||||||
if (persistedRowsById.get(expectedRow.id)?.circuitId !== expectedRow.expectedCircuitId) {
|
|
||||||
throw new Error("Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetCircuit: { id: string; circuitListId: string };
|
|
||||||
if (input.createTargetCircuit) {
|
|
||||||
tx
|
|
||||||
.insert(circuits)
|
|
||||||
.values({
|
|
||||||
id: targetCircuitId,
|
|
||||||
circuitListId: input.createTargetCircuit.circuitListId,
|
|
||||||
sectionId: input.createTargetCircuit.sectionId,
|
|
||||||
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
|
|
||||||
displayName: input.createTargetCircuit.displayName,
|
|
||||||
sortOrder: input.createTargetCircuit.sortOrder,
|
|
||||||
isReserve: 0,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
targetCircuit = {
|
|
||||||
id: targetCircuitId,
|
|
||||||
circuitListId: input.createTargetCircuit.circuitListId,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const [persistedTargetCircuit] = tx
|
|
||||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.id, targetCircuitId))
|
|
||||||
.limit(1)
|
|
||||||
.all();
|
|
||||||
if (!persistedTargetCircuit) {
|
|
||||||
throw new Error("Der Zielstromkreis ist ungültig.");
|
|
||||||
}
|
|
||||||
targetCircuit = persistedTargetCircuit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceCircuitIds = [...new Set(input.rows.map((row) => row.expectedCircuitId))];
|
|
||||||
const sourceCircuits = tx
|
|
||||||
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
|
|
||||||
.from(circuits)
|
|
||||||
.where(inArray(circuits.id, sourceCircuitIds))
|
|
||||||
.all();
|
|
||||||
if (sourceCircuits.length !== sourceCircuitIds.length) {
|
|
||||||
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
|
|
||||||
}
|
|
||||||
if (sourceCircuits.some((circuit) => circuit.circuitListId !== targetCircuit.circuitListId)) {
|
|
||||||
throw new Error("Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const movedRowIdSet = new Set(rowIds);
|
|
||||||
const retainedTargetRows = tx
|
|
||||||
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
|
|
||||||
.all()
|
|
||||||
.filter((row) => !movedRowIdSet.has(row.id));
|
|
||||||
const lastTargetSortOrder = retainedTargetRows.reduce(
|
|
||||||
(highest, row) => Math.max(highest, row.sortOrder),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let index = 0; index < input.rows.length; index += 1) {
|
|
||||||
const row = input.rows[index];
|
|
||||||
const result = tx
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({
|
|
||||||
circuitId: targetCircuitId,
|
|
||||||
sortOrder: lastTargetSortOrder + (index + 1) * 10,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuitDeviceRows.id, row.id),
|
|
||||||
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error("Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sourceCircuitId of sourceCircuitIds) {
|
|
||||||
const remainingRows = tx
|
|
||||||
.select({ id: circuitDeviceRows.id })
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
|
|
||||||
.all();
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
|
|
||||||
.where(eq(circuits.id, sourceCircuitId))
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, targetCircuitId)).run();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
movedRowIds: rowIds,
|
|
||||||
targetCircuitId,
|
|
||||||
createdCircuitId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { CircuitSectionTransactionStore } from "../../domain/ports/circuit-section-transaction.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
|
||||||
|
export class CircuitSectionTransactionRepository
|
||||||
|
implements CircuitSectionTransactionStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
updateSortOrders(sectionId: string, orderedCircuitIds: string[]) {
|
||||||
|
if (orderedCircuitIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
|
||||||
|
throw new Error(
|
||||||
|
"Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
const sectionCircuits = tx
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.sectionId, sectionId))
|
||||||
|
.all();
|
||||||
|
const sectionCircuitIds = new Set(
|
||||||
|
sectionCircuits.map((circuit) => circuit.id)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
sectionCircuits.length !== orderedCircuitIds.length ||
|
||||||
|
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Die Reihenfolge muss alle Stromkreise des Bereichs enthalten."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ sortOrder: (index + 1) * 10 })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.sectionId, sectionId),
|
||||||
|
eq(circuits.id, orderedCircuitIds[index])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Ein Stromkreis wurde vor Abschluss der Sortierung verändert."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEquipmentIdentifiers(
|
||||||
|
circuitListId: string,
|
||||||
|
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
||||||
|
tempNamespace: string
|
||||||
|
) {
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
const ids = updates.map((entry) => entry.id);
|
||||||
|
const existing = tx
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.circuitListId, circuitListId),
|
||||||
|
inArray(circuits.id, ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
if (existing.length !== ids.length) {
|
||||||
|
throw new Error(
|
||||||
|
"One or more circuit ids are invalid for circuit list."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
for (let index = 0; index < updates.length; index += 1) {
|
||||||
|
const entry = updates[index];
|
||||||
|
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
|
||||||
|
tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ equipmentIdentifier: tempIdentifier })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.circuitListId, circuitListId),
|
||||||
|
eq(circuits.id, entry.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of updates) {
|
||||||
|
tx
|
||||||
|
.update(circuits)
|
||||||
|
.set({ equipmentIdentifier: entry.equipmentIdentifier })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.circuitListId, circuitListId),
|
||||||
|
eq(circuits.id, entry.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export interface CircuitCreatePersistenceInput {
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
displayName?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
protectionType?: string;
|
||||||
|
protectionRatedCurrent?: number;
|
||||||
|
protectionCharacteristic?: string;
|
||||||
|
cableType?: string;
|
||||||
|
cableCrossSection?: string;
|
||||||
|
cableLength?: number;
|
||||||
|
voltage?: number;
|
||||||
|
controlRequirement?: string;
|
||||||
|
remark?: string;
|
||||||
|
rcdAssignment?: string;
|
||||||
|
terminalDesignation?: string;
|
||||||
|
status?: string;
|
||||||
|
isReserve?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
circuitListId: input.circuitListId,
|
||||||
|
sectionId: input.sectionId,
|
||||||
|
equipmentIdentifier: input.equipmentIdentifier,
|
||||||
|
displayName: input.displayName ?? null,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
protectionType: input.protectionType ?? null,
|
||||||
|
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
|
||||||
|
protectionCharacteristic: input.protectionCharacteristic ?? null,
|
||||||
|
cableType: input.cableType ?? null,
|
||||||
|
cableCrossSection: input.cableCrossSection ?? null,
|
||||||
|
cableLength: input.cableLength ?? null,
|
||||||
|
rcdAssignment: input.rcdAssignment ?? null,
|
||||||
|
terminalDesignation: input.terminalDesignation ?? null,
|
||||||
|
voltage: input.voltage ?? null,
|
||||||
|
controlRequirement: input.controlRequirement ?? null,
|
||||||
|
status: input.status ?? null,
|
||||||
|
isReserve: input.isReserve ? 1 : 0,
|
||||||
|
remark: input.remark ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,28 +1,14 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { and, asc, eq, inArray, ne } from "drizzle-orm";
|
import { and, asc, eq, ne } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import {
|
||||||
|
toCircuitCreateValues,
|
||||||
|
type CircuitCreatePersistenceInput,
|
||||||
|
} from "./circuit.persistence.js";
|
||||||
|
|
||||||
export interface CircuitCreatePersistenceInput {
|
export type { CircuitCreatePersistenceInput } from "./circuit.persistence.js";
|
||||||
circuitListId: string;
|
export { toCircuitCreateValues } from "./circuit.persistence.js";
|
||||||
sectionId: string;
|
|
||||||
equipmentIdentifier: string;
|
|
||||||
displayName?: string;
|
|
||||||
sortOrder: number;
|
|
||||||
protectionType?: string;
|
|
||||||
protectionRatedCurrent?: number;
|
|
||||||
protectionCharacteristic?: string;
|
|
||||||
cableType?: string;
|
|
||||||
cableCrossSection?: string;
|
|
||||||
cableLength?: number;
|
|
||||||
voltage?: number;
|
|
||||||
controlRequirement?: string;
|
|
||||||
remark?: string;
|
|
||||||
rcdAssignment?: string;
|
|
||||||
terminalDesignation?: string;
|
|
||||||
status?: string;
|
|
||||||
isReserve?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CircuitPatchPersistenceInput {
|
export interface CircuitPatchPersistenceInput {
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
@@ -44,30 +30,6 @@ export interface CircuitPatchPersistenceInput {
|
|||||||
isReserve?: boolean;
|
isReserve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toCircuitCreateValues(id: string, input: CircuitCreatePersistenceInput) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
circuitListId: input.circuitListId,
|
|
||||||
sectionId: input.sectionId,
|
|
||||||
equipmentIdentifier: input.equipmentIdentifier,
|
|
||||||
displayName: input.displayName ?? null,
|
|
||||||
sortOrder: input.sortOrder,
|
|
||||||
protectionType: input.protectionType ?? null,
|
|
||||||
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
|
|
||||||
protectionCharacteristic: input.protectionCharacteristic ?? null,
|
|
||||||
cableType: input.cableType ?? null,
|
|
||||||
cableCrossSection: input.cableCrossSection ?? null,
|
|
||||||
cableLength: input.cableLength ?? null,
|
|
||||||
rcdAssignment: input.rcdAssignment ?? null,
|
|
||||||
terminalDesignation: input.terminalDesignation ?? null,
|
|
||||||
voltage: input.voltage ?? null,
|
|
||||||
controlRequirement: input.controlRequirement ?? null,
|
|
||||||
status: input.status ?? null,
|
|
||||||
isReserve: input.isReserve ? 1 : 0,
|
|
||||||
remark: input.remark ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
|
function toCircuitPatchValues(input: CircuitPatchPersistenceInput) {
|
||||||
const values: Partial<typeof circuits.$inferInsert> = {};
|
const values: Partial<typeof circuits.$inferInsert> = {};
|
||||||
const has = (field: keyof CircuitPatchPersistenceInput) =>
|
const has = (field: keyof CircuitPatchPersistenceInput) =>
|
||||||
@@ -207,89 +169,4 @@ export class CircuitRepository {
|
|||||||
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
.orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSortOrdersSafely(sectionId: string, orderedCircuitIds: string[]) {
|
|
||||||
if (orderedCircuitIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
|
|
||||||
throw new Error("Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen.");
|
|
||||||
}
|
|
||||||
|
|
||||||
db.transaction((tx) => {
|
|
||||||
const sectionCircuits = tx
|
|
||||||
.select({ id: circuits.id })
|
|
||||||
.from(circuits)
|
|
||||||
.where(eq(circuits.sectionId, sectionId))
|
|
||||||
.all();
|
|
||||||
const sectionCircuitIds = new Set(sectionCircuits.map((circuit) => circuit.id));
|
|
||||||
if (
|
|
||||||
sectionCircuits.length !== orderedCircuitIds.length ||
|
|
||||||
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
|
|
||||||
) {
|
|
||||||
throw new Error("Die Reihenfolge muss alle Stromkreise des Bereichs enthalten.");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
|
|
||||||
const result = tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ sortOrder: (index + 1) * 10 })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(circuits.sectionId, sectionId),
|
|
||||||
eq(circuits.id, orderedCircuitIds[index])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
if (result.changes !== 1) {
|
|
||||||
throw new Error("Ein Stromkreis wurde vor Abschluss der Sortierung verändert.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateEquipmentIdentifiersSafely(
|
|
||||||
circuitListId: string,
|
|
||||||
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
|
||||||
tempNamespace: string
|
|
||||||
) {
|
|
||||||
if (updates.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// better-sqlite3 transactions are synchronous callbacks. Do not make this callback
|
|
||||||
// async or return a Promise, otherwise statements may run outside the transaction scope.
|
|
||||||
db.transaction((tx) => {
|
|
||||||
const ids = updates.map((entry) => entry.id);
|
|
||||||
const existing = tx
|
|
||||||
.select({ id: circuits.id })
|
|
||||||
.from(circuits)
|
|
||||||
.where(and(eq(circuits.circuitListId, circuitListId), inArray(circuits.id, ids)))
|
|
||||||
.all();
|
|
||||||
if (existing.length !== ids.length) {
|
|
||||||
throw new Error("One or more circuit ids are invalid for circuit list.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct identifier swaps can violate UNIQUE(circuit_list_id, equipment_identifier)
|
|
||||||
// mid-update (for example A->B while B->A). Two-phase strategy prevents that:
|
|
||||||
// 1) assign unique temporary identifiers for all affected circuits
|
|
||||||
// 2) assign final user-visible identifiers
|
|
||||||
const stamp = Date.now();
|
|
||||||
for (let index = 0; index < updates.length; index += 1) {
|
|
||||||
const entry = updates[index];
|
|
||||||
const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`;
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ equipmentIdentifier: tempIdentifier })
|
|
||||||
.where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id)))
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of updates) {
|
|
||||||
tx
|
|
||||||
.update(circuits)
|
|
||||||
.set({ equipmentIdentifier: entry.equipmentIdentifier })
|
|
||||||
.where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id)))
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { db } from "../client.js";
|
|
||||||
import { consumers } from "../schema/consumers.js";
|
|
||||||
import type {
|
|
||||||
CreateConsumerInput,
|
|
||||||
UpdateConsumerInput,
|
|
||||||
} from "../../shared/validation/consumer.schemas.js";
|
|
||||||
|
|
||||||
export class ConsumerRepository {
|
|
||||||
async listByProject(projectId: string) {
|
|
||||||
return db.select().from(consumers).where(eq(consumers.projectId, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async listByCircuitList(circuitListId: string) {
|
|
||||||
return db.select().from(consumers).where(eq(consumers.circuitListId, circuitListId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(input: CreateConsumerInput) {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
const normalizedName = input.name?.trim() || "Unbenannter Eintrag";
|
|
||||||
const normalizedQuantity = input.quantity ?? 0;
|
|
||||||
const normalizedInstalledPowerPerUnitKw = input.installedPowerPerUnitKw ?? 0;
|
|
||||||
const normalizedDemandFactor = input.demandFactor ?? 1;
|
|
||||||
await db.insert(consumers).values({
|
|
||||||
id,
|
|
||||||
projectId: input.projectId,
|
|
||||||
distributionBoardId: input.distributionBoardId ?? null,
|
|
||||||
circuitListId: input.circuitListId ?? null,
|
|
||||||
projectDeviceId: input.projectDeviceId ?? null,
|
|
||||||
isLinkedToDevice: input.isLinkedToDevice ? 1 : 0,
|
|
||||||
roomId: input.roomId ?? null,
|
|
||||||
circuitNumber: input.circuitNumber ?? null,
|
|
||||||
description: input.description ?? null,
|
|
||||||
name: normalizedName,
|
|
||||||
category: input.category ?? null,
|
|
||||||
deviceType: input.deviceType ?? null,
|
|
||||||
phaseType: input.phaseType ?? null,
|
|
||||||
tradeOrCostGroup: input.tradeOrCostGroup ?? null,
|
|
||||||
group: input.group ?? null,
|
|
||||||
protectionType: input.protectionType ?? null,
|
|
||||||
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
|
|
||||||
protectionCharacteristic: input.protectionCharacteristic ?? null,
|
|
||||||
cableType: input.cableType ?? null,
|
|
||||||
cableCrossSection: input.cableCrossSection ?? null,
|
|
||||||
comment: input.comment ?? null,
|
|
||||||
quantity: normalizedQuantity,
|
|
||||||
installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw,
|
|
||||||
demandFactor: normalizedDemandFactor,
|
|
||||||
voltageV: input.voltageV ?? null,
|
|
||||||
phaseCount: input.phaseCount ?? null,
|
|
||||||
powerFactor: input.powerFactor ?? null,
|
|
||||||
note: input.note ?? null,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
...input,
|
|
||||||
name: normalizedName,
|
|
||||||
quantity: normalizedQuantity,
|
|
||||||
installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw,
|
|
||||||
demandFactor: normalizedDemandFactor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(consumerId: string, input: UpdateConsumerInput) {
|
|
||||||
const normalizedName = input.name?.trim() || "Unbenannter Eintrag";
|
|
||||||
const normalizedQuantity = input.quantity ?? 0;
|
|
||||||
const normalizedInstalledPowerPerUnitKw = input.installedPowerPerUnitKw ?? 0;
|
|
||||||
const normalizedDemandFactor = input.demandFactor ?? 1;
|
|
||||||
await db
|
|
||||||
.update(consumers)
|
|
||||||
.set({
|
|
||||||
projectId: input.projectId,
|
|
||||||
distributionBoardId: input.distributionBoardId ?? null,
|
|
||||||
circuitListId: input.circuitListId ?? null,
|
|
||||||
projectDeviceId: input.projectDeviceId ?? null,
|
|
||||||
isLinkedToDevice: input.isLinkedToDevice ? 1 : 0,
|
|
||||||
roomId: input.roomId ?? null,
|
|
||||||
circuitNumber: input.circuitNumber ?? null,
|
|
||||||
description: input.description ?? null,
|
|
||||||
name: normalizedName,
|
|
||||||
category: input.category ?? null,
|
|
||||||
deviceType: input.deviceType ?? null,
|
|
||||||
phaseType: input.phaseType ?? null,
|
|
||||||
tradeOrCostGroup: input.tradeOrCostGroup ?? null,
|
|
||||||
group: input.group ?? null,
|
|
||||||
protectionType: input.protectionType ?? null,
|
|
||||||
protectionRatedCurrent: input.protectionRatedCurrent ?? null,
|
|
||||||
protectionCharacteristic: input.protectionCharacteristic ?? null,
|
|
||||||
cableType: input.cableType ?? null,
|
|
||||||
cableCrossSection: input.cableCrossSection ?? null,
|
|
||||||
comment: input.comment ?? null,
|
|
||||||
quantity: normalizedQuantity,
|
|
||||||
installedPowerPerUnitKw: normalizedInstalledPowerPerUnitKw,
|
|
||||||
demandFactor: normalizedDemandFactor,
|
|
||||||
voltageV: input.voltageV ?? null,
|
|
||||||
phaseCount: input.phaseCount ?? null,
|
|
||||||
powerFactor: input.powerFactor ?? null,
|
|
||||||
note: input.note ?? null,
|
|
||||||
})
|
|
||||||
.where(eq(consumers.id, consumerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(consumerId: string) {
|
|
||||||
const [row] = await db.select().from(consumers).where(eq(consumers.id, consumerId)).limit(1);
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(consumerId: string) {
|
|
||||||
await db.delete(consumers).where(eq(consumers.id, consumerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncLinkedConsumersFromProjectDevice(
|
|
||||||
projectId: string,
|
|
||||||
projectDeviceId: string,
|
|
||||||
data: {
|
|
||||||
displayName: string;
|
|
||||||
category?: string;
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
phaseCount?: 1 | 3;
|
|
||||||
powerFactor?: number;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
await db
|
|
||||||
.update(consumers)
|
|
||||||
.set({
|
|
||||||
name: data.displayName,
|
|
||||||
category: data.category ?? null,
|
|
||||||
quantity: data.quantity,
|
|
||||||
installedPowerPerUnitKw: data.installedPowerPerUnitKw,
|
|
||||||
demandFactor: data.demandFactor,
|
|
||||||
phaseCount: data.phaseCount ?? null,
|
|
||||||
powerFactor: data.powerFactor ?? null,
|
|
||||||
note: data.note ?? null,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(consumers.projectId, projectId),
|
|
||||||
eq(consumers.projectDeviceId, projectDeviceId),
|
|
||||||
eq(consumers.isLinkedToDevice, 1)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray, isNull } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { consumers } from "../schema/consumers.js";
|
||||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||||
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||||
import {
|
import {
|
||||||
toCircuitDeviceRowCreateValues,
|
toCircuitDeviceRowCreateValues,
|
||||||
type CircuitDeviceRowCreateInput,
|
type CircuitDeviceRowCreateInput,
|
||||||
} from "./circuit-device-row.repository.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
import {
|
import {
|
||||||
toCircuitCreateValues,
|
toCircuitCreateValues,
|
||||||
type CircuitCreatePersistenceInput,
|
type CircuitCreatePersistenceInput,
|
||||||
} from "./circuit.repository.js";
|
} from "./circuit.persistence.js";
|
||||||
|
|
||||||
export interface LegacyMigrationCircuitPersistenceInput {
|
export interface LegacyMigrationCircuitPersistenceInput {
|
||||||
circuit: CircuitCreatePersistenceInput;
|
circuit: CircuitCreatePersistenceInput;
|
||||||
@@ -34,8 +35,32 @@ export interface LegacyMigrationReportPersistenceInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LegacyConsumerMigrationRepository {
|
export class LegacyConsumerMigrationRepository {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
async listSourceConsumersByCircuitList(circuitListId: string) {
|
||||||
|
return this.database
|
||||||
|
.select()
|
||||||
|
.from(consumers)
|
||||||
|
.where(eq(consumers.circuitListId, circuitListId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUnmigratedConsumers() {
|
||||||
|
return this.database
|
||||||
|
.select({
|
||||||
|
id: consumers.id,
|
||||||
|
projectId: consumers.projectId,
|
||||||
|
circuitListId: consumers.circuitListId,
|
||||||
|
})
|
||||||
|
.from(consumers)
|
||||||
|
.leftJoin(
|
||||||
|
legacyConsumerCircuitMigrations,
|
||||||
|
eq(legacyConsumerCircuitMigrations.consumerId, consumers.id)
|
||||||
|
)
|
||||||
|
.where(isNull(legacyConsumerCircuitMigrations.consumerId));
|
||||||
|
}
|
||||||
|
|
||||||
async listMigratedConsumerIds(circuitListId: string) {
|
async listMigratedConsumerIds(circuitListId: string) {
|
||||||
const rows = await db
|
const rows = await this.database
|
||||||
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
||||||
.from(legacyConsumerCircuitMigrations)
|
.from(legacyConsumerCircuitMigrations)
|
||||||
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
|
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
|
||||||
@@ -78,7 +103,7 @@ export class LegacyConsumerMigrationRepository {
|
|||||||
});
|
});
|
||||||
const createdAtIso = new Date().toISOString();
|
const createdAtIso = new Date().toISOString();
|
||||||
|
|
||||||
db.transaction((tx) => {
|
this.database.transaction((tx) => {
|
||||||
if (consumerIds.length > 0) {
|
if (consumerIds.length > 0) {
|
||||||
const existingMappings = tx
|
const existingMappings = tx
|
||||||
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
ProjectDeviceLinkedRowValues,
|
||||||
|
ProjectDeviceRowSyncStore,
|
||||||
|
} from "../../domain/ports/project-device-row-sync.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { toCircuitDeviceRowUpdateValues } from "./circuit-device-row.persistence.js";
|
||||||
|
|
||||||
|
export class ProjectDeviceRowSyncRepository implements ProjectDeviceRowSyncStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
updateLinkedRows(
|
||||||
|
projectDeviceId: string,
|
||||||
|
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
|
||||||
|
) {
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
for (const change of changes) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set(toCircuitDeviceRowUpdateValues(change.input))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, change.rowId),
|
||||||
|
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"A linked row changed before the operation could be completed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]) {
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ linkedProjectDeviceId: null })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, rowId),
|
||||||
|
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"A linked row changed before the operation could be completed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectRows(projectDeviceId: string, rowIds: string[]) {
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const result = tx
|
||||||
|
.update(circuitDeviceRows)
|
||||||
|
.set({ linkedProjectDeviceId: projectDeviceId })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitDeviceRows.id, rowId),
|
||||||
|
isNull(circuitDeviceRows.linkedProjectDeviceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"A disconnected row changed before the operation could be undone."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { projects } from "../schema/projects.js";
|
|||||||
import type {
|
import type {
|
||||||
CreateProjectInput,
|
CreateProjectInput,
|
||||||
UpdateProjectSettingsInput,
|
UpdateProjectSettingsInput,
|
||||||
} from "../../shared/validation/consumer.schemas.js";
|
} from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
export class ProjectRepository {
|
export class ProjectRepository {
|
||||||
async list() {
|
async list() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import type { CreateRoomInput } from "../../shared/validation/consumer.schemas.js";
|
import type { CreateRoomInput } from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
export class RoomRepository {
|
export class RoomRepository {
|
||||||
async listByProject(projectId: string) {
|
async listByProject(projectId: string) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const projectDevices = sqliteTable("project_devices", {
|
|||||||
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
||||||
cosPhi: real("cos_phi"),
|
cosPhi: real("cos_phi"),
|
||||||
remark: text("remark"),
|
remark: text("remark"),
|
||||||
// Legacy compatibility fields. Keep these synchronized until the Consumer editor is removed.
|
// Transitional mirror columns retained until a dedicated schema cleanup migration removes them.
|
||||||
installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(),
|
installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(),
|
||||||
demandFactor: real("demand_factor").notNull(),
|
demandFactor: real("demand_factor").notNull(),
|
||||||
voltageV: real("voltage_v"),
|
voltageV: real("voltage_v"),
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
export interface PowerInput {
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentInput {
|
|
||||||
demandPowerKw: number;
|
|
||||||
voltageV: number;
|
|
||||||
phaseCount: 1 | 3;
|
|
||||||
powerFactor: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateInstalledPowerKw(input: PowerInput): number {
|
|
||||||
return input.quantity * input.installedPowerPerUnitKw;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateDemandPowerKw(input: PowerInput): number {
|
|
||||||
return calculateInstalledPowerKw(input) * input.demandFactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateCurrentA(input: CurrentInput): number {
|
|
||||||
const powerW = input.demandPowerKw * 1000;
|
|
||||||
if (input.phaseCount === 1) {
|
|
||||||
return powerW / (input.voltageV * input.powerFactor);
|
|
||||||
}
|
|
||||||
return powerW / (Math.sqrt(3) * input.voltageV * input.powerFactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
export interface Consumer {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
distributionBoardId?: string;
|
|
||||||
circuitListId?: string;
|
|
||||||
projectDeviceId?: string;
|
|
||||||
isLinkedToDevice?: boolean;
|
|
||||||
roomId?: string;
|
|
||||||
roomNumber?: string;
|
|
||||||
roomName?: string;
|
|
||||||
floorId?: string;
|
|
||||||
floorName?: string;
|
|
||||||
circuitNumber?: string;
|
|
||||||
description?: string;
|
|
||||||
name: string;
|
|
||||||
category?: string;
|
|
||||||
deviceType?: string;
|
|
||||||
phaseType?: string;
|
|
||||||
tradeOrCostGroup?: string;
|
|
||||||
group?: string;
|
|
||||||
protectionType?: string;
|
|
||||||
protectionRatedCurrent?: number;
|
|
||||||
protectionCharacteristic?: string;
|
|
||||||
cableType?: string;
|
|
||||||
cableCrossSection?: string;
|
|
||||||
comment?: string;
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
voltageV?: number;
|
|
||||||
phaseCount?: 1 | 3;
|
|
||||||
powerFactor?: number;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,56 @@ export interface CreateCircuitDeviceRowTransactionInput {
|
|||||||
overriddenFields?: string;
|
overriddenFields?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCircuitTransactionInput {
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
displayName?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
protectionType?: string;
|
||||||
|
protectionRatedCurrent?: number;
|
||||||
|
protectionCharacteristic?: string;
|
||||||
|
cableType?: string;
|
||||||
|
cableCrossSection?: string;
|
||||||
|
cableLength?: number;
|
||||||
|
voltage?: number;
|
||||||
|
controlRequirement?: string;
|
||||||
|
remark?: string;
|
||||||
|
rcdAssignment?: string;
|
||||||
|
terminalDesignation?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCircuitWithDeviceRowsTransactionInput {
|
||||||
|
circuit: CreateCircuitTransactionInput;
|
||||||
|
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveCircuitDeviceRowsTransactionInput {
|
||||||
|
rows: Array<{ id: string; expectedCircuitId: string }>;
|
||||||
|
targetCircuitId?: string;
|
||||||
|
createTargetCircuit?: {
|
||||||
|
circuitListId: string;
|
||||||
|
sectionId: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
displayName: string;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveCircuitDeviceRowsTransactionResult {
|
||||||
|
movedRowIds: string[];
|
||||||
|
targetCircuitId: string;
|
||||||
|
createdCircuitId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CircuitDeviceRowTransactionStore {
|
export interface CircuitDeviceRowTransactionStore {
|
||||||
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
|
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
|
||||||
|
createCircuitWithDeviceRows(
|
||||||
|
input: CreateCircuitWithDeviceRowsTransactionInput
|
||||||
|
): { circuitId: string; rowIds: string[] };
|
||||||
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
|
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
|
||||||
|
moveRows(
|
||||||
|
input: MoveCircuitDeviceRowsTransactionInput
|
||||||
|
): MoveCircuitDeviceRowsTransactionResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface CircuitSectionTransactionStore {
|
||||||
|
updateEquipmentIdentifiers(
|
||||||
|
circuitListId: string,
|
||||||
|
updates: Array<{ id: string; equipmentIdentifier: string }>,
|
||||||
|
tempNamespace: string
|
||||||
|
): void;
|
||||||
|
updateSortOrders(sectionId: string, orderedCircuitIds: string[]): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface ProjectDeviceLinkedRowValues {
|
||||||
|
linkedProjectDeviceId?: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
phaseType?: string;
|
||||||
|
connectionKind?: string;
|
||||||
|
costGroup?: string;
|
||||||
|
category?: string;
|
||||||
|
level?: string;
|
||||||
|
roomId?: string;
|
||||||
|
roomNumberSnapshot?: string;
|
||||||
|
roomNameSnapshot?: string;
|
||||||
|
quantity: number;
|
||||||
|
powerPerUnit: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi?: number;
|
||||||
|
remark?: string;
|
||||||
|
overriddenFields?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDeviceRowSyncStore {
|
||||||
|
updateLinkedRows(
|
||||||
|
projectDeviceId: string,
|
||||||
|
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
|
||||||
|
): void;
|
||||||
|
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]): void;
|
||||||
|
reconnectRows(projectDeviceId: string, rowIds: string[]): void;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
|
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
|
||||||
|
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
@@ -28,6 +29,7 @@ export class CircuitWriteService {
|
|||||||
private readonly circuitListRepository: CircuitListRepository;
|
private readonly circuitListRepository: CircuitListRepository;
|
||||||
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
private readonly deviceRowRepository: CircuitDeviceRowRepository;
|
||||||
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
|
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||||
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
private readonly projectDeviceRepository: ProjectDeviceRepository;
|
||||||
private readonly numberingService: CircuitNumberingService;
|
private readonly numberingService: CircuitNumberingService;
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export class CircuitWriteService {
|
|||||||
circuitListRepository?: CircuitListRepository;
|
circuitListRepository?: CircuitListRepository;
|
||||||
deviceRowRepository?: CircuitDeviceRowRepository;
|
deviceRowRepository?: CircuitDeviceRowRepository;
|
||||||
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
|
||||||
|
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
|
||||||
projectDeviceRepository?: ProjectDeviceRepository;
|
projectDeviceRepository?: ProjectDeviceRepository;
|
||||||
numberingService?: CircuitNumberingService;
|
numberingService?: CircuitNumberingService;
|
||||||
}) {
|
}) {
|
||||||
@@ -45,6 +48,7 @@ export class CircuitWriteService {
|
|||||||
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository();
|
||||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||||
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
|
||||||
|
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
|
||||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||||
}
|
}
|
||||||
@@ -98,6 +102,13 @@ export class CircuitWriteService {
|
|||||||
return this.deviceRowTransactionStore;
|
return this.deviceRowTransactionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCircuitSectionTransactionStore() {
|
||||||
|
if (!this.circuitSectionTransactionStore) {
|
||||||
|
throw new Error("Circuit-section transactions are not configured.");
|
||||||
|
}
|
||||||
|
return this.circuitSectionTransactionStore;
|
||||||
|
}
|
||||||
|
|
||||||
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
||||||
if (!linkedProjectDeviceId) {
|
if (!linkedProjectDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -164,11 +175,10 @@ export class CircuitWriteService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = this.deviceRowRepository.createCircuitWithDeviceRowsTransactional({
|
const created = this.getDeviceRowTransactionStore().createCircuitWithDeviceRows({
|
||||||
circuit: {
|
circuit: {
|
||||||
circuitListId,
|
circuitListId,
|
||||||
...input.circuit,
|
...input.circuit,
|
||||||
isReserve: false,
|
|
||||||
},
|
},
|
||||||
deviceRows: input.deviceRows,
|
deviceRows: input.deviceRows,
|
||||||
});
|
});
|
||||||
@@ -335,7 +345,7 @@ export class CircuitWriteService {
|
|||||||
return this.deviceRowRepository.findById(rowId);
|
return this.deviceRowRepository.findById(rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.deviceRowRepository.moveRowsTransactional({
|
this.getDeviceRowTransactionStore().moveRows({
|
||||||
rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
|
rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
|
||||||
targetCircuitId: targetCircuit?.id,
|
targetCircuitId: targetCircuit?.id,
|
||||||
createTargetCircuit,
|
createTargetCircuit,
|
||||||
@@ -416,7 +426,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.deviceRowRepository.moveRowsTransactional({
|
return this.getDeviceRowTransactionStore().moveRows({
|
||||||
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
|
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
|
||||||
targetCircuitId: targetCircuit?.id,
|
targetCircuitId: targetCircuit?.id,
|
||||||
createTargetCircuit,
|
createTargetCircuit,
|
||||||
@@ -452,7 +462,7 @@ export class CircuitWriteService {
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
|
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
|
||||||
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
|
||||||
section.circuitListId,
|
section.circuitListId,
|
||||||
finalAssignments,
|
finalAssignments,
|
||||||
sectionId
|
sectionId
|
||||||
@@ -480,7 +490,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
this.getCircuitSectionTransactionStore().updateEquipmentIdentifiers(
|
||||||
section.circuitListId,
|
section.circuitListId,
|
||||||
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
|
input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })),
|
||||||
sectionId
|
sectionId
|
||||||
@@ -505,7 +515,10 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.circuitRepository.updateSortOrdersSafely(sectionId, input.orderedCircuitIds);
|
this.getCircuitSectionTransactionStore().updateSortOrders(
|
||||||
|
sectionId,
|
||||||
|
input.orderedCircuitIds
|
||||||
|
);
|
||||||
return this.circuitRepository.listBySection(sectionId);
|
return this.circuitRepository.listBySection(sectionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { CreateConsumerInput } from "../../shared/validation/consumer.schemas.js";
|
|
||||||
|
|
||||||
type ProjectDeviceForLinking = {
|
|
||||||
displayName: string;
|
|
||||||
category: string | null;
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
phaseCount: number | null;
|
|
||||||
powerFactor: number | null;
|
|
||||||
note: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function applyLinkedProjectDeviceValues(
|
|
||||||
input: CreateConsumerInput,
|
|
||||||
projectDevice: ProjectDeviceForLinking | null
|
|
||||||
): CreateConsumerInput {
|
|
||||||
if (!input.projectDeviceId || !input.isLinkedToDevice || !projectDevice) {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
name: projectDevice.displayName,
|
|
||||||
category: projectDevice.category ?? undefined,
|
|
||||||
quantity: projectDevice.quantity,
|
|
||||||
installedPowerPerUnitKw: projectDevice.installedPowerPerUnitKw,
|
|
||||||
demandFactor: projectDevice.demandFactor,
|
|
||||||
phaseCount:
|
|
||||||
projectDevice.phaseCount === 1 || projectDevice.phaseCount === 3
|
|
||||||
? (projectDevice.phaseCount as 1 | 3)
|
|
||||||
: undefined,
|
|
||||||
powerFactor: projectDevice.powerFactor ?? undefined,
|
|
||||||
note: projectDevice.note ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
|
|
||||||
import {
|
import {
|
||||||
LegacyConsumerMigrationRepository,
|
LegacyConsumerMigrationRepository,
|
||||||
type LegacyMigrationCircuitPersistenceInput,
|
type LegacyMigrationCircuitPersistenceInput,
|
||||||
@@ -14,7 +13,11 @@ import {
|
|||||||
normalizeCircuitNumber,
|
normalizeCircuitNumber,
|
||||||
} from "./legacy-consumer-migration-planner.js";
|
} from "./legacy-consumer-migration-planner.js";
|
||||||
|
|
||||||
type LegacyConsumerRow = Awaited<ReturnType<ConsumerRepository["listByCircuitList"]>>[number];
|
type LegacyConsumerRow = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
LegacyConsumerMigrationRepository["listSourceConsumersByCircuitList"]
|
||||||
|
>
|
||||||
|
>[number];
|
||||||
|
|
||||||
function parseEquipmentSequence(equipmentIdentifier: string, prefix: string): number | null {
|
function parseEquipmentSequence(equipmentIdentifier: string, prefix: string): number | null {
|
||||||
if (!equipmentIdentifier.startsWith(prefix)) {
|
if (!equipmentIdentifier.startsWith(prefix)) {
|
||||||
@@ -32,9 +35,11 @@ export class LegacyConsumerMigrationService {
|
|||||||
private readonly circuitListRepository = new CircuitListRepository();
|
private readonly circuitListRepository = new CircuitListRepository();
|
||||||
private readonly sectionRepository = new CircuitSectionRepository();
|
private readonly sectionRepository = new CircuitSectionRepository();
|
||||||
private readonly circuitRepository = new CircuitRepository();
|
private readonly circuitRepository = new CircuitRepository();
|
||||||
private readonly consumerRepository = new ConsumerRepository();
|
|
||||||
private readonly roomRepository = new RoomRepository();
|
private readonly roomRepository = new RoomRepository();
|
||||||
private readonly migrationRepository = new LegacyConsumerMigrationRepository();
|
|
||||||
|
constructor(
|
||||||
|
private readonly migrationRepository: LegacyConsumerMigrationRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
||||||
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created
|
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created
|
||||||
@@ -57,7 +62,10 @@ export class LegacyConsumerMigrationService {
|
|||||||
existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase())
|
existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const legacyConsumers = await this.consumerRepository.listByCircuitList(circuitListId);
|
const legacyConsumers =
|
||||||
|
await this.migrationRepository.listSourceConsumersByCircuitList(
|
||||||
|
circuitListId
|
||||||
|
);
|
||||||
const rooms = await this.roomRepository.listByProject(projectId);
|
const rooms = await this.roomRepository.listByProject(projectId);
|
||||||
const roomById = new Map(rooms.map((room) => [room.id, room]));
|
const roomById = new Map(rooms.map((room) => [room.id, room]));
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import {
|
|
||||||
calculateCurrentA,
|
|
||||||
calculateDemandPowerKw,
|
|
||||||
calculateInstalledPowerKw,
|
|
||||||
} from "../calculations/power-calculation.js";
|
|
||||||
import type { Consumer } from "../models/consumer.model.js";
|
|
||||||
|
|
||||||
export interface ConsumerWithCalculatedValues extends Consumer {
|
|
||||||
installedPowerKw: number;
|
|
||||||
demandPowerKw: number;
|
|
||||||
effectiveVoltageV?: number;
|
|
||||||
currentA?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectVoltageDefaults {
|
|
||||||
singlePhaseVoltageV: number;
|
|
||||||
threePhaseVoltageV: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PowerBalanceService {
|
|
||||||
enrichConsumer(
|
|
||||||
consumer: Consumer,
|
|
||||||
projectVoltageDefaults?: ProjectVoltageDefaults
|
|
||||||
): ConsumerWithCalculatedValues {
|
|
||||||
const installedPowerKw = calculateInstalledPowerKw({
|
|
||||||
quantity: consumer.quantity,
|
|
||||||
installedPowerPerUnitKw: consumer.installedPowerPerUnitKw,
|
|
||||||
demandFactor: consumer.demandFactor,
|
|
||||||
});
|
|
||||||
const demandPowerKw = calculateDemandPowerKw({
|
|
||||||
quantity: consumer.quantity,
|
|
||||||
installedPowerPerUnitKw: consumer.installedPowerPerUnitKw,
|
|
||||||
demandFactor: consumer.demandFactor,
|
|
||||||
});
|
|
||||||
|
|
||||||
const effectiveVoltageV =
|
|
||||||
consumer.voltageV ??
|
|
||||||
(consumer.phaseCount === 1
|
|
||||||
? projectVoltageDefaults?.singlePhaseVoltageV
|
|
||||||
: consumer.phaseCount === 3
|
|
||||||
? projectVoltageDefaults?.threePhaseVoltageV
|
|
||||||
: undefined);
|
|
||||||
|
|
||||||
let currentA: number | undefined;
|
|
||||||
if (effectiveVoltageV && consumer.phaseCount && consumer.powerFactor) {
|
|
||||||
currentA = calculateCurrentA({
|
|
||||||
demandPowerKw,
|
|
||||||
voltageV: effectiveVoltageV,
|
|
||||||
phaseCount: consumer.phaseCount,
|
|
||||||
powerFactor: consumer.powerFactor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...consumer,
|
|
||||||
installedPowerKw,
|
|
||||||
demandPowerKw,
|
|
||||||
effectiveVoltageV,
|
|
||||||
currentA,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
|
import type { ProjectDeviceRowSyncStore } from "../ports/project-device-row-sync.store.js";
|
||||||
import {
|
import {
|
||||||
projectDeviceSyncFields,
|
projectDeviceSyncFields,
|
||||||
type ProjectDeviceSyncField,
|
type ProjectDeviceSyncField,
|
||||||
@@ -14,10 +15,8 @@ type SyncDependencies = {
|
|||||||
CircuitDeviceRowRepository,
|
CircuitDeviceRowRepository,
|
||||||
| "listLinkedByProjectDevice"
|
| "listLinkedByProjectDevice"
|
||||||
| "listLinkStatesByIds"
|
| "listLinkStatesByIds"
|
||||||
| "updateLinkedRowsTransactional"
|
|
||||||
| "disconnectLinkedRowsTransactional"
|
|
||||||
| "reconnectRowsTransactional"
|
|
||||||
>;
|
>;
|
||||||
|
deviceRowSyncStore: ProjectDeviceRowSyncStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ProjectDeviceSyncRestoreRow {
|
export interface ProjectDeviceSyncRestoreRow {
|
||||||
@@ -71,10 +70,19 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
|
|||||||
export class ProjectDeviceSyncService {
|
export class ProjectDeviceSyncService {
|
||||||
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
||||||
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
||||||
|
private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"];
|
||||||
|
|
||||||
constructor(deps?: Partial<SyncDependencies>) {
|
constructor(deps?: Partial<SyncDependencies>) {
|
||||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||||
|
this.deviceRowSyncStore = deps?.deviceRowSyncStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceRowSyncStore() {
|
||||||
|
if (!this.deviceRowSyncStore) {
|
||||||
|
throw new Error("Project-device row synchronization is not configured.");
|
||||||
|
}
|
||||||
|
return this.deviceRowSyncStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreview(projectId: string, projectDeviceId: string) {
|
async getPreview(projectId: string, projectDeviceId: string) {
|
||||||
@@ -137,7 +145,7 @@ export class ProjectDeviceSyncService {
|
|||||||
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
||||||
changes.push({ rowId: row.id, input: next });
|
changes.push({ rowId: row.id, input: next });
|
||||||
}
|
}
|
||||||
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
|
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preview: await this.getPreview(projectId, projectDeviceId),
|
preview: await this.getPreview(projectId, projectDeviceId),
|
||||||
@@ -150,7 +158,10 @@ export class ProjectDeviceSyncService {
|
|||||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||||
const disconnectedRowIds = selectedRows.map((row) => row.id);
|
const disconnectedRowIds = selectedRows.map((row) => row.id);
|
||||||
|
|
||||||
this.deviceRowRepository.disconnectLinkedRowsTransactional(projectDeviceId, disconnectedRowIds);
|
this.getDeviceRowSyncStore().disconnectLinkedRows(
|
||||||
|
projectDeviceId,
|
||||||
|
disconnectedRowIds
|
||||||
|
);
|
||||||
|
|
||||||
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
|
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
|
||||||
}
|
}
|
||||||
@@ -175,7 +186,7 @@ export class ProjectDeviceSyncService {
|
|||||||
return { rowId: row.id, input: next };
|
return { rowId: row.id, input: next };
|
||||||
});
|
});
|
||||||
|
|
||||||
this.deviceRowRepository.updateLinkedRowsTransactional(projectDeviceId, changes);
|
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes);
|
||||||
return this.getPreview(projectId, projectDeviceId);
|
return this.getPreview(projectId, projectDeviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +200,7 @@ export class ProjectDeviceSyncService {
|
|||||||
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
|
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) {
|
||||||
throw new Error("One or more rows cannot be reconnected.");
|
throw new Error("One or more rows cannot be reconnected.");
|
||||||
}
|
}
|
||||||
this.deviceRowRepository.reconnectRowsTransactional(projectDeviceId, uniqueRowIds);
|
this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds);
|
||||||
return this.getPreview(projectId, projectDeviceId);
|
return this.getPreview(projectId, projectDeviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Components Placeholder
|
|
||||||
|
|
||||||
Reusable UI components for project, distribution board, and consumer tables.
|
|
||||||
|
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Activity, FolderPlus, Pencil, Plus, RefreshCw, Save, Trash2, X, Zap } from "lucide-react";
|
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
createConsumer,
|
|
||||||
createDistributionBoard,
|
|
||||||
createProject,
|
|
||||||
deleteConsumer,
|
|
||||||
listConsumers,
|
|
||||||
listDistributionBoards,
|
|
||||||
listProjects,
|
|
||||||
updateConsumer,
|
|
||||||
} from "../utils/api";
|
|
||||||
import type {
|
|
||||||
ConsumerWithCalculatedValues,
|
|
||||||
CreateConsumerInput,
|
|
||||||
DistributionBoardDto,
|
|
||||||
ProjectDto,
|
|
||||||
UpdateConsumerInput,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
const initialConsumerForm = {
|
|
||||||
name: "",
|
|
||||||
category: "",
|
|
||||||
quantity: "1",
|
|
||||||
installedPowerPerUnitKw: "0.1",
|
|
||||||
demandFactor: "1",
|
|
||||||
voltageV: "230",
|
|
||||||
phaseCount: "1",
|
|
||||||
powerFactor: "1",
|
|
||||||
note: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EditConsumerForm {
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
distributionBoardId: string;
|
|
||||||
quantity: string;
|
|
||||||
installedPowerPerUnitKw: string;
|
|
||||||
demandFactor: string;
|
|
||||||
voltageV: string;
|
|
||||||
phaseCount: "1" | "3";
|
|
||||||
powerFactor: string;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConsumerForm = typeof initialConsumerForm;
|
|
||||||
|
|
||||||
function toOptionalNumber(value: string) {
|
|
||||||
if (value.trim() === "") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return Number(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number | undefined, digits = 2) {
|
|
||||||
if (value === undefined || Number.isNaN(value)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
maximumFractionDigits: digits,
|
|
||||||
minimumFractionDigits: digits,
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEditForm(consumer: ConsumerWithCalculatedValues): EditConsumerForm {
|
|
||||||
return {
|
|
||||||
name: consumer.name,
|
|
||||||
category: consumer.category ?? "",
|
|
||||||
distributionBoardId: consumer.distributionBoardId ?? "",
|
|
||||||
quantity: String(consumer.quantity),
|
|
||||||
installedPowerPerUnitKw: String(consumer.installedPowerPerUnitKw),
|
|
||||||
demandFactor: String(consumer.demandFactor),
|
|
||||||
voltageV: consumer.voltageV !== undefined ? String(consumer.voltageV) : "",
|
|
||||||
phaseCount: consumer.phaseCount === 3 ? "3" : "1",
|
|
||||||
powerFactor: consumer.powerFactor !== undefined ? String(consumer.powerFactor) : "",
|
|
||||||
note: consumer.note ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PowerBalanceWorkspace() {
|
|
||||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
|
||||||
const [distributionBoards, setDistributionBoards] = useState<DistributionBoardDto[]>([]);
|
|
||||||
const [selectedBoardId, setSelectedBoardId] = useState("");
|
|
||||||
const [consumers, setConsumers] = useState<ConsumerWithCalculatedValues[]>([]);
|
|
||||||
const [projectName, setProjectName] = useState("");
|
|
||||||
const [boardName, setBoardName] = useState("");
|
|
||||||
const [consumerForm, setConsumerForm] = useState<ConsumerForm>(initialConsumerForm);
|
|
||||||
const [editingConsumerId, setEditingConsumerId] = useState<string | null>(null);
|
|
||||||
const [editConsumerForm, setEditConsumerForm] = useState<EditConsumerForm | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const selectedProject = projects.find((project) => project.id === selectedProjectId);
|
|
||||||
const selectedBoard = distributionBoards.find((board) => board.id === selectedBoardId);
|
|
||||||
const boardNames = new Map(distributionBoards.map((board) => [board.id, board.name]));
|
|
||||||
const visibleConsumers = selectedBoardId
|
|
||||||
? consumers.filter((consumer) => consumer.distributionBoardId === selectedBoardId)
|
|
||||||
: consumers;
|
|
||||||
|
|
||||||
const totals = useMemo(
|
|
||||||
() =>
|
|
||||||
visibleConsumers.reduce(
|
|
||||||
(sum, consumer) => ({
|
|
||||||
installedPowerKw: sum.installedPowerKw + consumer.installedPowerKw,
|
|
||||||
demandPowerKw: sum.demandPowerKw + consumer.demandPowerKw,
|
|
||||||
}),
|
|
||||||
{ installedPowerKw: 0, demandPowerKw: 0 }
|
|
||||||
),
|
|
||||||
[visibleConsumers]
|
|
||||||
);
|
|
||||||
const totalsByBoard = useMemo(() => {
|
|
||||||
const bucket = new Map<string, { consumerCount: number; installedPowerKw: number; demandPowerKw: number }>();
|
|
||||||
for (const board of distributionBoards) {
|
|
||||||
bucket.set(board.id, { consumerCount: 0, installedPowerKw: 0, demandPowerKw: 0 });
|
|
||||||
}
|
|
||||||
bucket.set("__unassigned__", { consumerCount: 0, installedPowerKw: 0, demandPowerKw: 0 });
|
|
||||||
|
|
||||||
for (const consumer of consumers) {
|
|
||||||
const key = consumer.distributionBoardId ?? "__unassigned__";
|
|
||||||
const entry = bucket.get(key);
|
|
||||||
if (!entry) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
entry.consumerCount += 1;
|
|
||||||
entry.installedPowerKw += consumer.installedPowerKw;
|
|
||||||
entry.demandPowerKw += consumer.demandPowerKw;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
...distributionBoards.map((board) => ({
|
|
||||||
key: board.id,
|
|
||||||
boardName: board.name,
|
|
||||||
...bucket.get(board.id)!,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
key: "__unassigned__",
|
|
||||||
boardName: "Ohne Verteilung",
|
|
||||||
...bucket.get("__unassigned__")!,
|
|
||||||
},
|
|
||||||
].filter((item) => item.consumerCount > 0);
|
|
||||||
}, [consumers, distributionBoards]);
|
|
||||||
const projectTotals = useMemo(
|
|
||||||
() =>
|
|
||||||
consumers.reduce(
|
|
||||||
(sum, consumer) => ({
|
|
||||||
consumerCount: sum.consumerCount + 1,
|
|
||||||
installedPowerKw: sum.installedPowerKw + consumer.installedPowerKw,
|
|
||||||
demandPowerKw: sum.demandPowerKw + consumer.demandPowerKw,
|
|
||||||
}),
|
|
||||||
{ consumerCount: 0, installedPowerKw: 0, demandPowerKw: 0 }
|
|
||||||
),
|
|
||||||
[consumers]
|
|
||||||
);
|
|
||||||
|
|
||||||
async function refreshProjects() {
|
|
||||||
setError(null);
|
|
||||||
const loadedProjects = await listProjects();
|
|
||||||
setProjects(loadedProjects);
|
|
||||||
setSelectedProjectId((current) => current || loadedProjects[0]?.id || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshConsumers(projectId: string) {
|
|
||||||
if (!projectId) {
|
|
||||||
setConsumers([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
setConsumers(await listConsumers(projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshDistributionBoards(projectId: string) {
|
|
||||||
if (!projectId) {
|
|
||||||
setDistributionBoards([]);
|
|
||||||
setSelectedBoardId("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
const boards = await listDistributionBoards(projectId);
|
|
||||||
setDistributionBoards(boards);
|
|
||||||
setSelectedBoardId((current) => (boards.some((board) => board.id === current) ? current : boards[0]?.id || ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshProjects()
|
|
||||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Projekte konnten nicht geladen werden."))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setEditingConsumerId(null);
|
|
||||||
setEditConsumerForm(null);
|
|
||||||
refreshDistributionBoards(selectedProjectId).catch((err: unknown) =>
|
|
||||||
setError(err instanceof Error ? err.message : "Verteilungen konnten nicht geladen werden.")
|
|
||||||
);
|
|
||||||
refreshConsumers(selectedProjectId).catch((err: unknown) =>
|
|
||||||
setError(err instanceof Error ? err.message : "Verbraucher konnten nicht geladen werden.")
|
|
||||||
);
|
|
||||||
}, [selectedProjectId]);
|
|
||||||
|
|
||||||
async function handleCreateProject(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
const name = projectName.trim();
|
|
||||||
if (!name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const project = await createProject(name);
|
|
||||||
setProjects((current) => [...current, project]);
|
|
||||||
setSelectedProjectId(project.id);
|
|
||||||
setProjectName("");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Projekt konnte nicht angelegt werden.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateDistributionBoard(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
const name = boardName.trim();
|
|
||||||
if (!selectedProjectId || !name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const board = await createDistributionBoard(selectedProjectId, name);
|
|
||||||
setDistributionBoards((current) => [...current, board]);
|
|
||||||
setSelectedBoardId(board.id);
|
|
||||||
setBoardName("");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Verteilung konnte nicht angelegt werden.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateConsumer(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!selectedProjectId || !consumerForm.name.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input: CreateConsumerInput = {
|
|
||||||
projectId: selectedProjectId,
|
|
||||||
distributionBoardId: selectedBoardId || undefined,
|
|
||||||
name: consumerForm.name.trim(),
|
|
||||||
category: consumerForm.category.trim() || undefined,
|
|
||||||
quantity: Number(consumerForm.quantity),
|
|
||||||
installedPowerPerUnitKw: Number(consumerForm.installedPowerPerUnitKw),
|
|
||||||
demandFactor: Number(consumerForm.demandFactor),
|
|
||||||
voltageV: toOptionalNumber(consumerForm.voltageV),
|
|
||||||
phaseCount: consumerForm.phaseCount === "3" ? 3 : 1,
|
|
||||||
powerFactor: toOptionalNumber(consumerForm.powerFactor),
|
|
||||||
note: consumerForm.note.trim() || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const created = await createConsumer(input);
|
|
||||||
setConsumers((current) => [...current, created]);
|
|
||||||
setConsumerForm(initialConsumerForm);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht angelegt werden.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveConsumer(consumerId: string) {
|
|
||||||
if (!selectedProjectId || !editConsumerForm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input: UpdateConsumerInput = {
|
|
||||||
projectId: selectedProjectId,
|
|
||||||
distributionBoardId: editConsumerForm.distributionBoardId || undefined,
|
|
||||||
name: editConsumerForm.name.trim(),
|
|
||||||
category: editConsumerForm.category.trim() || undefined,
|
|
||||||
quantity: Number(editConsumerForm.quantity),
|
|
||||||
installedPowerPerUnitKw: Number(editConsumerForm.installedPowerPerUnitKw),
|
|
||||||
demandFactor: Number(editConsumerForm.demandFactor),
|
|
||||||
voltageV: toOptionalNumber(editConsumerForm.voltageV),
|
|
||||||
phaseCount: editConsumerForm.phaseCount === "3" ? 3 : 1,
|
|
||||||
powerFactor: toOptionalNumber(editConsumerForm.powerFactor),
|
|
||||||
note: editConsumerForm.note.trim() || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await updateConsumer(consumerId, input);
|
|
||||||
setConsumers((current) => current.map((item) => (item.id === consumerId ? updated : item)));
|
|
||||||
setEditingConsumerId(null);
|
|
||||||
setEditConsumerForm(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht gespeichert werden.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteConsumer(consumerId: string) {
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await deleteConsumer(consumerId);
|
|
||||||
setConsumers((current) => current.filter((item) => item.id !== consumerId));
|
|
||||||
if (editingConsumerId === consumerId) {
|
|
||||||
setEditingConsumerId(null);
|
|
||||||
setEditConsumerForm(null);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Verbraucher konnte nicht gelöscht werden.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditConsumer(consumer: ConsumerWithCalculatedValues) {
|
|
||||||
setEditingConsumerId(consumer.id);
|
|
||||||
setEditConsumerForm(createEditForm(consumer));
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditConsumer() {
|
|
||||||
setEditingConsumerId(null);
|
|
||||||
setEditConsumerForm(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateConsumerForm(field: keyof ConsumerForm, value: string) {
|
|
||||||
setConsumerForm((current) => ({ ...current, [field]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEditConsumerForm(field: keyof EditConsumerForm, value: string) {
|
|
||||||
setEditConsumerForm((current) => (current ? { ...current, [field]: value } : current));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="workspace">
|
|
||||||
<header className="topbar">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">TGA / ELT Planung</p>
|
|
||||||
<h1>Leistungsbilanz</h1>
|
|
||||||
</div>
|
|
||||||
<button className="iconButton" type="button" onClick={() => refreshConsumers(selectedProjectId)} title="Aktualisieren">
|
|
||||||
<RefreshCw size={18} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error ? <p className="alert">{error}</p> : null}
|
|
||||||
|
|
||||||
<section className="toolbarBand">
|
|
||||||
<form className="projectForm" onSubmit={handleCreateProject}>
|
|
||||||
<label>
|
|
||||||
Projekt
|
|
||||||
<select value={selectedProjectId} onChange={(event) => setSelectedProjectId(event.target.value)}>
|
|
||||||
<option value="">Kein Projekt</option>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<option key={project.id} value={project.id}>
|
|
||||||
{project.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Neues Projekt
|
|
||||||
<input value={projectName} onChange={(event) => setProjectName(event.target.value)} placeholder="z. B. BV Neubau" />
|
|
||||||
</label>
|
|
||||||
<button className="primaryButton" type="submit" disabled={isSaving}>
|
|
||||||
<FolderPlus size={17} />
|
|
||||||
Anlegen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form className="boardForm" onSubmit={handleCreateDistributionBoard}>
|
|
||||||
<label>
|
|
||||||
Verteilung
|
|
||||||
<select value={selectedBoardId} onChange={(event) => setSelectedBoardId(event.target.value)}>
|
|
||||||
<option value="">Alle Verteilungen</option>
|
|
||||||
{distributionBoards.map((board) => (
|
|
||||||
<option key={board.id} value={board.id}>
|
|
||||||
{board.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Neue Verteilung
|
|
||||||
<input value={boardName} onChange={(event) => setBoardName(event.target.value)} placeholder="z. B. UV-01" />
|
|
||||||
</label>
|
|
||||||
<button className="primaryButton" type="submit" disabled={!selectedProjectId || isSaving}>
|
|
||||||
<Plus size={17} />
|
|
||||||
Anlegen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="summaryStrip" aria-label="Summen">
|
|
||||||
<div>
|
|
||||||
<span>Verbraucher</span>
|
|
||||||
<strong>{visibleConsumers.length}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Installierte Leistung</span>
|
|
||||||
<strong>{formatNumber(totals.installedPowerKw)} kW</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Berechnete Leistung</span>
|
|
||||||
<strong>{formatNumber(totals.demandPowerKw)} kW</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="entryBand">
|
|
||||||
<form className="consumerForm" onSubmit={handleCreateConsumer}>
|
|
||||||
<label>
|
|
||||||
Verbraucher
|
|
||||||
<input value={consumerForm.name} onChange={(event) => updateConsumerForm("name", event.target.value)} placeholder="z. B. Steckdosen Büro" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Kategorie
|
|
||||||
<input value={consumerForm.category} onChange={(event) => updateConsumerForm("category", event.target.value)} placeholder="Allgemein" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Anzahl
|
|
||||||
<input min="0" type="number" value={consumerForm.quantity} onChange={(event) => updateConsumerForm("quantity", event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Leistung je Stück [kW]
|
|
||||||
<input min="0" step="0.01" type="number" value={consumerForm.installedPowerPerUnitKw} onChange={(event) => updateConsumerForm("installedPowerPerUnitKw", event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Gleichzeitigkeitsfaktor
|
|
||||||
<input min="0" max="1" step="0.01" type="number" value={consumerForm.demandFactor} onChange={(event) => updateConsumerForm("demandFactor", event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Spannung [V]
|
|
||||||
<input min="1" type="number" value={consumerForm.voltageV} onChange={(event) => updateConsumerForm("voltageV", event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Phasen
|
|
||||||
<select value={consumerForm.phaseCount} onChange={(event) => updateConsumerForm("phaseCount", event.target.value)}>
|
|
||||||
<option value="1">1-phasig</option>
|
|
||||||
<option value="3">3-phasig</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
cos phi
|
|
||||||
<input min="0" max="1" step="0.01" type="number" value={consumerForm.powerFactor} onChange={(event) => updateConsumerForm("powerFactor", event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="wideField">
|
|
||||||
Bemerkung
|
|
||||||
<input value={consumerForm.note} onChange={(event) => updateConsumerForm("note", event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<button className="primaryButton" type="submit" disabled={!selectedProjectId || isSaving}>
|
|
||||||
<Plus size={17} />
|
|
||||||
Hinzufügen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="tableBand">
|
|
||||||
<div className="tableHeader">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Aktuelles Projekt</p>
|
|
||||||
<h2>{selectedProject?.name || "Noch kein Projekt ausgewählt"}</h2>
|
|
||||||
<p className="subline">{selectedBoard ? `Verteilung: ${selectedBoard.name}` : "Alle Verteilungen"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="statusPill">
|
|
||||||
<Activity size={16} />
|
|
||||||
{isLoading ? "Lädt" : "Bereit"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="boardTotals">
|
|
||||||
<h3>Summen nach Verteilung</h3>
|
|
||||||
<div className="tableScroll">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Verteilung</th>
|
|
||||||
<th>Verbraucher</th>
|
|
||||||
<th>Installierte Leistung [kW]</th>
|
|
||||||
<th>Berechnete Leistung [kW]</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{totalsByBoard.map((item) => (
|
|
||||||
<tr key={item.key}>
|
|
||||||
<td>{item.boardName}</td>
|
|
||||||
<td>{item.consumerCount}</td>
|
|
||||||
<td>{formatNumber(item.installedPowerKw)}</td>
|
|
||||||
<td>{formatNumber(item.demandPowerKw)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!totalsByBoard.length ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="emptyState">
|
|
||||||
Noch keine Verbraucher für eine Summenbildung vorhanden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
<tr className="totalRow">
|
|
||||||
<td>Projekt gesamt</td>
|
|
||||||
<td>{projectTotals.consumerCount}</td>
|
|
||||||
<td>{formatNumber(projectTotals.installedPowerKw)}</td>
|
|
||||||
<td>{formatNumber(projectTotals.demandPowerKw)}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tableScroll">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Verbraucher</th>
|
|
||||||
<th>Verteilung</th>
|
|
||||||
<th>Kategorie</th>
|
|
||||||
<th>Anzahl</th>
|
|
||||||
<th>Leistung je Stück [kW]</th>
|
|
||||||
<th>Installierte Leistung [kW]</th>
|
|
||||||
<th>GZF</th>
|
|
||||||
<th>Berechnete Leistung [kW]</th>
|
|
||||||
<th>Strom [A]</th>
|
|
||||||
<th>Bemerkung</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{visibleConsumers.map((consumer) => {
|
|
||||||
const isEditing = editingConsumerId === consumer.id && editConsumerForm;
|
|
||||||
return (
|
|
||||||
<tr key={consumer.id}>
|
|
||||||
<td className="nameCell">
|
|
||||||
<Zap size={15} />
|
|
||||||
{isEditing ? (
|
|
||||||
<input value={editConsumerForm.name} onChange={(event) => updateEditConsumerForm("name", event.target.value)} />
|
|
||||||
) : (
|
|
||||||
consumer.name
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{isEditing ? (
|
|
||||||
<select
|
|
||||||
value={editConsumerForm.distributionBoardId}
|
|
||||||
onChange={(event) => updateEditConsumerForm("distributionBoardId", event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">-</option>
|
|
||||||
{distributionBoards.map((board) => (
|
|
||||||
<option key={board.id} value={board.id}>
|
|
||||||
{board.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : consumer.distributionBoardId ? (
|
|
||||||
boardNames.get(consumer.distributionBoardId) || "-"
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{isEditing ? (
|
|
||||||
<input value={editConsumerForm.category} onChange={(event) => updateEditConsumerForm("category", event.target.value)} />
|
|
||||||
) : (
|
|
||||||
consumer.category || "-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{isEditing ? (
|
|
||||||
<input min="0" type="number" value={editConsumerForm.quantity} onChange={(event) => updateEditConsumerForm("quantity", event.target.value)} />
|
|
||||||
) : (
|
|
||||||
consumer.quantity
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
type="number"
|
|
||||||
value={editConsumerForm.installedPowerPerUnitKw}
|
|
||||||
onChange={(event) => updateEditConsumerForm("installedPowerPerUnitKw", event.target.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
formatNumber(consumer.installedPowerPerUnitKw)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{formatNumber(consumer.installedPowerKw)}</td>
|
|
||||||
<td>
|
|
||||||
{isEditing ? (
|
|
||||||
<input min="0" max="1" step="0.01" type="number" value={editConsumerForm.demandFactor} onChange={(event) => updateEditConsumerForm("demandFactor", event.target.value)} />
|
|
||||||
) : (
|
|
||||||
formatNumber(consumer.demandFactor)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{formatNumber(consumer.demandPowerKw)}</td>
|
|
||||||
<td>
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="rowField">
|
|
||||||
<input min="1" type="number" value={editConsumerForm.voltageV} onChange={(event) => updateEditConsumerForm("voltageV", event.target.value)} />
|
|
||||||
<select value={editConsumerForm.phaseCount} onChange={(event) => updateEditConsumerForm("phaseCount", event.target.value)}>
|
|
||||||
<option value="1">1</option>
|
|
||||||
<option value="3">3</option>
|
|
||||||
</select>
|
|
||||||
<input min="0" max="1" step="0.01" type="number" value={editConsumerForm.powerFactor} onChange={(event) => updateEditConsumerForm("powerFactor", event.target.value)} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
formatNumber(consumer.currentA)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{isEditing ? (
|
|
||||||
<input value={editConsumerForm.note} onChange={(event) => updateEditConsumerForm("note", event.target.value)} />
|
|
||||||
) : (
|
|
||||||
consumer.note || "-"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="rowActions">
|
|
||||||
{isEditing ? (
|
|
||||||
<>
|
|
||||||
<button className="iconButton small" type="button" title="Speichern" onClick={() => handleSaveConsumer(consumer.id)} disabled={isSaving}>
|
|
||||||
<Save size={14} />
|
|
||||||
</button>
|
|
||||||
<button className="iconButton small muted" type="button" title="Abbrechen" onClick={cancelEditConsumer}>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button className="iconButton small" type="button" title="Bearbeiten" onClick={() => startEditConsumer(consumer)}>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="iconButton small danger" type="button" title="Löschen" onClick={() => handleDeleteConsumer(consumer.id)} disabled={isSaving}>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!visibleConsumers.length ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={11} className="emptyState">
|
|
||||||
Lege eine Verteilung an oder erfasse den ersten Verbraucher.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,45 +5,6 @@ export interface ProjectDto {
|
|||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsumerWithCalculatedValues {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
distributionBoardId?: string | null;
|
|
||||||
circuitListId?: string | null;
|
|
||||||
projectDeviceId?: string | null;
|
|
||||||
isLinkedToDevice?: boolean;
|
|
||||||
roomId?: string | null;
|
|
||||||
roomNumber?: string;
|
|
||||||
roomName?: string;
|
|
||||||
floorId?: string;
|
|
||||||
floorName?: string;
|
|
||||||
circuitNumber?: string;
|
|
||||||
description?: string;
|
|
||||||
name: string;
|
|
||||||
category?: string;
|
|
||||||
deviceType?: string;
|
|
||||||
phaseType?: string;
|
|
||||||
tradeOrCostGroup?: string;
|
|
||||||
group?: string;
|
|
||||||
protectionType?: string;
|
|
||||||
protectionRatedCurrent?: number;
|
|
||||||
protectionCharacteristic?: string;
|
|
||||||
cableType?: string;
|
|
||||||
cableCrossSection?: string;
|
|
||||||
comment?: string;
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
voltageV?: number;
|
|
||||||
phaseCount?: 1 | 3;
|
|
||||||
powerFactor?: number;
|
|
||||||
note?: string;
|
|
||||||
installedPowerKw: number;
|
|
||||||
demandPowerKw: number;
|
|
||||||
effectiveVoltageV?: number;
|
|
||||||
currentA?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DistributionBoardDto {
|
export interface DistributionBoardDto {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -101,47 +62,9 @@ export interface ProjectDeviceDto {
|
|||||||
totalPower: number;
|
totalPower: number;
|
||||||
cosPhi: number | null;
|
cosPhi: number | null;
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
// Legacy aliases retained until the old Consumer editor is removed.
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
voltageV: number | null;
|
voltageV: number | null;
|
||||||
phaseCount: 1 | 3 | null;
|
|
||||||
powerFactor: number | null;
|
|
||||||
note: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateConsumerInput {
|
|
||||||
projectId: string;
|
|
||||||
distributionBoardId?: string;
|
|
||||||
circuitListId?: string;
|
|
||||||
projectDeviceId?: string;
|
|
||||||
isLinkedToDevice?: boolean;
|
|
||||||
roomId?: string;
|
|
||||||
circuitNumber?: string;
|
|
||||||
description?: string;
|
|
||||||
name: string;
|
|
||||||
category?: string;
|
|
||||||
deviceType?: string;
|
|
||||||
phaseType?: string;
|
|
||||||
tradeOrCostGroup?: string;
|
|
||||||
group?: string;
|
|
||||||
protectionType?: string;
|
|
||||||
protectionRatedCurrent?: number;
|
|
||||||
protectionCharacteristic?: string;
|
|
||||||
cableType?: string;
|
|
||||||
cableCrossSection?: string;
|
|
||||||
comment?: string;
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
voltageV?: number;
|
|
||||||
phaseCount?: 1 | 3;
|
|
||||||
powerFactor?: number;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateConsumerInput extends CreateConsumerInput {}
|
|
||||||
|
|
||||||
export interface CreateFloorInput {
|
export interface CreateFloorInput {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import type {
|
|||||||
CreateFloorInput,
|
CreateFloorInput,
|
||||||
CreateProjectDeviceInput,
|
CreateProjectDeviceInput,
|
||||||
CreateRoomInput,
|
CreateRoomInput,
|
||||||
ConsumerWithCalculatedValues,
|
|
||||||
CreateConsumerInput,
|
|
||||||
CreateGlobalDeviceInput,
|
CreateGlobalDeviceInput,
|
||||||
DistributionBoardDto,
|
DistributionBoardDto,
|
||||||
FloorDto,
|
FloorDto,
|
||||||
@@ -16,7 +14,6 @@ import type {
|
|||||||
ProjectDeviceSyncResultDto,
|
ProjectDeviceSyncResultDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
UpdateConsumerInput,
|
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
CircuitTreeCircuitDto,
|
CircuitTreeCircuitDto,
|
||||||
CircuitTreeDeviceRowDto,
|
CircuitTreeDeviceRowDto,
|
||||||
@@ -229,28 +226,6 @@ export function createRoom(projectId: string, input: CreateRoomInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listConsumers(projectId: string) {
|
|
||||||
return request<ConsumerWithCalculatedValues[]>(`/api/consumers/projects/${projectId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createConsumer(input: CreateConsumerInput) {
|
|
||||||
return request<ConsumerWithCalculatedValues>("/api/consumers", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateConsumer(consumerId: string, input: UpdateConsumerInput) {
|
|
||||||
return request<ConsumerWithCalculatedValues>(`/api/consumers/${consumerId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteConsumer(consumerId: string) {
|
|
||||||
return request<void>(`/api/consumers/${consumerId}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listGlobalDevices() {
|
export function listGlobalDevices() {
|
||||||
return request<GlobalDeviceDto[]>("/api/global-devices");
|
return request<GlobalDeviceDto[]>("/api/global-devices");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
||||||
import { db } from "../../db/client.js";
|
import { db } from "../../db/client.js";
|
||||||
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
|
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
|
||||||
|
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
|
||||||
|
|
||||||
export const circuitWriteService = new CircuitWriteService({
|
export const circuitWriteService = new CircuitWriteService({
|
||||||
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
|
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
|
||||||
|
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { ProjectDeviceRowSyncRepository } from "../../db/repositories/project-device-row-sync.repository.js";
|
||||||
|
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
|
||||||
|
|
||||||
|
export const projectDeviceSyncService = new ProjectDeviceSyncService({
|
||||||
|
deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db),
|
||||||
|
});
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { db } from "../../db/client.js";
|
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
|
||||||
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
|
|
||||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
|
||||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
|
||||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
|
||||||
import type { Consumer } from "../../domain/models/consumer.model.js";
|
|
||||||
import { applyLinkedProjectDeviceValues } from "../../domain/services/consumer-linking.service.js";
|
|
||||||
import { PowerBalanceService } from "../../domain/services/power-balance.service.js";
|
|
||||||
import {
|
|
||||||
type CreateConsumerInput,
|
|
||||||
createConsumerSchema,
|
|
||||||
updateConsumerSchema,
|
|
||||||
} from "../../shared/validation/consumer.schemas.js";
|
|
||||||
|
|
||||||
const circuitListRepository = new CircuitListRepository();
|
|
||||||
const consumerRepository = new ConsumerRepository();
|
|
||||||
const distributionBoardRepository = new DistributionBoardRepository(db);
|
|
||||||
const floorRepository = new FloorRepository();
|
|
||||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
|
||||||
const projectRepository = new ProjectRepository();
|
|
||||||
const roomRepository = new RoomRepository();
|
|
||||||
const powerBalanceService = new PowerBalanceService();
|
|
||||||
|
|
||||||
type ConsumerRow = {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
distributionBoardId: string | null;
|
|
||||||
circuitListId: string | null;
|
|
||||||
projectDeviceId: string | null;
|
|
||||||
isLinkedToDevice: number;
|
|
||||||
roomId: string | null;
|
|
||||||
circuitNumber: string | null;
|
|
||||||
description: string | null;
|
|
||||||
name: string;
|
|
||||||
category: string | null;
|
|
||||||
deviceType: string | null;
|
|
||||||
phaseType: string | null;
|
|
||||||
tradeOrCostGroup: string | null;
|
|
||||||
group: string | null;
|
|
||||||
protectionType: string | null;
|
|
||||||
protectionRatedCurrent: number | null;
|
|
||||||
protectionCharacteristic: string | null;
|
|
||||||
cableType: string | null;
|
|
||||||
cableCrossSection: string | null;
|
|
||||||
comment: string | null;
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
voltageV: number | null;
|
|
||||||
phaseCount: number | null;
|
|
||||||
powerFactor: number | null;
|
|
||||||
note: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function validateDistributionBoardOwnership(
|
|
||||||
projectId: string,
|
|
||||||
distributionBoardId: string | undefined
|
|
||||||
) {
|
|
||||||
if (!distributionBoardId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return distributionBoardRepository.existsInProject(projectId, distributionBoardId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateRoomOwnership(projectId: string, roomId: string | undefined) {
|
|
||||||
if (!roomId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return roomRepository.existsInProject(projectId, roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateProjectDeviceOwnership(projectId: string, projectDeviceId: string | undefined) {
|
|
||||||
if (!projectDeviceId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
|
|
||||||
return Boolean(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyDeviceLinkIfNeeded(input: CreateConsumerInput): Promise<CreateConsumerInput> {
|
|
||||||
if (!input.projectDeviceId || !input.isLinkedToDevice) {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
const device = await projectDeviceRepository.findById(input.projectId, input.projectDeviceId);
|
|
||||||
return applyLinkedProjectDeviceValues(input, device);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveCircuitScope(input: {
|
|
||||||
projectId: string;
|
|
||||||
distributionBoardId?: string;
|
|
||||||
circuitListId?: string;
|
|
||||||
}) {
|
|
||||||
let distributionBoardId = input.distributionBoardId;
|
|
||||||
let circuitListId = input.circuitListId;
|
|
||||||
|
|
||||||
if (distributionBoardId) {
|
|
||||||
const linkedList = await circuitListRepository.findByDistributionBoardId(
|
|
||||||
input.projectId,
|
|
||||||
distributionBoardId
|
|
||||||
);
|
|
||||||
if (!linkedList) {
|
|
||||||
return { ok: false as const, error: "No circuit list found for the provided distribution board." };
|
|
||||||
}
|
|
||||||
if (circuitListId && circuitListId !== linkedList.id) {
|
|
||||||
return {
|
|
||||||
ok: false as const,
|
|
||||||
error: "Circuit list does not match the provided distribution board.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
circuitListId = linkedList.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (circuitListId) {
|
|
||||||
const list = await circuitListRepository.findById(input.projectId, circuitListId);
|
|
||||||
if (!list) {
|
|
||||||
return { ok: false as const, error: "Circuit list does not belong to the provided project." };
|
|
||||||
}
|
|
||||||
if (distributionBoardId && distributionBoardId !== list.distributionBoardId) {
|
|
||||||
return {
|
|
||||||
ok: false as const,
|
|
||||||
error: "Circuit list does not match the provided distribution board.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
distributionBoardId = list.distributionBoardId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true as const,
|
|
||||||
distributionBoardId,
|
|
||||||
circuitListId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConsumerFromRow(
|
|
||||||
row: ConsumerRow,
|
|
||||||
roomById: Map<string, { floorId: string | null; roomName: string; roomNumber: string }>,
|
|
||||||
floorById: Map<string, { name: string }>
|
|
||||||
): Consumer {
|
|
||||||
const room = row.roomId ? roomById.get(row.roomId) : undefined;
|
|
||||||
const floor = room?.floorId ? floorById.get(room.floorId) : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
projectId: row.projectId,
|
|
||||||
distributionBoardId: row.distributionBoardId ?? undefined,
|
|
||||||
circuitListId: row.circuitListId ?? undefined,
|
|
||||||
projectDeviceId: row.projectDeviceId ?? undefined,
|
|
||||||
isLinkedToDevice: Boolean(row.isLinkedToDevice),
|
|
||||||
roomId: row.roomId ?? undefined,
|
|
||||||
roomNumber: room?.roomNumber,
|
|
||||||
roomName: room?.roomName,
|
|
||||||
floorId: room?.floorId ?? undefined,
|
|
||||||
floorName: floor?.name,
|
|
||||||
circuitNumber: row.circuitNumber ?? undefined,
|
|
||||||
description: row.description ?? undefined,
|
|
||||||
name: row.name,
|
|
||||||
category: row.category ?? undefined,
|
|
||||||
deviceType: row.deviceType ?? undefined,
|
|
||||||
phaseType: row.phaseType ?? undefined,
|
|
||||||
tradeOrCostGroup: row.tradeOrCostGroup ?? undefined,
|
|
||||||
group: row.group ?? undefined,
|
|
||||||
protectionType: row.protectionType ?? undefined,
|
|
||||||
protectionRatedCurrent: row.protectionRatedCurrent ?? undefined,
|
|
||||||
protectionCharacteristic: row.protectionCharacteristic ?? undefined,
|
|
||||||
cableType: row.cableType ?? undefined,
|
|
||||||
cableCrossSection: row.cableCrossSection ?? undefined,
|
|
||||||
comment: row.comment ?? undefined,
|
|
||||||
quantity: row.quantity,
|
|
||||||
installedPowerPerUnitKw: row.installedPowerPerUnitKw,
|
|
||||||
demandFactor: row.demandFactor,
|
|
||||||
voltageV: row.voltageV ?? undefined,
|
|
||||||
phaseCount: row.phaseCount === 1 || row.phaseCount === 3 ? row.phaseCount : undefined,
|
|
||||||
powerFactor: row.powerFactor ?? undefined,
|
|
||||||
note: row.note ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listConsumersByProject(req: Request, res: Response) {
|
|
||||||
const { projectId } = req.params;
|
|
||||||
if (typeof projectId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid projectId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [rows, project, floors, rooms] = await Promise.all([
|
|
||||||
consumerRepository.listByProject(projectId),
|
|
||||||
projectRepository.findById(projectId),
|
|
||||||
floorRepository.listByProject(projectId),
|
|
||||||
roomRepository.listByProject(projectId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const roomById = new Map(
|
|
||||||
rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }])
|
|
||||||
);
|
|
||||||
const floorById = new Map(floors.map((floor) => [floor.id, { name: floor.name }]));
|
|
||||||
|
|
||||||
const projectVoltageDefaults = project
|
|
||||||
? {
|
|
||||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const enriched = rows.map((row) =>
|
|
||||||
powerBalanceService.enrichConsumer(
|
|
||||||
buildConsumerFromRow(row as ConsumerRow, roomById, floorById),
|
|
||||||
projectVoltageDefaults
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.json(enriched);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createConsumer(req: Request, res: Response) {
|
|
||||||
const parsed = createConsumerSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedData: CreateConsumerInput = {
|
|
||||||
...parsed.data,
|
|
||||||
name: parsed.data.name?.trim() || "Unbenannter Eintrag",
|
|
||||||
quantity: parsed.data.quantity ?? 0,
|
|
||||||
installedPowerPerUnitKw: parsed.data.installedPowerPerUnitKw ?? 0,
|
|
||||||
demandFactor: parsed.data.demandFactor ?? 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [hasValidDistributionBoard, hasValidRoom, hasValidProjectDevice] = await Promise.all([
|
|
||||||
validateDistributionBoardOwnership(normalizedData.projectId, normalizedData.distributionBoardId),
|
|
||||||
validateRoomOwnership(normalizedData.projectId, normalizedData.roomId),
|
|
||||||
validateProjectDeviceOwnership(normalizedData.projectId, normalizedData.projectDeviceId),
|
|
||||||
]);
|
|
||||||
if (!hasValidDistributionBoard) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Distribution board does not belong to the provided project." });
|
|
||||||
}
|
|
||||||
if (!hasValidRoom) {
|
|
||||||
return res.status(400).json({ error: "Room does not belong to the provided project." });
|
|
||||||
}
|
|
||||||
if (!hasValidProjectDevice) {
|
|
||||||
return res.status(400).json({ error: "Project device does not belong to the provided project." });
|
|
||||||
}
|
|
||||||
if (normalizedData.isLinkedToDevice && !normalizedData.projectDeviceId) {
|
|
||||||
return res.status(400).json({ error: "Linked entries require a projectDeviceId." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedScope = await resolveCircuitScope({
|
|
||||||
projectId: normalizedData.projectId,
|
|
||||||
distributionBoardId: normalizedData.distributionBoardId,
|
|
||||||
circuitListId: normalizedData.circuitListId,
|
|
||||||
});
|
|
||||||
if (!resolvedScope.ok) {
|
|
||||||
return res.status(400).json({ error: resolvedScope.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await applyDeviceLinkIfNeeded({
|
|
||||||
...normalizedData,
|
|
||||||
distributionBoardId: resolvedScope.distributionBoardId,
|
|
||||||
circuitListId: resolvedScope.circuitListId,
|
|
||||||
description: normalizedData.description ?? normalizedData.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await consumerRepository.create(payload);
|
|
||||||
const [project, floors, rooms] = await Promise.all([
|
|
||||||
projectRepository.findById(normalizedData.projectId),
|
|
||||||
floorRepository.listByProject(normalizedData.projectId),
|
|
||||||
roomRepository.listByProject(normalizedData.projectId),
|
|
||||||
]);
|
|
||||||
const roomById = new Map(
|
|
||||||
rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }])
|
|
||||||
);
|
|
||||||
const floorById = new Map(floors.map((floor) => [floor.id, { name: floor.name }]));
|
|
||||||
|
|
||||||
const enriched = powerBalanceService.enrichConsumer(
|
|
||||||
{
|
|
||||||
...(created as Consumer),
|
|
||||||
description: created.description ?? created.name,
|
|
||||||
roomNumber: created.roomId ? roomById.get(created.roomId)?.roomNumber : undefined,
|
|
||||||
roomName: created.roomId ? roomById.get(created.roomId)?.roomName : undefined,
|
|
||||||
floorId: created.roomId ? roomById.get(created.roomId)?.floorId ?? undefined : undefined,
|
|
||||||
floorName:
|
|
||||||
created.roomId && roomById.get(created.roomId)?.floorId
|
|
||||||
? floorById.get(roomById.get(created.roomId)!.floorId as string)?.name
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
project
|
|
||||||
? {
|
|
||||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.status(201).json(enriched);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateConsumer(req: Request, res: Response) {
|
|
||||||
const { consumerId } = req.params;
|
|
||||||
if (typeof consumerId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid consumerId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = updateConsumerSchema.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedData: CreateConsumerInput = {
|
|
||||||
...parsed.data,
|
|
||||||
name: parsed.data.name?.trim() || "Unbenannter Eintrag",
|
|
||||||
quantity: parsed.data.quantity ?? 0,
|
|
||||||
installedPowerPerUnitKw: parsed.data.installedPowerPerUnitKw ?? 0,
|
|
||||||
demandFactor: parsed.data.demandFactor ?? 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [hasValidDistributionBoard, hasValidRoom, hasValidProjectDevice] = await Promise.all([
|
|
||||||
validateDistributionBoardOwnership(normalizedData.projectId, normalizedData.distributionBoardId),
|
|
||||||
validateRoomOwnership(normalizedData.projectId, normalizedData.roomId),
|
|
||||||
validateProjectDeviceOwnership(normalizedData.projectId, normalizedData.projectDeviceId),
|
|
||||||
]);
|
|
||||||
if (!hasValidDistributionBoard) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Distribution board does not belong to the provided project." });
|
|
||||||
}
|
|
||||||
if (!hasValidRoom) {
|
|
||||||
return res.status(400).json({ error: "Room does not belong to the provided project." });
|
|
||||||
}
|
|
||||||
if (!hasValidProjectDevice) {
|
|
||||||
return res.status(400).json({ error: "Project device does not belong to the provided project." });
|
|
||||||
}
|
|
||||||
if (normalizedData.isLinkedToDevice && !normalizedData.projectDeviceId) {
|
|
||||||
return res.status(400).json({ error: "Linked entries require a projectDeviceId." });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedScope = await resolveCircuitScope({
|
|
||||||
projectId: normalizedData.projectId,
|
|
||||||
distributionBoardId: normalizedData.distributionBoardId,
|
|
||||||
circuitListId: normalizedData.circuitListId,
|
|
||||||
});
|
|
||||||
if (!resolvedScope.ok) {
|
|
||||||
return res.status(400).json({ error: resolvedScope.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await applyDeviceLinkIfNeeded({
|
|
||||||
...normalizedData,
|
|
||||||
distributionBoardId: resolvedScope.distributionBoardId,
|
|
||||||
circuitListId: resolvedScope.circuitListId,
|
|
||||||
description: normalizedData.description ?? normalizedData.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
await consumerRepository.update(consumerId, payload);
|
|
||||||
|
|
||||||
const row = await consumerRepository.findById(consumerId);
|
|
||||||
if (!row) {
|
|
||||||
return res.status(404).json({ error: "Consumer not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const [project, floors, rooms] = await Promise.all([
|
|
||||||
projectRepository.findById(row.projectId),
|
|
||||||
floorRepository.listByProject(row.projectId),
|
|
||||||
roomRepository.listByProject(row.projectId),
|
|
||||||
]);
|
|
||||||
const roomById = new Map(
|
|
||||||
rooms.map((room) => [room.id, { floorId: room.floorId, roomName: room.roomName, roomNumber: room.roomNumber }])
|
|
||||||
);
|
|
||||||
const floorById = new Map(floors.map((floor) => [floor.id, { name: floor.name }]));
|
|
||||||
|
|
||||||
const enriched = powerBalanceService.enrichConsumer(
|
|
||||||
buildConsumerFromRow(row as ConsumerRow, roomById, floorById),
|
|
||||||
project
|
|
||||||
? {
|
|
||||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
return res.json(enriched);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteConsumer(req: Request, res: Response) {
|
|
||||||
const { consumerId } = req.params;
|
|
||||||
if (typeof consumerId !== "string") {
|
|
||||||
return res.status(400).json({ error: "Invalid consumerId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await consumerRepository.delete(consumerId);
|
|
||||||
return res.status(204).send();
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { db } from "../../db/client.js";
|
import { db } from "../../db/client.js";
|
||||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
|
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
const distributionBoardRepository = new DistributionBoardRepository(db);
|
const distributionBoardRepository = new DistributionBoardRepository(db);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||||
import { createFloorSchema } from "../../shared/validation/consumer.schemas.js";
|
import { createFloorSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
const floorRepository = new FloorRepository();
|
const floorRepository = new FloorRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
|
import { projectDeviceSyncService } from "../composition/project-device-sync-service.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceSchema,
|
createProjectDeviceSchema,
|
||||||
disconnectProjectDeviceRowsSchema,
|
disconnectProjectDeviceRowsSchema,
|
||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
|
|
||||||
const globalDeviceRepository = new GlobalDeviceRepository();
|
const globalDeviceRepository = new GlobalDeviceRepository();
|
||||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||||
const projectDeviceSyncService = new ProjectDeviceSyncService();
|
|
||||||
|
|
||||||
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
if (typeof projectId !== "string") {
|
if (typeof projectId !== "string") {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
|||||||
import {
|
import {
|
||||||
createProjectSchema,
|
createProjectSchema,
|
||||||
updateProjectSettingsSchema,
|
updateProjectSettingsSchema,
|
||||||
} from "../../shared/validation/consumer.schemas.js";
|
} from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
const projectRepository = new ProjectRepository();
|
const projectRepository = new ProjectRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||||
import { createRoomSchema } from "../../shared/validation/consumer.schemas.js";
|
import { createRoomSchema } from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
const floorRepository = new FloorRepository();
|
const floorRepository = new FloorRepository();
|
||||||
const roomRepository = new RoomRepository();
|
const roomRepository = new RoomRepository();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import express from "express";
|
|||||||
import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js";
|
import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js";
|
||||||
import { circuitRouter } from "./routes/circuit.routes.js";
|
import { circuitRouter } from "./routes/circuit.routes.js";
|
||||||
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
|
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
|
||||||
import { consumerRouter } from "./routes/consumer.routes.js";
|
|
||||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||||
import { projectRouter } from "./routes/project.routes.js";
|
import { projectRouter } from "./routes/project.routes.js";
|
||||||
@@ -21,7 +20,6 @@ app.use("/api/projects", projectRouter);
|
|||||||
app.use("/api", circuitRouter);
|
app.use("/api", circuitRouter);
|
||||||
app.use("/api", circuitDeviceRowRouter);
|
app.use("/api", circuitDeviceRowRouter);
|
||||||
app.use("/api", circuitSectionRouter);
|
app.use("/api", circuitSectionRouter);
|
||||||
app.use("/api/consumers", consumerRouter);
|
|
||||||
app.use("/api/global-devices", globalDeviceRouter);
|
app.use("/api/global-devices", globalDeviceRouter);
|
||||||
app.use("/api/project-devices", projectDeviceRouter);
|
app.use("/api/project-devices", projectDeviceRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
import {
|
|
||||||
createConsumer,
|
|
||||||
deleteConsumer,
|
|
||||||
listConsumersByProject,
|
|
||||||
updateConsumer,
|
|
||||||
} from "../controllers/consumer.controller.js";
|
|
||||||
|
|
||||||
export const consumerRouter = Router();
|
|
||||||
|
|
||||||
consumerRouter.get("/projects/:projectId", listConsumersByProject);
|
|
||||||
consumerRouter.post("/", createConsumer);
|
|
||||||
consumerRouter.put("/:consumerId", updateConsumer);
|
|
||||||
consumerRouter.delete("/:consumerId", deleteConsumer);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
export const deviceTypeOptions = [
|
|
||||||
"Beleuchtung",
|
|
||||||
"Steckdose",
|
|
||||||
"Heizung",
|
|
||||||
"Kühlung",
|
|
||||||
"Lüftung",
|
|
||||||
"Antrieb",
|
|
||||||
"Sicherheit",
|
|
||||||
"IT",
|
|
||||||
"Sonstiges",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const phaseTypeOptions = ["1-phasig", "3-phasig"] as const;
|
|
||||||
|
|
||||||
export const tradeOrCostGroupOptions = [
|
|
||||||
"KG 440 Starkstromanlagen",
|
|
||||||
"KG 450 Fernmelde- und informationstechnische Anlagen",
|
|
||||||
"KG 460 Förderanlagen",
|
|
||||||
"KG 470 Nutzungsspezifische Anlagen",
|
|
||||||
"KG 480 Gebäude- und Anlagenautomation",
|
|
||||||
"Sonstiges",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const consumerGroupOptions = [
|
|
||||||
"Allgemein",
|
|
||||||
"Notstrom",
|
|
||||||
"Sicherheitsstrom",
|
|
||||||
"USV",
|
|
||||||
"Technik",
|
|
||||||
"Reserve",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const protectionTypeOptions = ["LS", "Schmelzsicherung", "Leistungsschalter", "FI/LS"] as const;
|
|
||||||
|
|
||||||
export const protectionCharacteristicOptions = ["B", "C", "D", "K", "Z"] as const;
|
|
||||||
|
|
||||||
export const cableTypeOptions = ["NYM-J", "NYY-J", "H07RN-F", "NHXMH-J", "Sonstiges"] as const;
|
|
||||||
|
|
||||||
export const cableCrossSectionOptions = [
|
|
||||||
"1,5 mm²",
|
|
||||||
"2,5 mm²",
|
|
||||||
"4 mm²",
|
|
||||||
"6 mm²",
|
|
||||||
"10 mm²",
|
|
||||||
"16 mm²",
|
|
||||||
"25 mm²",
|
|
||||||
"35 mm²",
|
|
||||||
"50 mm²",
|
|
||||||
] as const;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export interface ProjectDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DistributionBoardDto {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConsumerDto {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
distributionBoardId: string | null;
|
|
||||||
circuitListId: string | null;
|
|
||||||
roomId: string | null;
|
|
||||||
circuitNumber: string | null;
|
|
||||||
description: string | null;
|
|
||||||
name: string;
|
|
||||||
category: string | null;
|
|
||||||
deviceType: string | null;
|
|
||||||
phaseType: string | null;
|
|
||||||
tradeOrCostGroup: string | null;
|
|
||||||
group: string | null;
|
|
||||||
protectionType: string | null;
|
|
||||||
protectionRatedCurrent: number | null;
|
|
||||||
protectionCharacteristic: string | null;
|
|
||||||
cableType: string | null;
|
|
||||||
cableCrossSection: string | null;
|
|
||||||
comment: string | null;
|
|
||||||
quantity: number;
|
|
||||||
installedPowerPerUnitKw: number;
|
|
||||||
demandFactor: number;
|
|
||||||
voltageV: number | null;
|
|
||||||
phaseCount: 1 | 3 | null;
|
|
||||||
powerFactor: number | null;
|
|
||||||
note: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
cableCrossSectionOptions,
|
|
||||||
cableTypeOptions,
|
|
||||||
consumerGroupOptions,
|
|
||||||
deviceTypeOptions,
|
|
||||||
phaseTypeOptions,
|
|
||||||
protectionCharacteristicOptions,
|
|
||||||
protectionTypeOptions,
|
|
||||||
tradeOrCostGroupOptions,
|
|
||||||
} from "../constants/consumer-option-lists.js";
|
|
||||||
|
|
||||||
export const createConsumerSchema = z.object({
|
|
||||||
projectId: z.string().min(1),
|
|
||||||
distributionBoardId: z.string().min(1).optional(),
|
|
||||||
circuitListId: z.string().min(1).optional(),
|
|
||||||
projectDeviceId: z.string().min(1).optional(),
|
|
||||||
isLinkedToDevice: z.boolean().optional(),
|
|
||||||
roomId: z.string().min(1).optional(),
|
|
||||||
circuitNumber: z.string().optional(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
name: z.string().optional(),
|
|
||||||
category: z.string().optional(),
|
|
||||||
deviceType: z.enum(deviceTypeOptions).optional(),
|
|
||||||
phaseType: z.enum(phaseTypeOptions).optional(),
|
|
||||||
tradeOrCostGroup: z.enum(tradeOrCostGroupOptions).optional(),
|
|
||||||
group: z.enum(consumerGroupOptions).optional(),
|
|
||||||
protectionType: z.enum(protectionTypeOptions).optional(),
|
|
||||||
protectionRatedCurrent: z.number().min(0).optional(),
|
|
||||||
protectionCharacteristic: z.enum(protectionCharacteristicOptions).optional(),
|
|
||||||
cableType: z.enum(cableTypeOptions).optional(),
|
|
||||||
cableCrossSection: z.enum(cableCrossSectionOptions).optional(),
|
|
||||||
comment: z.string().optional(),
|
|
||||||
quantity: z.number().min(0).optional(),
|
|
||||||
installedPowerPerUnitKw: z.number().min(0).optional(),
|
|
||||||
demandFactor: z.number().min(0).max(1).optional(),
|
|
||||||
voltageV: z.number().positive().optional(),
|
|
||||||
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(),
|
|
||||||
powerFactor: z.number().min(0).max(1).optional(),
|
|
||||||
note: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateConsumerSchema = createConsumerSchema;
|
|
||||||
|
|
||||||
export const createProjectSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
singlePhaseVoltageV: z.number().positive().optional(),
|
|
||||||
threePhaseVoltageV: z.number().positive().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateProjectSettingsSchema = z.object({
|
|
||||||
singlePhaseVoltageV: z.number().positive(),
|
|
||||||
threePhaseVoltageV: z.number().positive(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createDistributionBoardSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createFloorSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createRoomSchema = z.object({
|
|
||||||
floorId: z.string().min(1).optional(),
|
|
||||||
roomNumber: z.string().min(1),
|
|
||||||
roomName: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CreateConsumerInput = z.infer<typeof createConsumerSchema>;
|
|
||||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
|
||||||
export type UpdateProjectSettingsInput = z.infer<typeof updateProjectSettingsSchema>;
|
|
||||||
export type CreateDistributionBoardInput = z.infer<typeof createDistributionBoardSchema>;
|
|
||||||
export type UpdateConsumerInput = z.infer<typeof updateConsumerSchema>;
|
|
||||||
export type CreateFloorInput = z.infer<typeof createFloorSchema>;
|
|
||||||
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createProjectSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
singlePhaseVoltageV: z.number().positive().optional(),
|
||||||
|
threePhaseVoltageV: z.number().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateProjectSettingsSchema = z.object({
|
||||||
|
singlePhaseVoltageV: z.number().positive(),
|
||||||
|
threePhaseVoltageV: z.number().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDistributionBoardSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createFloorSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createRoomSchema = z.object({
|
||||||
|
floorId: z.string().min(1).optional(),
|
||||||
|
roomNumber: z.string().min(1),
|
||||||
|
roomName: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||||
|
export type UpdateProjectSettingsInput = z.infer<
|
||||||
|
typeof updateProjectSettingsSchema
|
||||||
|
>;
|
||||||
|
export type CreateDistributionBoardInput = z.infer<
|
||||||
|
typeof createDistributionBoardSchema
|
||||||
|
>;
|
||||||
|
export type CreateFloorInput = z.infer<typeof createFloorSchema>;
|
||||||
|
export type CreateRoomInput = z.infer<typeof createRoomSchema>;
|
||||||
@@ -47,15 +47,49 @@ function createTestDatabase(): DatabaseContext {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertDeviceRow(context: DatabaseContext) {
|
function insertCircuit(
|
||||||
|
context: DatabaseContext,
|
||||||
|
input: {
|
||||||
|
id: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isReserve?: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: input.id,
|
||||||
|
circuitListId: seedCircuit.circuitListId,
|
||||||
|
sectionId: seedCircuit.sectionId,
|
||||||
|
equipmentIdentifier: input.equipmentIdentifier,
|
||||||
|
displayName: input.equipmentIdentifier,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
isReserve: input.isReserve ?? 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertDeviceRow(
|
||||||
|
context: DatabaseContext,
|
||||||
|
input: {
|
||||||
|
id?: string;
|
||||||
|
circuitId?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
displayName?: string;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const rowId = input.id ?? "row-1";
|
||||||
|
const circuitId = input.circuitId ?? "circuit-1";
|
||||||
context.db
|
context.db
|
||||||
.insert(circuitDeviceRows)
|
.insert(circuitDeviceRows)
|
||||||
.values({
|
.values({
|
||||||
id: "row-1",
|
id: rowId,
|
||||||
circuitId: "circuit-1",
|
circuitId,
|
||||||
sortOrder: 10,
|
sortOrder: input.sortOrder ?? 10,
|
||||||
name: "Leuchte",
|
name: "Leuchte",
|
||||||
displayName: "Leuchte",
|
displayName: input.displayName ?? "Leuchte",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
powerPerUnit: 0.1,
|
powerPerUnit: 0.1,
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 1,
|
||||||
@@ -64,7 +98,7 @@ function insertDeviceRow(context: DatabaseContext) {
|
|||||||
context.db
|
context.db
|
||||||
.update(circuits)
|
.update(circuits)
|
||||||
.set({ isReserve: 0 })
|
.set({ isReserve: 0 })
|
||||||
.where(eq(circuits.id, "circuit-1"))
|
.where(eq(circuits.id, circuitId))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +168,115 @@ describe("circuit device-row transaction repository", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("commits a new circuit and all initial device rows together", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
const created = repository.createCircuitWithDeviceRows({
|
||||||
|
circuit: {
|
||||||
|
circuitListId: seedCircuit.circuitListId,
|
||||||
|
sectionId: seedCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Beleuchtung",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
deviceRows: [
|
||||||
|
{
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte 1",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
sortOrder: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte 2",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [createdCircuit] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, created.circuitId))
|
||||||
|
.all();
|
||||||
|
const createdRows = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, created.circuitId))
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.sortOrder - right.sortOrder);
|
||||||
|
|
||||||
|
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
|
||||||
|
assert.equal(createdCircuit.isReserve, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
createdRows.map((row) => [row.id, row.displayName, row.sortOrder]),
|
||||||
|
[
|
||||||
|
[created.rowIds[0], "Leuchte 1", 15],
|
||||||
|
[created.rowIds[1], "Leuchte 2", 20],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the circuit and earlier rows when an initial row insert fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const [seedCircuit] = context.db.select().from(circuits).limit(1).all();
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_initial_device_row
|
||||||
|
BEFORE INSERT ON circuit_device_rows
|
||||||
|
WHEN NEW.name = 'Fail'
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced initial row failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.createCircuitWithDeviceRows({
|
||||||
|
circuit: {
|
||||||
|
circuitListId: seedCircuit.circuitListId,
|
||||||
|
sectionId: seedCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Rollback",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
deviceRows: [
|
||||||
|
{
|
||||||
|
name: "First",
|
||||||
|
displayName: "Erste Zeile",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fail",
|
||||||
|
displayName: "Fehlerhafte Zeile",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/forced initial row failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(context.db.select().from(circuits).all().length, 1);
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("commits the last-row deletion and activates the circuit reserve status together", () => {
|
it("commits the last-row deletion and activates the circuit reserve status together", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
@@ -174,4 +317,213 @@ describe("circuit device-row transaction repository", () => {
|
|||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves rows to an existing circuit and updates both reserve states together", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertCircuit(context, {
|
||||||
|
id: "circuit-2",
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
});
|
||||||
|
insertCircuit(context, {
|
||||||
|
id: "circuit-3",
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
sortOrder: 30,
|
||||||
|
});
|
||||||
|
insertDeviceRow(context);
|
||||||
|
insertDeviceRow(context, {
|
||||||
|
id: "row-2",
|
||||||
|
circuitId: "circuit-2",
|
||||||
|
sortOrder: 10,
|
||||||
|
displayName: "Zweite Quellzeile",
|
||||||
|
});
|
||||||
|
insertDeviceRow(context, {
|
||||||
|
id: "target-row",
|
||||||
|
circuitId: "circuit-3",
|
||||||
|
sortOrder: 10,
|
||||||
|
displayName: "Bestehende Zielzeile",
|
||||||
|
});
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
const result = repository.moveRows({
|
||||||
|
rows: [
|
||||||
|
{ id: "row-1", expectedCircuitId: "circuit-1" },
|
||||||
|
{ id: "row-2", expectedCircuitId: "circuit-2" },
|
||||||
|
],
|
||||||
|
targetCircuitId: "circuit-3",
|
||||||
|
});
|
||||||
|
|
||||||
|
const movedRows = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.all()
|
||||||
|
.filter((row) => row.id === "row-1" || row.id === "row-2")
|
||||||
|
.sort((left, right) => left.sortOrder - right.sortOrder);
|
||||||
|
const persistedCircuits = context.db.select().from(circuits).all();
|
||||||
|
const targetCircuit = persistedCircuits.find(
|
||||||
|
(circuit) => circuit.id === "circuit-3"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
movedRowIds: ["row-1", "row-2"],
|
||||||
|
targetCircuitId: "circuit-3",
|
||||||
|
createdCircuitId: undefined,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
movedRows.map((row) => [row.id, row.circuitId, row.sortOrder]),
|
||||||
|
[
|
||||||
|
["row-1", "circuit-3", 20],
|
||||||
|
["row-2", "circuit-3", 30],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(targetCircuit?.isReserve, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back an existing-target move when a reserve update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertCircuit(context, {
|
||||||
|
id: "circuit-2",
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
sortOrder: 20,
|
||||||
|
});
|
||||||
|
insertDeviceRow(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_source_reserve_update
|
||||||
|
BEFORE UPDATE OF is_reserve ON circuits
|
||||||
|
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced source reserve failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.moveRows({
|
||||||
|
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
|
||||||
|
targetCircuitId: "circuit-2",
|
||||||
|
}),
|
||||||
|
/forced source reserve failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
const [row] = context.db.select().from(circuitDeviceRows).all();
|
||||||
|
const persistedCircuits = context.db.select().from(circuits).all();
|
||||||
|
assert.equal(row.circuitId, "circuit-1");
|
||||||
|
assert.equal(
|
||||||
|
persistedCircuits.find((circuit) => circuit.id === "circuit-1")?.isReserve,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
persistedCircuits.find((circuit) => circuit.id === "circuit-2")?.isReserve,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new target circuit and moves rows into it atomically", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertDeviceRow(context);
|
||||||
|
const [sourceCircuit] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.all();
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
const result = repository.moveRows({
|
||||||
|
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
|
||||||
|
createTargetCircuit: {
|
||||||
|
circuitListId: sourceCircuit.circuitListId,
|
||||||
|
sectionId: sourceCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Neues Ziel",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [movedRow] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.id, "row-1"))
|
||||||
|
.all();
|
||||||
|
const [createdCircuit] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, result.createdCircuitId!))
|
||||||
|
.all();
|
||||||
|
const [updatedSource] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
assert.equal(result.targetCircuitId, result.createdCircuitId);
|
||||||
|
assert.equal(createdCircuit.equipmentIdentifier, "-1F2");
|
||||||
|
assert.equal(createdCircuit.isReserve, 0);
|
||||||
|
assert.equal(movedRow.circuitId, createdCircuit.id);
|
||||||
|
assert.equal(updatedSource.isReserve, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a newly created target circuit when the move fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertDeviceRow(context);
|
||||||
|
const [sourceCircuit] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.where(eq(circuits.id, "circuit-1"))
|
||||||
|
.all();
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_new_target_source_reserve
|
||||||
|
BEFORE UPDATE OF is_reserve ON circuits
|
||||||
|
WHEN NEW.id = 'circuit-1' AND NEW.is_reserve = 1
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced new-target move failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitDeviceRowTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.moveRows({
|
||||||
|
rows: [{ id: "row-1", expectedCircuitId: "circuit-1" }],
|
||||||
|
createTargetCircuit: {
|
||||||
|
circuitListId: sourceCircuit.circuitListId,
|
||||||
|
sectionId: sourceCircuit.sectionId,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Rollback-Ziel",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/forced new-target move failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
const [row] = context.db.select().from(circuitDeviceRows).all();
|
||||||
|
const persistedCircuits = context.db.select().from(circuits).all();
|
||||||
|
assert.equal(row.circuitId, "circuit-1");
|
||||||
|
assert.equal(persistedCircuits.length, 1);
|
||||||
|
assert.equal(persistedCircuits[0].isReserve, 0);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { CircuitSectionTransactionRepository } from "../src/db/repositories/circuit-section-transaction.repository.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const [section] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Stromkreis 1",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-2",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F2",
|
||||||
|
displayName: "Stromkreis 2",
|
||||||
|
sortOrder: 20,
|
||||||
|
isReserve: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "circuit-3",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F3",
|
||||||
|
displayName: "Stromkreis 3",
|
||||||
|
sortOrder: 30,
|
||||||
|
isReserve: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values({
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Leuchte",
|
||||||
|
displayName: "Leuchte",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.1,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCircuits(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(circuits)
|
||||||
|
.orderBy(asc(circuits.id))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("circuit-section transaction repository", () => {
|
||||||
|
it("commits identifier swaps without violating the circuit-list uniqueness constraint", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
repository.updateEquipmentIdentifiers(
|
||||||
|
persisted[0].circuitListId,
|
||||||
|
[
|
||||||
|
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
|
||||||
|
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
|
||||||
|
],
|
||||||
|
persisted[0].sectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F2", "-1F1", "-1F3"]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(circuitDeviceRows).all()[0].circuitId,
|
||||||
|
"circuit-1"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back temporary and final identifiers when a later update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_second_final_identifier
|
||||||
|
BEFORE UPDATE OF equipment_identifier ON circuits
|
||||||
|
WHEN OLD.id = 'circuit-2' AND NEW.equipment_identifier = '-1F1'
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced identifier failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.updateEquipmentIdentifiers(
|
||||||
|
persisted[0].circuitListId,
|
||||||
|
[
|
||||||
|
{ id: "circuit-1", equipmentIdentifier: "-1F2" },
|
||||||
|
{ id: "circuit-2", equipmentIdentifier: "-1F1" },
|
||||||
|
],
|
||||||
|
persisted[0].sectionId
|
||||||
|
),
|
||||||
|
/forced identifier failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => circuit.equipmentIdentifier),
|
||||||
|
["-1F1", "-1F2", "-1F3"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a complete section reorder without changing identifiers", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
repository.updateSortOrders(persisted[0].sectionId, [
|
||||||
|
"circuit-3",
|
||||||
|
"circuit-1",
|
||||||
|
"circuit-2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => [
|
||||||
|
circuit.id,
|
||||||
|
circuit.sortOrder,
|
||||||
|
circuit.equipmentIdentifier,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["circuit-1", 20, "-1F1"],
|
||||||
|
["circuit-2", 30, "-1F2"],
|
||||||
|
["circuit-3", 10, "-1F3"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back earlier sort orders when a later update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const persisted = listCircuits(context);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_last_sort_order
|
||||||
|
BEFORE UPDATE OF sort_order ON circuits
|
||||||
|
WHEN OLD.id = 'circuit-2' AND NEW.sort_order = 30
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced sort failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new CircuitSectionTransactionRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.updateSortOrders(persisted[0].sectionId, [
|
||||||
|
"circuit-3",
|
||||||
|
"circuit-1",
|
||||||
|
"circuit-2",
|
||||||
|
]),
|
||||||
|
/forced sort failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listCircuits(context).map((circuit) => circuit.sortOrder),
|
||||||
|
[10, 20, 30]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js";
|
||||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
|
||||||
import { CircuitRepository } from "../src/db/repositories/circuit.repository.js";
|
|
||||||
import { db } from "../src/db/client.js";
|
|
||||||
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
|
import { createCircuitSchema } from "../src/shared/validation/circuit.schemas.js";
|
||||||
|
|
||||||
describe("circuit write service rules", () => {
|
describe("circuit write service rules", () => {
|
||||||
@@ -251,14 +248,16 @@ describe("circuit write service rules", () => {
|
|||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
deviceRowRepository: {
|
deviceRowRepository: {
|
||||||
createCircuitWithDeviceRowsTransactional(input: typeof transactionalCreate) {
|
|
||||||
transactionalCreate = input;
|
|
||||||
return { circuitId: "c-new", rowIds: ["r1", "r2"] };
|
|
||||||
},
|
|
||||||
async findById(rowId: string) {
|
async findById(rowId: string) {
|
||||||
return { id: rowId, circuitId: "c-new" } as never;
|
return { id: rowId, circuitId: "c-new" } as never;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
|
deviceRowTransactionStore: {
|
||||||
|
createCircuitWithDeviceRows(input: typeof transactionalCreate) {
|
||||||
|
transactionalCreate = input;
|
||||||
|
return { circuitId: "c-new", rowIds: ["r1", "r2"] };
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = await service.createCircuitWithDeviceRows("p1", "l1", {
|
const created = await service.createCircuitWithDeviceRows("p1", "l1", {
|
||||||
@@ -318,7 +317,9 @@ describe("circuit write service rules", () => {
|
|||||||
async listByCircuitList() {
|
async listByCircuitList() {
|
||||||
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
|
return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
||||||
safeUpdatePayload = updates;
|
safeUpdatePayload = updates;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -351,7 +352,9 @@ describe("circuit write service rules", () => {
|
|||||||
async listByCircuitList() {
|
async listByCircuitList() {
|
||||||
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
|
return [{ id: "o1", sectionId: "s2", equipmentIdentifier: "-1F2" }] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers(_listId: string, updates: Array<{ id: string; equipmentIdentifier: string }>) {
|
||||||
safeUpdatePayload = updates;
|
safeUpdatePayload = updates;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -384,7 +387,9 @@ describe("circuit write service rules", () => {
|
|||||||
async listByCircuitList() {
|
async listByCircuitList() {
|
||||||
return [] as never[];
|
return [] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely() {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers() {
|
||||||
safeCalled += 1;
|
safeCalled += 1;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -412,7 +417,9 @@ describe("circuit write service rules", () => {
|
|||||||
async findById() {
|
async findById() {
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
return { id: "r1", circuitId: "c1" } as never;
|
||||||
},
|
},
|
||||||
moveRowsTransactional(input: typeof transactionalMove) {
|
} as never,
|
||||||
|
deviceRowTransactionStore: {
|
||||||
|
moveRows(input: typeof transactionalMove) {
|
||||||
transactionalMove = input;
|
transactionalMove = input;
|
||||||
return {
|
return {
|
||||||
movedRowIds: input!.rows.map((row) => row.id),
|
movedRowIds: input!.rows.map((row) => row.id),
|
||||||
@@ -467,7 +474,9 @@ describe("circuit write service rules", () => {
|
|||||||
async findById() {
|
async findById() {
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
return { id: "r1", circuitId: "c1" } as never;
|
||||||
},
|
},
|
||||||
moveRowsTransactional(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
|
} as never,
|
||||||
|
deviceRowTransactionStore: {
|
||||||
|
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
|
||||||
preparedTarget = input.createTargetCircuit;
|
preparedTarget = input.createTargetCircuit;
|
||||||
return {
|
return {
|
||||||
movedRowIds: input.rows.map((row) => row.id),
|
movedRowIds: input.rows.map((row) => row.id),
|
||||||
@@ -523,7 +532,9 @@ describe("circuit write service rules", () => {
|
|||||||
async findById() {
|
async findById() {
|
||||||
return { id: "r1", circuitId: "c1" } as never;
|
return { id: "r1", circuitId: "c1" } as never;
|
||||||
},
|
},
|
||||||
moveRowsTransactional() {
|
} as never,
|
||||||
|
deviceRowTransactionStore: {
|
||||||
|
moveRows() {
|
||||||
transactionalMoveCount += 1;
|
transactionalMoveCount += 1;
|
||||||
throw new Error("same-circuit move must not write");
|
throw new Error("same-circuit move must not write");
|
||||||
},
|
},
|
||||||
@@ -563,7 +574,9 @@ describe("circuit write service rules", () => {
|
|||||||
}
|
}
|
||||||
return { id: "r2", circuitId: "c2" } as never;
|
return { id: "r2", circuitId: "c2" } as never;
|
||||||
},
|
},
|
||||||
moveRowsTransactional(input: typeof transactionalMove) {
|
} as never,
|
||||||
|
deviceRowTransactionStore: {
|
||||||
|
moveRows(input: typeof transactionalMove) {
|
||||||
transactionalMove = input;
|
transactionalMove = input;
|
||||||
return {
|
return {
|
||||||
movedRowIds: input!.rows.map((row) => row.id),
|
movedRowIds: input!.rows.map((row) => row.id),
|
||||||
@@ -615,7 +628,9 @@ describe("circuit write service rules", () => {
|
|||||||
async findById(rowId: string) {
|
async findById(rowId: string) {
|
||||||
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
|
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
|
||||||
},
|
},
|
||||||
moveRowsTransactional(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
|
} as never,
|
||||||
|
deviceRowTransactionStore: {
|
||||||
|
moveRows(input: { rows: Array<{ id: string }>; createTargetCircuit?: typeof preparedTarget }) {
|
||||||
transactionCount += 1;
|
transactionCount += 1;
|
||||||
preparedTarget = input.createTargetCircuit;
|
preparedTarget = input.createTargetCircuit;
|
||||||
return {
|
return {
|
||||||
@@ -680,7 +695,9 @@ describe("circuit write service rules", () => {
|
|||||||
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
||||||
] as never[];
|
] as never[];
|
||||||
},
|
},
|
||||||
updateSortOrdersSafely(sectionId: string, circuitIds: string[]) {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateSortOrders(sectionId: string, circuitIds: string[]) {
|
||||||
safeReorder = { sectionId, circuitIds };
|
safeReorder = { sectionId, circuitIds };
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -708,7 +725,9 @@ describe("circuit write service rules", () => {
|
|||||||
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
|
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 },
|
||||||
] as never[];
|
] as never[];
|
||||||
},
|
},
|
||||||
async updateEquipmentIdentifiersSafely() {
|
} as never,
|
||||||
|
circuitSectionTransactionStore: {
|
||||||
|
updateEquipmentIdentifiers() {
|
||||||
safeCalled = true;
|
safeCalled = true;
|
||||||
},
|
},
|
||||||
} as never,
|
} as never,
|
||||||
@@ -722,279 +741,6 @@ describe("circuit write service rules", () => {
|
|||||||
assert.equal(safeCalled, true);
|
assert.equal(safeCalled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("safe identifier bulk update uses synchronous transaction callback", async () => {
|
|
||||||
const repository = new CircuitRepository();
|
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
|
||||||
|
|
||||||
let callbackReturnedPromise = false;
|
|
||||||
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
|
||||||
const fakeTx = {
|
|
||||||
select() {
|
|
||||||
return {
|
|
||||||
from() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
all() {
|
|
||||||
return [{ id: "c1" }, { id: "c2" }];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
update() {
|
|
||||||
return {
|
|
||||||
set() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = cb(fakeTx);
|
|
||||||
callbackReturnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
|
|
||||||
if (callbackReturnedPromise) {
|
|
||||||
throw new Error("Transaction function cannot return a promise");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await repository.updateEquipmentIdentifiersSafely(
|
|
||||||
"l1",
|
|
||||||
[
|
|
||||||
{ id: "c1", equipmentIdentifier: "-1F1" },
|
|
||||||
{ id: "c2", equipmentIdentifier: "-1F2" },
|
|
||||||
],
|
|
||||||
"s1"
|
|
||||||
);
|
|
||||||
assert.equal(callbackReturnedPromise, false);
|
|
||||||
} finally {
|
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("safe circuit reorder uses one synchronous transaction callback", () => {
|
|
||||||
const repository = new CircuitRepository();
|
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
|
||||||
|
|
||||||
let callbackReturnedPromise = false;
|
|
||||||
const sortOrders: number[] = [];
|
|
||||||
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
|
||||||
const fakeTx = {
|
|
||||||
select() {
|
|
||||||
return {
|
|
||||||
from() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
all() {
|
|
||||||
return [{ id: "c1" }, { id: "c2" }, { id: "c3" }];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
update() {
|
|
||||||
return {
|
|
||||||
set(values: { sortOrder: number }) {
|
|
||||||
sortOrders.push(values.sortOrder);
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
return { changes: 1 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const callbackResult = cb(fakeTx);
|
|
||||||
callbackReturnedPromise = Boolean(
|
|
||||||
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
repository.updateSortOrdersSafely("s1", ["c3", "c1", "c2"]);
|
|
||||||
assert.equal(callbackReturnedPromise, false);
|
|
||||||
assert.deepEqual(sortOrders, [10, 20, 30]);
|
|
||||||
} finally {
|
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bulk device row move uses one synchronous transaction callback", () => {
|
|
||||||
const repository = new CircuitDeviceRowRepository();
|
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
|
||||||
|
|
||||||
let callbackReturnedPromise = false;
|
|
||||||
let selectCall = 0;
|
|
||||||
let rowUpdateCount = 0;
|
|
||||||
const selectedRows = [
|
|
||||||
[
|
|
||||||
{ id: "r1", circuitId: "c1" },
|
|
||||||
{ id: "r2", circuitId: "c2" },
|
|
||||||
],
|
|
||||||
[{ id: "c3", circuitListId: "l1" }],
|
|
||||||
[
|
|
||||||
{ id: "c1", circuitListId: "l1" },
|
|
||||||
{ id: "c2", circuitListId: "l1" },
|
|
||||||
],
|
|
||||||
[{ id: "existing-target-row", sortOrder: 10 }],
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
];
|
|
||||||
|
|
||||||
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
|
||||||
const fakeTx = {
|
|
||||||
select() {
|
|
||||||
const rows = selectedRows[selectCall++] ?? [];
|
|
||||||
const query = {
|
|
||||||
from() {
|
|
||||||
return query;
|
|
||||||
},
|
|
||||||
where() {
|
|
||||||
return query;
|
|
||||||
},
|
|
||||||
limit() {
|
|
||||||
return query;
|
|
||||||
},
|
|
||||||
all() {
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return query;
|
|
||||||
},
|
|
||||||
update() {
|
|
||||||
return {
|
|
||||||
set() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
rowUpdateCount += 1;
|
|
||||||
return { changes: 1 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const callbackResult = cb(fakeTx);
|
|
||||||
callbackReturnedPromise = Boolean(
|
|
||||||
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
|
|
||||||
);
|
|
||||||
if (callbackReturnedPromise) {
|
|
||||||
throw new Error("Transaction function cannot return a promise");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = repository.moveRowsTransactional({
|
|
||||||
rows: [
|
|
||||||
{ id: "r1", expectedCircuitId: "c1" },
|
|
||||||
{ id: "r2", expectedCircuitId: "c2" },
|
|
||||||
],
|
|
||||||
targetCircuitId: "c3",
|
|
||||||
});
|
|
||||||
assert.equal(callbackReturnedPromise, false);
|
|
||||||
assert.equal(rowUpdateCount, 5);
|
|
||||||
assert.deepEqual(result, {
|
|
||||||
movedRowIds: ["r1", "r2"],
|
|
||||||
targetCircuitId: "c3",
|
|
||||||
createdCircuitId: undefined,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("new circuit and initial device rows share one synchronous transaction", () => {
|
|
||||||
const repository = new CircuitDeviceRowRepository();
|
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
|
||||||
|
|
||||||
let callbackReturnedPromise = false;
|
|
||||||
const insertedValues: Array<Record<string, unknown>> = [];
|
|
||||||
(db as unknown as { transaction: (cb: (tx: unknown) => unknown) => void }).transaction = (cb) => {
|
|
||||||
const fakeTx = {
|
|
||||||
insert() {
|
|
||||||
return {
|
|
||||||
values(values: Record<string, unknown>) {
|
|
||||||
insertedValues.push(values);
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
return { changes: 1 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const callbackResult = cb(fakeTx);
|
|
||||||
callbackReturnedPromise = Boolean(
|
|
||||||
callbackResult && typeof (callbackResult as Promise<unknown>).then === "function"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = repository.createCircuitWithDeviceRowsTransactional({
|
|
||||||
circuit: {
|
|
||||||
circuitListId: "l1",
|
|
||||||
sectionId: "s1",
|
|
||||||
equipmentIdentifier: "-2F1",
|
|
||||||
displayName: "Steckdosen",
|
|
||||||
sortOrder: 10,
|
|
||||||
},
|
|
||||||
deviceRows: [
|
|
||||||
{
|
|
||||||
name: "Socket",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.2,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
sortOrder: 15,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Manual",
|
|
||||||
displayName: "Manuell",
|
|
||||||
quantity: 2,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(callbackReturnedPromise, false);
|
|
||||||
assert.equal(insertedValues.length, 3);
|
|
||||||
assert.equal(insertedValues[0].id, created.circuitId);
|
|
||||||
assert.equal(insertedValues[0].isReserve, 0);
|
|
||||||
assert.equal(insertedValues[1].id, created.rowIds[0]);
|
|
||||||
assert.equal(insertedValues[1].circuitId, created.circuitId);
|
|
||||||
assert.equal(insertedValues[1].sortOrder, 15);
|
|
||||||
assert.equal(insertedValues[2].id, created.rowIds[1]);
|
|
||||||
assert.equal(insertedValues[2].circuitId, created.circuitId);
|
|
||||||
assert.equal(insertedValues[2].sortOrder, 20);
|
|
||||||
} finally {
|
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks local edits on linked device rows as overridden fields", async () => {
|
it("tracks local edits on linked device rows as overridden fields", async () => {
|
||||||
let updatePayload: Record<string, unknown> = {};
|
let updatePayload: Record<string, unknown> = {};
|
||||||
const current = {
|
const current = {
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { applyLinkedProjectDeviceValues } from "../src/domain/services/consumer-linking.service.js";
|
|
||||||
|
|
||||||
describe("consumer linking service", () => {
|
|
||||||
it("applies project-device values to linked consumers", () => {
|
|
||||||
const input = {
|
|
||||||
projectId: "p1",
|
|
||||||
projectDeviceId: "d1",
|
|
||||||
isLinkedToDevice: true,
|
|
||||||
name: "Alt",
|
|
||||||
quantity: 1,
|
|
||||||
installedPowerPerUnitKw: 0.2,
|
|
||||||
demandFactor: 0.8,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = applyLinkedProjectDeviceValues(input, {
|
|
||||||
displayName: "Kaffeemaschine",
|
|
||||||
category: "Küche",
|
|
||||||
quantity: 3,
|
|
||||||
installedPowerPerUnitKw: 1.5,
|
|
||||||
demandFactor: 0.6,
|
|
||||||
phaseCount: 3,
|
|
||||||
powerFactor: 0.9,
|
|
||||||
note: "aus Vorlage",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.name, "Kaffeemaschine");
|
|
||||||
assert.equal(result.category, "Küche");
|
|
||||||
assert.equal(result.quantity, 3);
|
|
||||||
assert.equal(result.installedPowerPerUnitKw, 1.5);
|
|
||||||
assert.equal(result.demandFactor, 0.6);
|
|
||||||
assert.equal(result.phaseCount, 3);
|
|
||||||
assert.equal(result.powerFactor, 0.9);
|
|
||||||
assert.equal(result.note, "aus Vorlage");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps values unchanged when entry is not linked", () => {
|
|
||||||
const input = {
|
|
||||||
projectId: "p1",
|
|
||||||
projectDeviceId: "d1",
|
|
||||||
isLinkedToDevice: false,
|
|
||||||
name: "Eigener Name",
|
|
||||||
quantity: 2,
|
|
||||||
installedPowerPerUnitKw: 0.5,
|
|
||||||
demandFactor: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = applyLinkedProjectDeviceValues(input, {
|
|
||||||
displayName: "Vorlage",
|
|
||||||
category: "Test",
|
|
||||||
quantity: 9,
|
|
||||||
installedPowerPerUnitKw: 9,
|
|
||||||
demandFactor: 0.2,
|
|
||||||
phaseCount: 1,
|
|
||||||
powerFactor: 1,
|
|
||||||
note: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.name, "Eigener Name");
|
|
||||||
assert.equal(result.quantity, 2);
|
|
||||||
assert.equal(result.installedPowerPerUnitKw, 0.5);
|
|
||||||
assert.equal(result.demandFactor, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { createConsumerSchema } from "../src/shared/validation/consumer.schemas.js";
|
|
||||||
|
|
||||||
describe("consumer schema fixed option lists", () => {
|
|
||||||
it("accepts valid predefined domain values", () => {
|
|
||||||
const parsed = createConsumerSchema.safeParse({
|
|
||||||
projectId: "p1",
|
|
||||||
name: "Test",
|
|
||||||
quantity: 1,
|
|
||||||
installedPowerPerUnitKw: 1,
|
|
||||||
demandFactor: 1,
|
|
||||||
deviceType: "Beleuchtung",
|
|
||||||
phaseType: "3-phasig",
|
|
||||||
tradeOrCostGroup: "KG 440 Starkstromanlagen",
|
|
||||||
group: "Technik",
|
|
||||||
protectionType: "LS",
|
|
||||||
protectionCharacteristic: "C",
|
|
||||||
cableType: "NYM-J",
|
|
||||||
cableCrossSection: "2,5 mm²",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(parsed.success, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown domain values", () => {
|
|
||||||
const parsed = createConsumerSchema.safeParse({
|
|
||||||
projectId: "p1",
|
|
||||||
name: "Test",
|
|
||||||
quantity: 1,
|
|
||||||
installedPowerPerUnitKw: 1,
|
|
||||||
demandFactor: 1,
|
|
||||||
deviceType: "Irgendwas",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(parsed.success, false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createVerifiedDatabaseBackup,
|
||||||
|
verifyDatabaseFile,
|
||||||
|
} from "../src/db/database-backup.js";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
|
describe("database backup and restore", () => {
|
||||||
|
it("backs up committed WAL data and restores it as an independent database", async () => {
|
||||||
|
const testDirectory = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "leistungsbilanz-db-backup-")
|
||||||
|
);
|
||||||
|
const sourcePath = path.join(testDirectory, "source.db");
|
||||||
|
const backupPath = path.join(testDirectory, "backup.db");
|
||||||
|
const restoredPath = path.join(testDirectory, "restored.db");
|
||||||
|
const source = createDatabaseContext(sourcePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
source.sqlite.pragma("journal_mode = WAL");
|
||||||
|
migrate(source.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
source.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({ id: "project-in-backup", name: "Backup project" })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
assert.equal(fs.existsSync(`${sourcePath}-wal`), true);
|
||||||
|
|
||||||
|
const backupVerification = await createVerifiedDatabaseBackup(
|
||||||
|
sourcePath,
|
||||||
|
backupPath
|
||||||
|
);
|
||||||
|
assert.deepEqual(backupVerification, {
|
||||||
|
integrityCheck: "ok",
|
||||||
|
foreignKeyViolations: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
source.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({ id: "project-after-backup", name: "Later project" })
|
||||||
|
.run();
|
||||||
|
source.close();
|
||||||
|
|
||||||
|
await createVerifiedDatabaseBackup(backupPath, restoredPath);
|
||||||
|
const restored = new Database(restoredPath, {
|
||||||
|
readonly: true,
|
||||||
|
fileMustExist: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const restoredProjects = restored
|
||||||
|
.prepare("SELECT id, name FROM projects ORDER BY id")
|
||||||
|
.all();
|
||||||
|
assert.deepEqual(restoredProjects, [
|
||||||
|
{ id: "project-in-backup", name: "Backup project" },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
restored.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (source.sqlite.open) {
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
fs.rmSync(testDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a corrupt database file", () => {
|
||||||
|
const testDirectory = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "leistungsbilanz-db-verify-")
|
||||||
|
);
|
||||||
|
const corruptPath = path.join(testDirectory, "corrupt.db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(corruptPath, "not a sqlite database");
|
||||||
|
assert.throws(() => verifyDatabaseFile(corruptPath), /not a database/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(testDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,141 +1,245 @@
|
|||||||
|
import path from "node:path";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { db } from "../src/db/client.js";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
|
import { consumers } from "../src/db/schema/consumers.js";
|
||||||
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
|
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
|
||||||
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
|
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
describe("legacy consumer migration repository", () => {
|
function createTestDatabase() {
|
||||||
it("writes circuits, rows, mappings and report in one synchronous transaction", () => {
|
const context = createDatabaseContext(":memory:");
|
||||||
const originalTransaction = db.transaction;
|
migrate(context.db, {
|
||||||
const inserts: Array<{ table: unknown; values: unknown }> = [];
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
let transactionCalls = 0;
|
});
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const [section] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
return { context, circuitListId: board.id, sectionId: section.id };
|
||||||
|
}
|
||||||
|
|
||||||
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => unknown }).transaction =
|
function migrationInput(circuitListId: string, sectionId: string) {
|
||||||
(callback) => {
|
return {
|
||||||
transactionCalls += 1;
|
circuitListId,
|
||||||
const emptySelectChain = {
|
circuits: [
|
||||||
from() {
|
{
|
||||||
return emptySelectChain;
|
circuit: {
|
||||||
},
|
circuitListId,
|
||||||
where() {
|
sectionId,
|
||||||
return emptySelectChain;
|
equipmentIdentifier: "-1F1",
|
||||||
},
|
displayName: "Legacy lighting",
|
||||||
limit() {
|
sortOrder: 10,
|
||||||
return emptySelectChain;
|
},
|
||||||
},
|
deviceRows: [
|
||||||
all() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return callback({
|
|
||||||
select() {
|
|
||||||
return emptySelectChain;
|
|
||||||
},
|
|
||||||
insert(table: unknown) {
|
|
||||||
return {
|
|
||||||
values(values: unknown) {
|
|
||||||
inserts.push({ table, values });
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
return { changes: 1 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repository = new LegacyConsumerMigrationRepository();
|
|
||||||
repository.persistCircuitListMigration({
|
|
||||||
circuitListId: "list-1",
|
|
||||||
circuits: [
|
|
||||||
{
|
{
|
||||||
circuit: {
|
legacyConsumerId: "consumer-1",
|
||||||
circuitListId: "list-1",
|
sortOrder: 10,
|
||||||
sectionId: "section-1",
|
name: "Light 1",
|
||||||
equipmentIdentifier: "-1F1",
|
displayName: "Light 1",
|
||||||
displayName: "Legacy lighting",
|
quantity: 1,
|
||||||
sortOrder: 10,
|
powerPerUnit: 0.1,
|
||||||
},
|
simultaneityFactor: 1,
|
||||||
deviceRows: [
|
},
|
||||||
{
|
{
|
||||||
legacyConsumerId: "consumer-1",
|
legacyConsumerId: "consumer-2",
|
||||||
sortOrder: 10,
|
sortOrder: 20,
|
||||||
name: "Light 1",
|
name: "Light 2",
|
||||||
displayName: "Light 1",
|
displayName: "Light 2",
|
||||||
quantity: 1,
|
quantity: 2,
|
||||||
powerPerUnit: 0.1,
|
powerPerUnit: 0.2,
|
||||||
simultaneityFactor: 1,
|
simultaneityFactor: 0.8,
|
||||||
},
|
|
||||||
{
|
|
||||||
legacyConsumerId: "consumer-2",
|
|
||||||
sortOrder: 20,
|
|
||||||
name: "Light 2",
|
|
||||||
displayName: "Light 2",
|
|
||||||
quantity: 2,
|
|
||||||
powerPerUnit: 0.2,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
report: {
|
},
|
||||||
legacyConsumerCount: 2,
|
],
|
||||||
createdCircuitCount: 1,
|
report: {
|
||||||
createdDeviceRowCount: 2,
|
legacyConsumerCount: 2,
|
||||||
duplicateGroupedCount: 1,
|
createdCircuitCount: 1,
|
||||||
generatedIdentifierCount: 0,
|
createdDeviceRowCount: 2,
|
||||||
unassignedRowCount: 0,
|
duplicateGroupedCount: 1,
|
||||||
warningsJson: "[]",
|
generatedIdentifierCount: 0,
|
||||||
generatedIdentifiersJson: "[]",
|
unassignedRowCount: 0,
|
||||||
duplicateGroupsJson: "[{\"normalizedCircuitNumber\":\"-1F1\",\"count\":2}]",
|
warningsJson: "[]",
|
||||||
},
|
generatedIdentifiersJson: "[]",
|
||||||
});
|
duplicateGroupsJson:
|
||||||
|
"[{\"normalizedCircuitNumber\":\"-1F1\",\"count\":2}]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoMigrationWrites(context: DatabaseContext) {
|
||||||
|
assert.equal(context.db.select().from(circuits).all().length, 0);
|
||||||
|
assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(legacyConsumerCircuitMigrations).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(legacyConsumerMigrationReports).all().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("legacy consumer migration repository", () => {
|
||||||
|
it("reports every consumer without a completed migration mapping", async () => {
|
||||||
|
const { context, circuitListId, sectionId } = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.db
|
||||||
|
.insert(consumers)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "consumer-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
circuitListId,
|
||||||
|
name: "Light 1",
|
||||||
|
quantity: 1,
|
||||||
|
installedPowerPerUnitKw: 0.1,
|
||||||
|
demandFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "consumer-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
circuitListId: null,
|
||||||
|
name: "Light 2",
|
||||||
|
quantity: 1,
|
||||||
|
installedPowerPerUnitKw: 0.2,
|
||||||
|
demandFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const repository = new LegacyConsumerMigrationRepository(context.db);
|
||||||
|
|
||||||
assert.equal(transactionCalls, 1);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
inserts.map((entry) => entry.table),
|
(await repository.listSourceConsumersByCircuitList(circuitListId)).map(
|
||||||
|
(consumer) => consumer.id
|
||||||
|
),
|
||||||
|
["consumer-1"]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await repository.listUnmigratedConsumers()).sort((left, right) =>
|
||||||
|
left.id.localeCompare(right.id)
|
||||||
|
),
|
||||||
[
|
[
|
||||||
circuits,
|
{
|
||||||
circuitDeviceRows,
|
id: "consumer-1",
|
||||||
legacyConsumerCircuitMigrations,
|
projectId: "project-1",
|
||||||
circuitDeviceRows,
|
circuitListId,
|
||||||
legacyConsumerCircuitMigrations,
|
},
|
||||||
legacyConsumerMigrationReports,
|
{
|
||||||
|
id: "consumer-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
circuitListId: null,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const circuit = inserts[0].values as { id: string };
|
repository.persistCircuitListMigration(
|
||||||
const firstRow = inserts[1].values as { id: string; circuitId: string; legacyConsumerId: string };
|
migrationInput(circuitListId, sectionId)
|
||||||
const firstMapping = inserts[2].values as {
|
|
||||||
consumerId: string;
|
|
||||||
circuitId: string;
|
|
||||||
circuitDeviceRowId: string;
|
|
||||||
circuitListId: string;
|
|
||||||
};
|
|
||||||
assert.equal(firstRow.circuitId, circuit.id);
|
|
||||||
assert.equal(firstRow.legacyConsumerId, "consumer-1");
|
|
||||||
assert.deepEqual(
|
|
||||||
{
|
|
||||||
consumerId: firstMapping.consumerId,
|
|
||||||
circuitId: firstMapping.circuitId,
|
|
||||||
circuitDeviceRowId: firstMapping.circuitDeviceRowId,
|
|
||||||
circuitListId: firstMapping.circuitListId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
consumerId: "consumer-1",
|
|
||||||
circuitId: circuit.id,
|
|
||||||
circuitDeviceRowId: firstRow.id,
|
|
||||||
circuitListId: "list-1",
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(await repository.listUnmigratedConsumers(), []);
|
||||||
} finally {
|
} finally {
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits circuits, rows, mappings and report together", () => {
|
||||||
|
const { context, circuitListId, sectionId } = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new LegacyConsumerMigrationRepository(context.db);
|
||||||
|
|
||||||
|
repository.persistCircuitListMigration(
|
||||||
|
migrationInput(circuitListId, sectionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistedCircuits = context.db.select().from(circuits).all();
|
||||||
|
const persistedRows = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.sortOrder - right.sortOrder);
|
||||||
|
const persistedMappings = context.db
|
||||||
|
.select()
|
||||||
|
.from(legacyConsumerCircuitMigrations)
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.consumerId.localeCompare(right.consumerId));
|
||||||
|
const persistedReports = context.db
|
||||||
|
.select()
|
||||||
|
.from(legacyConsumerMigrationReports)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
assert.equal(persistedCircuits.length, 1);
|
||||||
|
assert.equal(persistedRows.length, 2);
|
||||||
|
assert.equal(persistedMappings.length, 2);
|
||||||
|
assert.equal(persistedReports.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
persistedMappings.map((mapping) => [
|
||||||
|
mapping.consumerId,
|
||||||
|
mapping.circuitId,
|
||||||
|
mapping.circuitDeviceRowId,
|
||||||
|
mapping.circuitListId,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"consumer-1",
|
||||||
|
persistedCircuits[0].id,
|
||||||
|
persistedRows[0].id,
|
||||||
|
circuitListId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"consumer-2",
|
||||||
|
persistedCircuits[0].id,
|
||||||
|
persistedRows[1].id,
|
||||||
|
circuitListId,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(persistedReports[0].createdCircuitCount, 1);
|
||||||
|
assert.equal(persistedReports[0].createdDeviceRowCount, 2);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back all migrated entities when the final report write fails", () => {
|
||||||
|
const { context, circuitListId, sectionId } = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_migration_report
|
||||||
|
BEFORE INSERT ON legacy_consumer_migration_reports
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced migration report failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new LegacyConsumerMigrationRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.persistCircuitListMigration(
|
||||||
|
migrationInput(circuitListId, sectionId)
|
||||||
|
),
|
||||||
|
/forced migration report failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assertNoMigrationWrites(context);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
const vitest_1 = require("vitest");
|
|
||||||
const power_calculation_js_1 = require("../src/domain/calculations/power-calculation.js");
|
|
||||||
(0, vitest_1.describe)("power calculation", () => {
|
|
||||||
(0, vitest_1.it)("calculates installed power", () => {
|
|
||||||
const result = (0, power_calculation_js_1.calculateInstalledPowerKw)({
|
|
||||||
quantity: 4,
|
|
||||||
installedPowerPerUnitKw: 1.2,
|
|
||||||
demandFactor: 0.8,
|
|
||||||
});
|
|
||||||
(0, vitest_1.expect)(result).toBeCloseTo(4.8);
|
|
||||||
});
|
|
||||||
(0, vitest_1.it)("calculates demand power", () => {
|
|
||||||
const result = (0, power_calculation_js_1.calculateDemandPowerKw)({
|
|
||||||
quantity: 4,
|
|
||||||
installedPowerPerUnitKw: 1.2,
|
|
||||||
demandFactor: 0.8,
|
|
||||||
});
|
|
||||||
(0, vitest_1.expect)(result).toBeCloseTo(3.84);
|
|
||||||
});
|
|
||||||
(0, vitest_1.it)("handles zero quantity", () => {
|
|
||||||
const result = (0, power_calculation_js_1.calculateInstalledPowerKw)({
|
|
||||||
quantity: 0,
|
|
||||||
installedPowerPerUnitKw: 3,
|
|
||||||
demandFactor: 0.9,
|
|
||||||
});
|
|
||||||
(0, vitest_1.expect)(result).toBe(0);
|
|
||||||
});
|
|
||||||
(0, vitest_1.it)("handles zero demand factor", () => {
|
|
||||||
const result = (0, power_calculation_js_1.calculateDemandPowerKw)({
|
|
||||||
quantity: 5,
|
|
||||||
installedPowerPerUnitKw: 2,
|
|
||||||
demandFactor: 0,
|
|
||||||
});
|
|
||||||
(0, vitest_1.expect)(result).toBe(0);
|
|
||||||
});
|
|
||||||
(0, vitest_1.it)("calculates single-phase current", () => {
|
|
||||||
const current = (0, power_calculation_js_1.calculateCurrentA)({
|
|
||||||
demandPowerKw: 2.3,
|
|
||||||
voltageV: 230,
|
|
||||||
phaseCount: 1,
|
|
||||||
powerFactor: 0.95,
|
|
||||||
});
|
|
||||||
(0, vitest_1.expect)(current).toBeCloseTo(10.53, 2);
|
|
||||||
});
|
|
||||||
(0, vitest_1.it)("calculates three-phase current", () => {
|
|
||||||
const current = (0, power_calculation_js_1.calculateCurrentA)({
|
|
||||||
demandPowerKw: 9.5,
|
|
||||||
voltageV: 400,
|
|
||||||
phaseCount: 3,
|
|
||||||
powerFactor: 0.9,
|
|
||||||
});
|
|
||||||
(0, vitest_1.expect)(current).toBeCloseTo(15.23, 2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, it } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import {
|
|
||||||
calculateCurrentA,
|
|
||||||
calculateDemandPowerKw,
|
|
||||||
calculateInstalledPowerKw,
|
|
||||||
} from "../src/domain/calculations/power-calculation.js";
|
|
||||||
|
|
||||||
describe("power calculation", () => {
|
|
||||||
it("calculates installed power", () => {
|
|
||||||
const result = calculateInstalledPowerKw({
|
|
||||||
quantity: 4,
|
|
||||||
installedPowerPerUnitKw: 1.2,
|
|
||||||
demandFactor: 0.8,
|
|
||||||
});
|
|
||||||
assert.ok(Math.abs(result - 4.8) < 0.00001);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calculates demand power", () => {
|
|
||||||
const result = calculateDemandPowerKw({
|
|
||||||
quantity: 4,
|
|
||||||
installedPowerPerUnitKw: 1.2,
|
|
||||||
demandFactor: 0.8,
|
|
||||||
});
|
|
||||||
assert.ok(Math.abs(result - 3.84) < 0.00001);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles zero quantity", () => {
|
|
||||||
const result = calculateInstalledPowerKw({
|
|
||||||
quantity: 0,
|
|
||||||
installedPowerPerUnitKw: 3,
|
|
||||||
demandFactor: 0.9,
|
|
||||||
});
|
|
||||||
assert.equal(result, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles zero demand factor", () => {
|
|
||||||
const result = calculateDemandPowerKw({
|
|
||||||
quantity: 5,
|
|
||||||
installedPowerPerUnitKw: 2,
|
|
||||||
demandFactor: 0,
|
|
||||||
});
|
|
||||||
assert.equal(result, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calculates single-phase current", () => {
|
|
||||||
const current = calculateCurrentA({
|
|
||||||
demandPowerKw: 2.3,
|
|
||||||
voltageV: 230,
|
|
||||||
phaseCount: 1,
|
|
||||||
powerFactor: 0.95,
|
|
||||||
});
|
|
||||||
assert.ok(Math.abs(current - 10.53) < 0.02);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calculates three-phase current", () => {
|
|
||||||
const current = calculateCurrentA({
|
|
||||||
demandPowerKw: 9.5,
|
|
||||||
voltageV: 400,
|
|
||||||
phaseCount: 3,
|
|
||||||
powerFactor: 0.9,
|
|
||||||
});
|
|
||||||
assert.ok(Math.abs(current - 15.23) < 0.02);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import {
|
||||||
|
createDatabaseContext,
|
||||||
|
type DatabaseContext,
|
||||||
|
} from "../src/db/database-context.js";
|
||||||
|
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
|
||||||
|
import { ProjectDeviceRowSyncRepository } from "../src/db/repositories/project-device-row-sync.repository.js";
|
||||||
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.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 { projects } from "../src/db/schema/projects.js";
|
||||||
|
|
||||||
|
function createTestDatabase(): DatabaseContext {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, {
|
||||||
|
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||||
|
});
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run();
|
||||||
|
const board = new DistributionBoardRepository(
|
||||||
|
context.db
|
||||||
|
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||||
|
const [section] = context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitSections)
|
||||||
|
.where(eq(circuitSections.circuitListId, board.id))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
context.db
|
||||||
|
.insert(circuits)
|
||||||
|
.values({
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: board.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Beleuchtung",
|
||||||
|
sortOrder: 10,
|
||||||
|
isReserve: 0,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(projectDevices)
|
||||||
|
.values({
|
||||||
|
id: "project-device-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName: "Office lighting",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
installedPowerPerUnitKw: 0.04,
|
||||||
|
demandFactor: 0.8,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
context.db
|
||||||
|
.insert(circuitDeviceRows)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "row-1",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Old luminaire",
|
||||||
|
displayName: "Local row 1",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-2",
|
||||||
|
circuitId: "circuit-1",
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
sortOrder: 20,
|
||||||
|
name: "Old luminaire",
|
||||||
|
displayName: "Local row 2",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.03,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function synchronizedValues(displayName: string) {
|
||||||
|
return {
|
||||||
|
linkedProjectDeviceId: "project-device-1",
|
||||||
|
name: "Luminaire",
|
||||||
|
displayName,
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 4,
|
||||||
|
powerPerUnit: 0.04,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
remark: "DALI",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRows(context: DatabaseContext) {
|
||||||
|
return context.db
|
||||||
|
.select()
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.all()
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project-device row sync repository", () => {
|
||||||
|
it("commits synchronized values for all selected linked rows", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
||||||
|
|
||||||
|
repository.updateLinkedRows("project-device-1", [
|
||||||
|
{ rowId: "row-1", input: synchronizedValues("Synced row 1") },
|
||||||
|
{ rowId: "row-2", input: synchronizedValues("Synced row 2") },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listRows(context).map((row) => [
|
||||||
|
row.displayName,
|
||||||
|
row.quantity,
|
||||||
|
row.powerPerUnit,
|
||||||
|
row.simultaneityFactor,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
["Synced row 1", 4, 0.04, 0.8],
|
||||||
|
["Synced row 2", 4, 0.04, 0.8],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back earlier synchronized values when a later row update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_second_sync_update
|
||||||
|
BEFORE UPDATE ON circuit_device_rows
|
||||||
|
WHEN OLD.id = 'row-2' AND NEW.display_name = 'Synced'
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced sync failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.updateLinkedRows("project-device-1", [
|
||||||
|
{ rowId: "row-1", input: synchronizedValues("Synced") },
|
||||||
|
{ rowId: "row-2", input: synchronizedValues("Synced") },
|
||||||
|
]),
|
||||||
|
/forced sync failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listRows(context).map((row) => [row.displayName, row.quantity]),
|
||||||
|
[
|
||||||
|
["Local row 1", 1],
|
||||||
|
["Local row 2", 2],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disconnects all selected rows without changing local values", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
||||||
|
|
||||||
|
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listRows(context).map((row) => [
|
||||||
|
row.linkedProjectDeviceId,
|
||||||
|
row.displayName,
|
||||||
|
row.quantity,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[null, "Local row 1", 1],
|
||||||
|
[null, "Local row 2", 2],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back earlier disconnects when a later row update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_second_disconnect
|
||||||
|
BEFORE UPDATE ON circuit_device_rows
|
||||||
|
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id IS NULL
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced disconnect failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]),
|
||||||
|
/forced disconnect failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listRows(context).map((row) => row.linkedProjectDeviceId),
|
||||||
|
["project-device-1", "project-device-1"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects all selected disconnected rows", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
|
||||||
|
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
||||||
|
|
||||||
|
repository.reconnectRows("project-device-1", ["row-1", "row-2"]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listRows(context).map((row) => row.linkedProjectDeviceId),
|
||||||
|
["project-device-1", "project-device-1"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back earlier reconnects when a later row update fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_second_reconnect
|
||||||
|
BEFORE UPDATE ON circuit_device_rows
|
||||||
|
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id = 'project-device-1'
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced reconnect failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ProjectDeviceRowSyncRepository(context.db);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => repository.reconnectRows("project-device-1", ["row-1", "row-2"]),
|
||||||
|
/forced reconnect failure/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
listRows(context).map((row) => row.linkedProjectDeviceId),
|
||||||
|
[null, null]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
|
import { ProjectDeviceSyncService } from "../src/domain/services/project-device-sync.service.js";
|
||||||
import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js";
|
|
||||||
import { db } from "../src/db/client.js";
|
|
||||||
|
|
||||||
function projectDevice() {
|
function projectDevice() {
|
||||||
return {
|
return {
|
||||||
@@ -72,14 +70,16 @@ function createService() {
|
|||||||
async listLinkStatesByIds() {
|
async listLinkStatesByIds() {
|
||||||
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
|
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
|
||||||
},
|
},
|
||||||
updateLinkedRowsTransactional(_projectDeviceId, changes) {
|
} as never,
|
||||||
|
deviceRowSyncStore: {
|
||||||
|
updateLinkedRows(_projectDeviceId, changes) {
|
||||||
transactionCalls += 1;
|
transactionCalls += 1;
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
updates.push({ rowId: change.rowId, ...change.input });
|
updates.push({ rowId: change.rowId, ...change.input });
|
||||||
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
|
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
disconnectLinkedRowsTransactional(_projectDeviceId, rowIds) {
|
disconnectLinkedRows(_projectDeviceId, rowIds) {
|
||||||
transactionCalls += 1;
|
transactionCalls += 1;
|
||||||
for (const rowId of rowIds) {
|
for (const rowId of rowIds) {
|
||||||
const row = rows.find((entry) => entry.id === rowId)!;
|
const row = rows.find((entry) => entry.id === rowId)!;
|
||||||
@@ -87,7 +87,7 @@ function createService() {
|
|||||||
Object.assign(row, { linkedProjectDeviceId: null });
|
Object.assign(row, { linkedProjectDeviceId: null });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reconnectRowsTransactional(projectDeviceId, rowIds) {
|
reconnectRows(projectDeviceId, rowIds) {
|
||||||
transactionCalls += 1;
|
transactionCalls += 1;
|
||||||
for (const rowId of rowIds) {
|
for (const rowId of rowIds) {
|
||||||
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
|
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
|
||||||
@@ -160,54 +160,4 @@ describe("project device synchronization", () => {
|
|||||||
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
|
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("executes multi-row updates inside one synchronous transaction", () => {
|
|
||||||
const repository = new CircuitDeviceRowRepository();
|
|
||||||
const originalTransaction = (db as unknown as { transaction: unknown }).transaction;
|
|
||||||
let transactionCalls = 0;
|
|
||||||
let updateCalls = 0;
|
|
||||||
let returnedPromise = false;
|
|
||||||
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => void }).transaction = (callback) => {
|
|
||||||
transactionCalls += 1;
|
|
||||||
const fakeTx = {
|
|
||||||
update() {
|
|
||||||
return {
|
|
||||||
set() {
|
|
||||||
return {
|
|
||||||
where() {
|
|
||||||
return {
|
|
||||||
run() {
|
|
||||||
updateCalls += 1;
|
|
||||||
return { changes: 1 };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = callback(fakeTx);
|
|
||||||
returnedPromise = Boolean(result && typeof (result as Promise<unknown>).then === "function");
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input = {
|
|
||||||
linkedProjectDeviceId: "pd1",
|
|
||||||
name: "Luminaire",
|
|
||||||
displayName: "Office lighting",
|
|
||||||
quantity: 1,
|
|
||||||
powerPerUnit: 0.1,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
};
|
|
||||||
repository.updateLinkedRowsTransactional("pd1", [
|
|
||||||
{ rowId: "r1", input },
|
|
||||||
{ rowId: "r2", input },
|
|
||||||
]);
|
|
||||||
assert.equal(transactionCalls, 1);
|
|
||||||
assert.equal(updateCalls, 2);
|
|
||||||
assert.equal(returnedPromise, false);
|
|
||||||
} finally {
|
|
||||||
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user