diff --git a/AGENTS.md b/AGENTS.md index 54f2028..f2ffd50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,16 @@ Keyboard behavior: Support Ctrl+Plus and Ctrl+Shift+Plus for insertion. +## Bootstrap / Styling Rule + +Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals. + +Do not use Bootstrap as the core table/grid framework for the circuit list editor. + +The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior. + +Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing. + ## Drag-and-Drop Rules Dragging from the circuit identifier / circuit handle moves the whole circuit. diff --git a/README.md b/README.md index f9d378b..01df2b0 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,16 @@ Die folgende Liste fasst die zentralen Anforderungen zusammen und zeigt den aktu - `npm run db:generate` – Migrationen generieren - `npm run db:migrate` – Migrationen ausführen +### Circuit-First lokale Migration + +- `npm run db:backup` – lokale SQLite sichern +- `npm run db:migrate` – pending Migrationen ausführen +- `npm run db:verify:circuit-schema` – Circuit-First Tabellenprüfung +- `npm run db:backfill:sections` – Default-Sections für bestehende Listen anlegen +- `npm run db:migrate:legacy-consumers` – Legacy-Consumers explizit in Circuit-First überführen + +Siehe auch: `docs/local-db-circuit-first-migration.md` + ## Ergänzende Dokumente - Anforderungen (Quelle): [docs/electrical-load-balance-requirements-context-dump.md](docs/electrical-load-balance-requirements-context-dump.md) diff --git a/data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db b/data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db new file mode 100644 index 0000000..c803dba Binary files /dev/null and b/data/backups/leistungsbilanz-2026-05-03T19-47-12-976Z.db differ diff --git a/docs/local-db-circuit-first-migration.md b/docs/local-db-circuit-first-migration.md new file mode 100644 index 0000000..72bd9cb --- /dev/null +++ b/docs/local-db-circuit-first-migration.md @@ -0,0 +1,142 @@ +# Local DB Migration: Circuit-First Schema + +This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in `src/db/migrations`. + +## Safe command order + +1. Backup local DB (required before schema/data migration) + +```bash +npm run db:backup +``` + +2. Apply pending schema migrations (includes `0008_circuit_first_model`) + +```bash +npm run db:migrate +``` + +3. Verify circuit-first tables exist + +```bash +npm run db:verify:circuit-schema +``` + +4. Backfill default sections for existing `circuit_lists` + +```bash +npm run db:backfill:sections +``` + +5. Run legacy consumer -> circuit/device-row migration explicitly + +```bash +npm run db:migrate:legacy-consumers +``` + +## Verification SQL + +Run these against `data/leistungsbilanz.db`: + +```sql +SELECT name +FROM sqlite_master +WHERE type = 'table' + AND name IN ( + 'circuit_sections', + 'circuits', + 'circuit_device_rows', + 'legacy_consumer_circuit_migrations', + 'legacy_consumer_migration_reports' + ) +ORDER BY name; +``` + +```sql +SELECT circuit_list_id, key, prefix, sort_order +FROM circuit_sections +ORDER BY circuit_list_id, sort_order; +``` + +```sql +SELECT circuit_list_id, COUNT(*) AS circuits +FROM circuits +GROUP BY circuit_list_id; +``` + +```sql +SELECT c.circuit_list_id, COUNT(r.id) AS device_rows +FROM circuits c +LEFT JOIN circuit_device_rows r ON r.circuit_id = c.id +GROUP BY c.circuit_list_id; +``` + +## API verification + +After the steps above: + +`GET /api/projects/:projectId/circuit-lists/:circuitListId/tree` + +Expected response shape: + +```json +{ + "circuitListId": "string", + "sections": [ + { + "id": "string", + "key": "lighting|single_phase|three_phase|unassigned|...", + "displayName": "string", + "prefix": "-1F|-2F|-3F|-UF|...", + "sortOrder": 10, + "circuits": [ + { + "id": "string", + "equipmentIdentifier": "-2F1", + "displayName": "string", + "sortOrder": 10, + "isReserve": false, + "circuitTotalPower": 1.23, + "deviceRows": [ + { + "id": "string", + "displayName": "string", + "quantity": 1, + "powerPerUnit": 0.3, + "simultaneityFactor": 1, + "rowTotalPower": 0.3 + } + ] + } + ] + } + ] +} +``` + +If migrations were not applied, endpoint may return an empty fallback with a warning. + +## Dev-only visual test helper (multi-device circuit) + +Add one extra manual device row to an existing circuit: + +```bash +npm run dev:add-manual-circuit-row -- +``` + +Default inserted values: +- `name`: `Test sub device` +- `displayName`: `Beleuchtung WC` +- `phaseType`: `single_phase` +- `quantity`: `1` +- `powerPerUnit`: `0.05` +- `simultaneityFactor`: `1` +- `cosPhi`: `1` + +The script prints the created row id. + +Delete the test row again: + +```bash +npm run dev:delete-circuit-row -- +``` diff --git a/package.json b/package.json index cf84033..e957f0f 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,16 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "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/legacy-consumer-migration-planner.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/legacy-consumer-migration-planner.test.ts", + "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/legacy-consumer-migration-planner.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", + "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/legacy-consumer-migration-planner.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", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate" + "db:migrate": "drizzle-kit migrate", + "db:backup": "node scripts/db-backup.js", + "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", + "db:backfill:sections": "tsx scripts/db-backfill-sections.ts", + "db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts", + "dev:add-manual-circuit-row": "tsx scripts/dev-add-manual-circuit-row.ts", + "dev:delete-circuit-row": "tsx scripts/dev-delete-circuit-row.ts" }, "keywords": [], "author": "", diff --git a/scripts/db-backfill-sections.ts b/scripts/db-backfill-sections.ts new file mode 100644 index 0000000..6526359 --- /dev/null +++ b/scripts/db-backfill-sections.ts @@ -0,0 +1,25 @@ +import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js"; +import { CircuitSectionRepository } from "../src/db/repositories/circuit-section.repository.js"; +import { ProjectRepository } from "../src/db/repositories/project.repository.js"; + +const projectRepository = new ProjectRepository(); +const circuitListRepository = new CircuitListRepository(); +const circuitSectionRepository = new CircuitSectionRepository(); + +async function run() { + const projects = await projectRepository.list(); + let totalLists = 0; + for (const project of projects) { + const lists = await circuitListRepository.listByProject(project.id); + for (const list of lists) { + await circuitSectionRepository.createDefaults(list.id); + totalLists += 1; + } + } + console.log(`Section backfill done for ${totalLists} circuit list(s).`); +} + +run().catch((error) => { + console.error("Section backfill failed:", error); + process.exit(1); +}); diff --git a/scripts/db-backup.js b/scripts/db-backup.js new file mode 100644 index 0000000..2545473 --- /dev/null +++ b/scripts/db-backup.js @@ -0,0 +1,19 @@ +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}`); diff --git a/scripts/db-migrate-legacy-consumers.ts b/scripts/db-migrate-legacy-consumers.ts new file mode 100644 index 0000000..10662e6 --- /dev/null +++ b/scripts/db-migrate-legacy-consumers.ts @@ -0,0 +1,36 @@ +import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js"; +import { ProjectRepository } from "../src/db/repositories/project.repository.js"; +import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js"; + +const projectRepository = new ProjectRepository(); +const circuitListRepository = new CircuitListRepository(); +const migrationService = new LegacyConsumerMigrationService(); + +async function run() { + const projects = await projectRepository.list(); + const reports = []; + + for (const project of projects) { + const lists = await circuitListRepository.listByProject(project.id); + for (const list of lists) { + const report = await migrationService.migrateCircuitList(project.id, list.id); + reports.push({ + projectId: project.id, + circuitListId: list.id, + legacyConsumerCount: report.legacyConsumerCount, + createdCircuitCount: report.createdCircuitCount, + createdDeviceRowCount: report.createdDeviceRowCount, + generatedIdentifiers: report.generatedIdentifiers.length, + unassignedRows: report.unassignedRows.length, + }); + } + } + + console.log("Legacy consumer migration summary:"); + console.table(reports); +} + +run().catch((error) => { + console.error("Legacy consumer migration failed:", error); + process.exit(1); +}); diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js new file mode 100644 index 0000000..1a3523c --- /dev/null +++ b/scripts/db-verify-circuit-schema.js @@ -0,0 +1,31 @@ +const Database = require("better-sqlite3"); +const path = require("node:path"); + +const dbPath = path.resolve("data", "leistungsbilanz.db"); +const db = new Database(dbPath, { readonly: true }); + +const requiredTables = [ + "circuit_sections", + "circuits", + "circuit_device_rows", + "legacy_consumer_circuit_migrations", + "legacy_consumer_migration_reports", +]; + +const rows = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)") + .all(...requiredTables); + +const existing = new Set(rows.map((row) => row.name)); +const missing = requiredTables.filter((name) => !existing.has(name)); + +console.log("Database:", dbPath); +console.log("Required tables:", requiredTables.join(", ")); +console.log("Existing tables:", [...existing].join(", ") || "(none)"); + +if (missing.length > 0) { + console.error("Missing tables:", missing.join(", ")); + process.exit(1); +} + +console.log("Circuit-first schema verification passed."); diff --git a/scripts/dev-add-manual-circuit-row.ts b/scripts/dev-add-manual-circuit-row.ts new file mode 100644 index 0000000..a6b0299 --- /dev/null +++ b/scripts/dev-add-manual-circuit-row.ts @@ -0,0 +1,40 @@ +import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js"; +import { CircuitRepository } from "../src/db/repositories/circuit.repository.js"; + +async function run() { + const circuitId = process.argv[2]; + if (!circuitId) { + console.error("Usage: npm run dev:add-manual-circuit-row -- "); + process.exit(1); + } + + const circuitRepository = new CircuitRepository(); + const rowRepository = new CircuitDeviceRowRepository(); + + const circuit = await circuitRepository.findById(circuitId); + if (!circuit) { + console.error(`Circuit not found: ${circuitId}`); + process.exit(1); + } + + const rowCount = await rowRepository.countByCircuit(circuitId); + const createdRowId = await rowRepository.create({ + circuitId, + sortOrder: (rowCount + 1) * 10, + name: "Test sub device", + displayName: "Beleuchtung WC", + phaseType: "single_phase", + quantity: 1, + powerPerUnit: 0.05, + simultaneityFactor: 1, + cosPhi: 1, + }); + + console.log(`Created test row id: ${createdRowId}`); +} + +run().catch((error) => { + console.error("Failed to create test row:", error); + process.exit(1); +}); + diff --git a/scripts/dev-delete-circuit-row.ts b/scripts/dev-delete-circuit-row.ts new file mode 100644 index 0000000..3793128 --- /dev/null +++ b/scripts/dev-delete-circuit-row.ts @@ -0,0 +1,25 @@ +import { CircuitDeviceRowRepository } from "../src/db/repositories/circuit-device-row.repository.js"; + +async function run() { + const rowId = process.argv[2]; + if (!rowId) { + console.error("Usage: npm run dev:delete-circuit-row -- "); + process.exit(1); + } + + const rowRepository = new CircuitDeviceRowRepository(); + const row = await rowRepository.findById(rowId); + if (!row) { + console.error(`Row not found: ${rowId}`); + process.exit(1); + } + + await rowRepository.delete(rowId); + console.log(`Deleted row id: ${rowId}`); +} + +run().catch((error) => { + console.error("Failed to delete row:", error); + process.exit(1); +}); + diff --git a/src/app/globals.css b/src/app/globals.css index 4b7f0ce..dbc80ca 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -10,3 +10,32 @@ body { .table td select.form-select-sm { min-width: 8rem; } + +.circuit-tree-table .section-row td { + background: #e9eef8; + border-top: 2px solid #c6d3ea; + font-weight: 600; +} + +.circuit-tree-table .summary-row td { + background: #f5f8fd; + font-weight: 600; +} + +.circuit-tree-table .device-row td { + background: #ffffff; +} + +.circuit-tree-table .reserve-row td { + background: #fff8e8; +} + +.circuit-tree-table .placeholder-row td { + background: #f7f7f7; + color: #6c757d; + font-style: italic; +} + +.circuit-tree-table .indented-cell { + padding-left: 1.5rem; +} diff --git a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree/page.tsx b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree/page.tsx new file mode 100644 index 0000000..5426907 --- /dev/null +++ b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { CircuitTreePreview } from "../../../../../../frontend/components/circuit-tree-preview"; + +export default function CircuitTreePreviewPage() { + const params = useParams<{ projectId: string; circuitListId: string }>(); + const projectId = params.projectId; + const circuitListId = params.circuitListId; + + return ( +
+
+
+

Circuit Tree Preview

+

+ Read-only preview of section blocks, circuits and device rows. +

+
+ + Back to legacy editor + +
+ + +
+ ); +} + diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 34ed598..9bd2e6e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1777680000000, "tag": "0007_consumer_device_link", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1777800000000, + "tag": "0008_circuit_first_model", + "breakpoints": true } ] } diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index d36bf7a..38bdbc9 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -1,9 +1,19 @@ import crypto from "node:crypto"; -import { asc, inArray } from "drizzle-orm"; +import { asc, eq, inArray } from "drizzle-orm"; import { db } from "../client.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; export class CircuitDeviceRowRepository { + async findById(rowId: string) { + const [row] = await db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)).limit(1); + return row ?? null; + } + + async countByCircuit(circuitId: string) { + const rows = await db.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows).where(eq(circuitDeviceRows.circuitId, circuitId)); + return rows.length; + } + async listByCircuitList(circuitIds: string[]) { if (!circuitIds.length) { return []; @@ -26,6 +36,7 @@ export class CircuitDeviceRowRepository { connectionKind?: string; costGroup?: string; category?: string; + level?: string; roomId?: string; roomNumberSnapshot?: string; roomNameSnapshot?: string; @@ -34,9 +45,11 @@ export class CircuitDeviceRowRepository { simultaneityFactor: number; cosPhi?: number; remark?: string; + overriddenFields?: string; }) { + const id = crypto.randomUUID(); await db.insert(circuitDeviceRows).values({ - id: crypto.randomUUID(), + id, circuitId: input.circuitId, linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, legacyConsumerId: input.legacyConsumerId ?? null, @@ -47,6 +60,7 @@ export class CircuitDeviceRowRepository { connectionKind: input.connectionKind ?? null, costGroup: input.costGroup ?? null, category: input.category ?? null, + level: input.level ?? null, roomId: input.roomId ?? null, roomNumberSnapshot: input.roomNumberSnapshot ?? null, roomNameSnapshot: input.roomNameSnapshot ?? null, @@ -55,7 +69,58 @@ export class CircuitDeviceRowRepository { simultaneityFactor: input.simultaneityFactor, cosPhi: input.cosPhi ?? null, remark: input.remark ?? null, - overriddenFields: null, + overriddenFields: input.overriddenFields ?? null, }); + return id; + } + + async update( + rowId: string, + input: { + 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; + } + ) { + await db + .update(circuitDeviceRows) + .set({ + linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, + name: input.name, + displayName: input.displayName, + phaseType: input.phaseType ?? null, + connectionKind: input.connectionKind ?? null, + costGroup: input.costGroup ?? null, + category: input.category ?? null, + level: input.level ?? null, + roomId: input.roomId ?? null, + roomNumberSnapshot: input.roomNumberSnapshot ?? null, + roomNameSnapshot: input.roomNameSnapshot ?? null, + quantity: input.quantity, + powerPerUnit: input.powerPerUnit, + simultaneityFactor: input.simultaneityFactor, + cosPhi: input.cosPhi ?? null, + remark: input.remark ?? null, + overriddenFields: input.overriddenFields ?? null, + }) + .where(eq(circuitDeviceRows.id, rowId)); + } + + async delete(rowId: string) { + await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); } } diff --git a/src/db/repositories/circuit-list.repository.ts b/src/db/repositories/circuit-list.repository.ts index 455280a..5ed3788 100644 --- a/src/db/repositories/circuit-list.repository.ts +++ b/src/db/repositories/circuit-list.repository.ts @@ -53,4 +53,13 @@ export class CircuitListRepository { .limit(1); return row ?? null; } + + async findByIdByListIdOnly(circuitListId: string) { + const [row] = await db + .select() + .from(circuitLists) + .where(eq(circuitLists.id, circuitListId)) + .limit(1); + return row ?? null; + } } diff --git a/src/db/repositories/circuit-section.repository.ts b/src/db/repositories/circuit-section.repository.ts index b17425a..2d672dc 100644 --- a/src/db/repositories/circuit-section.repository.ts +++ b/src/db/repositories/circuit-section.repository.ts @@ -4,6 +4,11 @@ import { db } from "../client.js"; import { circuitSections } from "../schema/circuit-sections.js"; export class CircuitSectionRepository { + async findById(sectionId: string) { + const [row] = await db.select().from(circuitSections).where(eq(circuitSections.id, sectionId)).limit(1); + return row ?? null; + } + async listByCircuitList(circuitListId: string) { return db .select() @@ -39,4 +44,3 @@ export class CircuitSectionRepository { } } } - diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index 450244b..99d3bc5 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -1,9 +1,14 @@ import crypto from "node:crypto"; -import { asc, eq } from "drizzle-orm"; +import { and, asc, eq, ne } from "drizzle-orm"; import { db } from "../client.js"; import { circuits } from "../schema/circuits.js"; export class CircuitRepository { + async findById(circuitId: string) { + const [row] = await db.select().from(circuits).where(eq(circuits.id, circuitId)).limit(1); + return row ?? null; + } + async listByCircuitList(circuitListId: string) { return db .select() @@ -26,6 +31,10 @@ export class CircuitRepository { cableLength?: number; voltage?: number; remark?: string; + rcdAssignment?: string; + terminalDesignation?: string; + status?: string; + isReserve?: boolean; }) { const id = crypto.randomUUID(); await db.insert(circuits).values({ @@ -41,10 +50,86 @@ export class CircuitRepository { cableType: input.cableType ?? null, cableCrossSection: input.cableCrossSection ?? null, cableLength: input.cableLength ?? null, + rcdAssignment: input.rcdAssignment ?? null, + terminalDesignation: input.terminalDesignation ?? null, voltage: input.voltage ?? null, + status: input.status ?? null, + isReserve: input.isReserve ? 1 : 0, remark: input.remark ?? null, }); return id; } -} + async update( + circuitId: string, + input: { + sectionId: string; + equipmentIdentifier: string; + displayName?: string; + sortOrder: number; + protectionType?: string; + protectionRatedCurrent?: number; + protectionCharacteristic?: string; + cableType?: string; + cableCrossSection?: string; + cableLength?: number; + rcdAssignment?: string; + terminalDesignation?: string; + voltage?: number; + status?: string; + isReserve: boolean; + remark?: string; + } + ) { + await db + .update(circuits) + .set({ + 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, + status: input.status ?? null, + isReserve: input.isReserve ? 1 : 0, + remark: input.remark ?? null, + }) + .where(eq(circuits.id, circuitId)); + } + + async delete(circuitId: string) { + await db.delete(circuits).where(eq(circuits.id, circuitId)); + } + + async existsByEquipmentIdentifier(circuitListId: string, equipmentIdentifier: string, excludeCircuitId?: string) { + const rows = await db + .select({ id: circuits.id }) + .from(circuits) + .where( + excludeCircuitId + ? and( + eq(circuits.circuitListId, circuitListId), + eq(circuits.equipmentIdentifier, equipmentIdentifier), + ne(circuits.id, excludeCircuitId) + ) + : and(eq(circuits.circuitListId, circuitListId), eq(circuits.equipmentIdentifier, equipmentIdentifier)) + ) + .limit(1); + return Boolean(rows.length); + } + + async listBySection(sectionId: string) { + return db + .select() + .from(circuits) + .where(eq(circuits.sectionId, sectionId)) + .orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); + } +} diff --git a/src/domain/calculations/circuit-power-calculation.ts b/src/domain/calculations/circuit-power-calculation.ts new file mode 100644 index 0000000..0e00199 --- /dev/null +++ b/src/domain/calculations/circuit-power-calculation.ts @@ -0,0 +1,17 @@ +export function calculateRowTotalPower( + quantity: number, + powerPerUnit: number, + simultaneityFactor: number +): number { + return quantity * powerPerUnit * simultaneityFactor; +} + +export function calculateCircuitTotalPower( + rows: Array<{ quantity: number; powerPerUnit: number; simultaneityFactor: number }> +): number { + return rows.reduce( + (sum, row) => sum + calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor), + 0 + ); +} + diff --git a/src/domain/services/circuit-numbering.service.ts b/src/domain/services/circuit-numbering.service.ts new file mode 100644 index 0000000..c883d7e --- /dev/null +++ b/src/domain/services/circuit-numbering.service.ts @@ -0,0 +1,44 @@ +import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; +import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; + +function parseSuffix(equipmentIdentifier: string, prefix: string): number | null { + if (!equipmentIdentifier.startsWith(prefix)) { + return null; + } + const suffix = equipmentIdentifier.slice(prefix.length); + if (!/^\d+$/.test(suffix)) { + return null; + } + return Number(suffix); +} + +export class CircuitNumberingService { + private readonly sectionRepository: Pick; + private readonly circuitRepository: Pick; + + constructor(deps?: { + sectionRepository?: Pick; + circuitRepository?: Pick; + }) { + this.sectionRepository = deps?.sectionRepository ?? new CircuitSectionRepository(); + this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository(); + } + + async getNextIdentifier(sectionId: string) { + const section = await this.sectionRepository.findById(sectionId); + if (!section) { + throw new Error("Section not found"); + } + + const circuits = await this.circuitRepository.listBySection(sectionId); + let maxSuffix = 0; + for (const circuit of circuits) { + const suffix = parseSuffix(circuit.equipmentIdentifier, section.prefix); + if (suffix !== null && suffix > maxSuffix) { + maxSuffix = suffix; + } + } + + return `${section.prefix}${maxSuffix + 1}`; + } +} diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts new file mode 100644 index 0000000..aaf0dbd --- /dev/null +++ b/src/domain/services/circuit-write.service.ts @@ -0,0 +1,313 @@ +import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; +import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; +import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; +import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; +import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; +import type { + CreateCircuitDeviceRowInput, + CreateCircuitInput, + UpdateCircuitDeviceRowInput, + UpdateCircuitInput, +} from "../../shared/validation/circuit.schemas.js"; +import { CircuitNumberingService } from "./circuit-numbering.service.js"; + +export class CircuitWriteService { + private readonly circuitRepository: CircuitRepository; + private readonly circuitSectionRepository: CircuitSectionRepository; + private readonly circuitListRepository: CircuitListRepository; + private readonly deviceRowRepository: CircuitDeviceRowRepository; + private readonly projectDeviceRepository: ProjectDeviceRepository; + private readonly numberingService: CircuitNumberingService; + + constructor(deps?: { + circuitRepository?: CircuitRepository; + circuitSectionRepository?: CircuitSectionRepository; + circuitListRepository?: CircuitListRepository; + deviceRowRepository?: CircuitDeviceRowRepository; + projectDeviceRepository?: ProjectDeviceRepository; + numberingService?: CircuitNumberingService; + }) { + this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository(); + this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository(); + this.circuitListRepository = deps?.circuitListRepository ?? new CircuitListRepository(); + this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); + this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); + this.numberingService = deps?.numberingService ?? new CircuitNumberingService(); + } + + private async assertSectionInList(sectionId: string, circuitListId: string) { + const section = await this.circuitSectionRepository.findById(sectionId); + if (!section) { + throw new Error("Invalid section id."); + } + if (section.circuitListId !== circuitListId) { + throw new Error("Section does not belong to circuit list."); + } + return section; + } + + private async assertUniqueEquipmentIdentifier( + circuitListId: string, + equipmentIdentifier: string, + excludeCircuitId?: string + ) { + const exists = await this.circuitRepository.existsByEquipmentIdentifier( + circuitListId, + equipmentIdentifier, + excludeCircuitId + ); + if (exists) { + throw new Error("Duplicate equipmentIdentifier in circuit list."); + } + } + + private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) { + if (!linkedProjectDeviceId) { + return; + } + const circuit = await this.circuitRepository.findById(circuitId); + if (!circuit) { + throw new Error("Invalid circuit id."); + } + const list = await this.circuitListRepository.findByIdByListIdOnly(circuit.circuitListId); + if (!list) { + throw new Error("Circuit list not found."); + } + const device = await this.projectDeviceRepository.findById(list.projectId, linkedProjectDeviceId); + if (!device) { + throw new Error("Invalid linked project device id."); + } + } + + async createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInput) { + const list = await this.circuitListRepository.findById(projectId, circuitListId); + if (!list) { + throw new Error("Circuit list not found in project."); + } + await this.assertSectionInList(input.sectionId, circuitListId); + await this.assertUniqueEquipmentIdentifier(circuitListId, input.equipmentIdentifier); + const id = await this.circuitRepository.create({ + circuitListId, + sectionId: input.sectionId, + equipmentIdentifier: input.equipmentIdentifier, + displayName: input.displayName, + sortOrder: input.sortOrder, + protectionType: input.protectionType, + protectionRatedCurrent: input.protectionRatedCurrent, + protectionCharacteristic: input.protectionCharacteristic, + cableType: input.cableType, + cableCrossSection: input.cableCrossSection, + cableLength: input.cableLength, + rcdAssignment: input.rcdAssignment, + terminalDesignation: input.terminalDesignation, + voltage: input.voltage, + status: input.status, + isReserve: input.isReserve ?? true, + remark: input.remark, + }); + return this.circuitRepository.findById(id); + } + + async updateCircuit(circuitId: string, input: UpdateCircuitInput) { + const current = await this.circuitRepository.findById(circuitId); + if (!current) { + throw new Error("Invalid circuit id."); + } + + const sectionId = input.sectionId ?? current.sectionId; + const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier; + const sortOrder = input.sortOrder ?? current.sortOrder; + await this.assertSectionInList(sectionId, current.circuitListId); + await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId); + + await this.circuitRepository.update(circuitId, { + sectionId, + equipmentIdentifier, + displayName: input.displayName ?? current.displayName ?? undefined, + sortOrder, + protectionType: input.protectionType ?? current.protectionType ?? undefined, + protectionRatedCurrent: input.protectionRatedCurrent ?? current.protectionRatedCurrent ?? undefined, + protectionCharacteristic: + input.protectionCharacteristic ?? current.protectionCharacteristic ?? undefined, + cableType: input.cableType ?? current.cableType ?? undefined, + cableCrossSection: input.cableCrossSection ?? current.cableCrossSection ?? undefined, + cableLength: input.cableLength ?? current.cableLength ?? undefined, + rcdAssignment: input.rcdAssignment ?? current.rcdAssignment ?? undefined, + terminalDesignation: input.terminalDesignation ?? current.terminalDesignation ?? undefined, + voltage: input.voltage ?? current.voltage ?? undefined, + status: input.status ?? current.status ?? undefined, + isReserve: input.isReserve ?? Boolean(current.isReserve), + remark: input.remark ?? current.remark ?? undefined, + }); + return this.circuitRepository.findById(circuitId); + } + + async deleteCircuit(circuitId: string) { + const current = await this.circuitRepository.findById(circuitId); + if (!current) { + throw new Error("Invalid circuit id."); + } + await this.circuitRepository.delete(circuitId); + } + + async createDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInput) { + const circuit = await this.circuitRepository.findById(circuitId); + if (!circuit) { + throw new Error("Invalid circuit id."); + } + await this.assertValidLinkedProjectDevice(circuitId, input.linkedProjectDeviceId); + + const existingRows = await this.deviceRowRepository.countByCircuit(circuitId); + const rowId = await this.deviceRowRepository.create({ + circuitId, + linkedProjectDeviceId: input.linkedProjectDeviceId, + sortOrder: input.sortOrder ?? (existingRows + 1) * 10, + name: input.name, + displayName: input.displayName, + phaseType: input.phaseType, + connectionKind: input.connectionKind, + costGroup: input.costGroup, + category: input.category, + level: input.level, + roomId: input.roomId, + roomNumberSnapshot: input.roomNumberSnapshot, + roomNameSnapshot: input.roomNameSnapshot, + quantity: input.quantity, + powerPerUnit: input.powerPerUnit, + simultaneityFactor: input.simultaneityFactor, + cosPhi: input.cosPhi, + remark: input.remark, + overriddenFields: input.overriddenFields, + }); + + if (Boolean(circuit.isReserve)) { + await this.circuitRepository.update(circuit.id, { + sectionId: circuit.sectionId, + equipmentIdentifier: circuit.equipmentIdentifier, + displayName: circuit.displayName ?? undefined, + sortOrder: circuit.sortOrder, + protectionType: circuit.protectionType ?? undefined, + protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined, + protectionCharacteristic: circuit.protectionCharacteristic ?? undefined, + cableType: circuit.cableType ?? undefined, + cableCrossSection: circuit.cableCrossSection ?? undefined, + cableLength: circuit.cableLength ?? undefined, + rcdAssignment: circuit.rcdAssignment ?? undefined, + terminalDesignation: circuit.terminalDesignation ?? undefined, + voltage: circuit.voltage ?? undefined, + status: circuit.status ?? undefined, + isReserve: false, + remark: circuit.remark ?? undefined, + }); + } + + return this.deviceRowRepository.findById(rowId); + } + + async updateDeviceRow(rowId: string, input: UpdateCircuitDeviceRowInput) { + const current = await this.deviceRowRepository.findById(rowId); + if (!current) { + throw new Error("Invalid device row id."); + } + await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId); + await this.deviceRowRepository.update(rowId, { + linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined, + name: input.name ?? current.name, + displayName: input.displayName ?? current.displayName, + phaseType: input.phaseType ?? current.phaseType ?? undefined, + connectionKind: input.connectionKind ?? current.connectionKind ?? undefined, + costGroup: input.costGroup ?? current.costGroup ?? undefined, + category: input.category ?? current.category ?? undefined, + level: input.level ?? current.level ?? undefined, + roomId: input.roomId ?? current.roomId ?? undefined, + roomNumberSnapshot: input.roomNumberSnapshot ?? current.roomNumberSnapshot ?? undefined, + roomNameSnapshot: input.roomNameSnapshot ?? current.roomNameSnapshot ?? undefined, + quantity: input.quantity ?? current.quantity, + powerPerUnit: input.powerPerUnit ?? current.powerPerUnit, + simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor, + cosPhi: input.cosPhi ?? current.cosPhi ?? undefined, + remark: input.remark ?? current.remark ?? undefined, + overriddenFields: input.overriddenFields ?? current.overriddenFields ?? undefined, + }); + return this.deviceRowRepository.findById(rowId); + } + + async deleteDeviceRow(rowId: string) { + const current = await this.deviceRowRepository.findById(rowId); + if (!current) { + throw new Error("Invalid device row id."); + } + const circuit = await this.circuitRepository.findById(current.circuitId); + if (!circuit) { + throw new Error("Invalid circuit id."); + } + await this.deviceRowRepository.delete(rowId); + const remaining = await this.deviceRowRepository.countByCircuit(current.circuitId); + if (remaining === 0) { + await this.circuitRepository.update(circuit.id, { + sectionId: circuit.sectionId, + equipmentIdentifier: circuit.equipmentIdentifier, + displayName: circuit.displayName ?? undefined, + sortOrder: circuit.sortOrder, + protectionType: circuit.protectionType ?? undefined, + protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined, + protectionCharacteristic: circuit.protectionCharacteristic ?? undefined, + cableType: circuit.cableType ?? undefined, + cableCrossSection: circuit.cableCrossSection ?? undefined, + cableLength: circuit.cableLength ?? undefined, + rcdAssignment: circuit.rcdAssignment ?? undefined, + terminalDesignation: circuit.terminalDesignation ?? undefined, + voltage: circuit.voltage ?? undefined, + status: circuit.status ?? undefined, + isReserve: true, + remark: circuit.remark ?? undefined, + }); + } + } + + async getNextIdentifier(sectionId: string) { + return this.numberingService.getNextIdentifier(sectionId); + } + + async renumberSection(sectionId: string) { + const section = await this.circuitSectionRepository.findById(sectionId); + if (!section) { + throw new Error("Invalid section id."); + } + const sectionCircuits = await this.circuitRepository.listBySection(sectionId); + const otherCircuits = (await this.circuitRepository.listByCircuitList(section.circuitListId)).filter( + (circuit) => circuit.sectionId !== sectionId + ); + const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier)); + + let index = 1; + for (const circuit of sectionCircuits) { + let candidate = `${section.prefix}${index}`; + while (otherIdentifiers.has(candidate)) { + index += 1; + candidate = `${section.prefix}${index}`; + } + await this.circuitRepository.update(circuit.id, { + sectionId: circuit.sectionId, + equipmentIdentifier: candidate, + displayName: circuit.displayName ?? undefined, + sortOrder: circuit.sortOrder, + protectionType: circuit.protectionType ?? undefined, + protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined, + protectionCharacteristic: circuit.protectionCharacteristic ?? undefined, + cableType: circuit.cableType ?? undefined, + cableCrossSection: circuit.cableCrossSection ?? undefined, + cableLength: circuit.cableLength ?? undefined, + rcdAssignment: circuit.rcdAssignment ?? undefined, + terminalDesignation: circuit.terminalDesignation ?? undefined, + voltage: circuit.voltage ?? undefined, + status: circuit.status ?? undefined, + isReserve: Boolean(circuit.isReserve), + remark: circuit.remark ?? undefined, + }); + index += 1; + } + + return this.circuitRepository.listBySection(sectionId); + } +} diff --git a/src/frontend/components/circuit-tree-preview.tsx b/src/frontend/components/circuit-tree-preview.tsx new file mode 100644 index 0000000..26b5c20 --- /dev/null +++ b/src/frontend/components/circuit-tree-preview.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Fragment } from "react"; +import { getCircuitTree } from "../utils/api"; +import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types"; + +function formatNumber(value: number | undefined, digits = 2) { + if (value === undefined || Number.isNaN(value)) { + return "-"; + } + return new Intl.NumberFormat("de-DE", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); +} + +function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) { + if (circuit.displayName?.trim()) { + return circuit.displayName; + } + if (circuit.deviceRows.length > 1) { + return `${circuit.deviceRows.length} devices`; + } + return "Reserve"; +} + +export function CircuitTreePreview(props: { projectId: string; circuitListId: string }) { + const { projectId, circuitListId } = props; + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setIsLoading(true); + setError(null); + getCircuitTree(projectId, circuitListId) + .then(setData) + .catch((err: unknown) => + setError(err instanceof Error ? err.message : "Circuit tree could not be loaded.") + ) + .finally(() => setIsLoading(false)); + }, [projectId, circuitListId]); + + if (isLoading) { + return
Loading circuit tree...
; + } + + if (error) { + return
{error}
; + } + + if (!data || !data.sections.length) { + return
No sections or circuits available.
; + } + + const hasAnyCircuits = data.sections.some((section) => section.circuits.length > 0); + if (!hasAnyCircuits) { + return
Sections exist, but no circuits were found yet.
; + } + + return ( +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {data.sections.map((section) => ( + + ))} + +
Equipment identifierDisplay namePhase typeConnection kindCost groupCategoryLevelRoom numberRoom nameQuantityPower / unitSimultaneitycosPhiRow totalCircuit totalProtection typeProtection currentProtection characteristicCable typeCable cross-sectionCable lengthRemark
+
+
+
+ ); +} + +function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number] }) { + const { section } = props; + return ( + <> + + + {section.displayName} + + + {section.circuits.map((circuit) => { + if (circuit.deviceRows.length === 0) { + return ( + + {circuit.equipmentIdentifier} + {circuit.displayName?.trim() || "Reserve"} + - + {formatNumber(circuit.circuitTotalPower)} + {circuit.protectionType ?? "-"} + {formatNumber(circuit.protectionRatedCurrent)} + {circuit.protectionCharacteristic ?? "-"} + {circuit.cableType ?? "-"} + {circuit.cableCrossSection ?? "-"} + {formatNumber(circuit.cableLength)} + {circuit.remark ?? "-"} + + ); + } + + if (circuit.deviceRows.length === 1) { + const row = circuit.deviceRows[0]; + return ( + + {circuit.equipmentIdentifier} + {row.displayName || row.name} + {row.phaseType ?? "-"} + {row.connectionKind ?? "-"} + {row.costGroup ?? "-"} + {row.category ?? "-"} + {row.level ?? "-"} + {row.roomNumberSnapshot ?? "-"} + {row.roomNameSnapshot ?? "-"} + {formatNumber(row.quantity, 0)} + {formatNumber(row.powerPerUnit)} + {formatNumber(row.simultaneityFactor)} + {formatNumber(row.cosPhi)} + {formatNumber(row.rowTotalPower)} + {formatNumber(circuit.circuitTotalPower)} + {circuit.protectionType ?? "-"} + {formatNumber(circuit.protectionRatedCurrent)} + {circuit.protectionCharacteristic ?? "-"} + {circuit.cableType ?? "-"} + {circuit.cableCrossSection ?? "-"} + {formatNumber(circuit.cableLength)} + {row.remark ?? circuit.remark ?? "-"} + + ); + } + + return ( + + + {circuit.equipmentIdentifier} + {renderCircuitSummaryLabel(circuit)} + - + {formatNumber(circuit.circuitTotalPower)} + {circuit.protectionType ?? "-"} + {formatNumber(circuit.protectionRatedCurrent)} + {circuit.protectionCharacteristic ?? "-"} + {circuit.cableType ?? "-"} + {circuit.cableCrossSection ?? "-"} + {formatNumber(circuit.cableLength)} + {circuit.remark ?? "-"} + + {circuit.deviceRows.map((row) => ( + + + {row.displayName || row.name} + {row.phaseType ?? "-"} + {row.connectionKind ?? "-"} + {row.costGroup ?? "-"} + {row.category ?? "-"} + {row.level ?? "-"} + {row.roomNumberSnapshot ?? "-"} + {row.roomNameSnapshot ?? "-"} + {formatNumber(row.quantity, 0)} + {formatNumber(row.powerPerUnit)} + {formatNumber(row.simultaneityFactor)} + {formatNumber(row.cosPhi)} + {formatNumber(row.rowTotalPower)} + - + - + - + - + - + - + - + {row.remark ?? "-"} + + ))} + + ); + })} + + -frei- + free placeholder + + + ); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index e6033d2..e159499 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -168,3 +168,76 @@ export interface CreateProjectDeviceInput { powerFactor?: number; note?: string; } + +export interface CircuitTreeDeviceRowDto { + id: string; + linkedProjectDeviceId?: string; + legacyConsumerId?: string; + sortOrder: number; + 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; + rowTotalPower: number; +} + +export interface CircuitTreeCircuitDto { + id: string; + circuitListId: string; + sectionId: string; + equipmentIdentifier: string; + displayName?: string; + sortOrder: number; + protectionType?: string; + protectionRatedCurrent?: number; + protectionCharacteristic?: string; + cableType?: string; + cableCrossSection?: string; + cableLength?: number; + rcdAssignment?: string; + terminalDesignation?: string; + voltage?: number; + status?: string; + isReserve: boolean; + remark?: string; + circuitTotalPower: number; + deviceRows: CircuitTreeDeviceRowDto[]; +} + +export interface CircuitTreeSectionDto { + id: string; + key: string; + displayName: string; + prefix: string; + sortOrder: number; + circuits: CircuitTreeCircuitDto[]; +} + +export interface CircuitTreeMigrationReportDto { + circuitListId: string; + legacyConsumerCount: number; + createdCircuitCount: number; + createdDeviceRowCount: number; + groupedDuplicateCircuitNumbers: Array<{ normalizedCircuitNumber: string; count: number }>; + generatedIdentifiers: string[]; + unassignedRows: Array<{ consumerId: string; reason: string }>; + warnings: string[]; +} + +export interface CircuitTreeResponseDto { + circuitListId: string; + sections: CircuitTreeSectionDto[]; + migrationReport?: CircuitTreeMigrationReportDto; +} diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 92b3729..ce971c6 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -13,6 +13,7 @@ import type { ProjectDto, RoomDto, UpdateConsumerInput, + CircuitTreeResponseDto, } from "../types"; async function request(url: string, init?: RequestInit): Promise { @@ -77,6 +78,10 @@ export function listCircuitLists(projectId: string) { return request(`/api/projects/${projectId}/circuit-lists`); } +export function getCircuitTree(projectId: string, circuitListId: string) { + return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`); +} + export function listFloors(projectId: string) { return request(`/api/projects/${projectId}/floors`); } diff --git a/src/server/controllers/circuit-device-row.controller.ts b/src/server/controllers/circuit-device-row.controller.ts new file mode 100644 index 0000000..0acdf32 --- /dev/null +++ b/src/server/controllers/circuit-device-row.controller.ts @@ -0,0 +1,61 @@ +import type { Request, Response } from "express"; +import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; +import { + createCircuitDeviceRowSchema, + updateCircuitDeviceRowSchema, +} from "../../shared/validation/circuit.schemas.js"; + +const circuitWriteService = new CircuitWriteService(); + +export async function createCircuitDeviceRow(req: Request, res: Response) { + const { circuitId } = req.params; + if (typeof circuitId !== "string") { + return res.status(400).json({ error: "Invalid circuitId" }); + } + + const parsed = createCircuitDeviceRowSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + const created = await circuitWriteService.createDeviceRow(circuitId, parsed.data); + return res.status(201).json(created); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create device row." }); + } +} + +export async function updateCircuitDeviceRow(req: Request, res: Response) { + const { rowId } = req.params; + if (typeof rowId !== "string") { + return res.status(400).json({ error: "Invalid rowId" }); + } + + const parsed = updateCircuitDeviceRowSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + const updated = await circuitWriteService.updateDeviceRow(rowId, parsed.data); + return res.json(updated); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update device row." }); + } +} + +export async function deleteCircuitDeviceRow(req: Request, res: Response) { + const { rowId } = req.params; + if (typeof rowId !== "string") { + return res.status(400).json({ error: "Invalid rowId" }); + } + + try { + await circuitWriteService.deleteDeviceRow(rowId); + return res.status(204).send(); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete device row." }); + } +} + diff --git a/src/server/controllers/circuit-section.controller.ts b/src/server/controllers/circuit-section.controller.ts new file mode 100644 index 0000000..ee393be --- /dev/null +++ b/src/server/controllers/circuit-section.controller.ts @@ -0,0 +1,19 @@ +import type { Request, Response } from "express"; +import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; + +const circuitWriteService = new CircuitWriteService(); + +export async function renumberCircuitSection(req: Request, res: Response) { + const { sectionId } = req.params; + if (typeof sectionId !== "string") { + return res.status(400).json({ error: "Invalid sectionId" }); + } + + try { + const updatedCircuits = await circuitWriteService.renumberSection(sectionId); + return res.json({ sectionId, circuits: updatedCircuits }); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to renumber section." }); + } +} + diff --git a/src/server/controllers/circuit-tree.controller.ts b/src/server/controllers/circuit-tree.controller.ts index 6c09100..155fe4a 100644 --- a/src/server/controllers/circuit-tree.controller.ts +++ b/src/server/controllers/circuit-tree.controller.ts @@ -3,17 +3,26 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; +import { + calculateCircuitTotalPower, + calculateRowTotalPower, +} from "../../domain/calculations/circuit-power-calculation.js"; import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js"; -import { LegacyConsumerMigrationService } from "../../domain/services/legacy-consumer-migration.service.js"; const circuitListRepository = new CircuitListRepository(); const circuitSectionRepository = new CircuitSectionRepository(); const circuitRepository = new CircuitRepository(); const circuitDeviceRowRepository = new CircuitDeviceRowRepository(); -const legacyConsumerMigrationService = new LegacyConsumerMigrationService(); -function rowTotalPower(quantity: number, powerPerUnit: number, simultaneityFactor: number): number { - return quantity * powerPerUnit * simultaneityFactor; +export function isMissingCircuitTreeSchemaError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return ( + error.message.includes("no such table: circuit_sections") || + error.message.includes("no such table: circuits") || + error.message.includes("no such table: circuit_device_rows") + ); } export async function getCircuitTree(req: Request, res: Response) { @@ -27,87 +36,97 @@ export async function getCircuitTree(req: Request, res: Response) { return res.status(404).json({ error: "Circuit list not found" }); } - const migrationReport = await legacyConsumerMigrationService.migrateCircuitList(projectId, circuitListId); - const sections = await circuitSectionRepository.listByCircuitList(circuitListId); - const circuits = await circuitRepository.listByCircuitList(circuitListId); - const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)); + try { + const sections = await circuitSectionRepository.listByCircuitList(circuitListId); + const circuits = await circuitRepository.listByCircuitList(circuitListId); + const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)); - const sectionById = new Map(sections.map((section) => [section.id, section])); - const rowsByCircuitId = new Map(); - for (const row of rows) { - if (!rowsByCircuitId.has(row.circuitId)) { - rowsByCircuitId.set(row.circuitId, []); + const sectionById = new Map(sections.map((section) => [section.id, section])); + const rowsByCircuitId = new Map(); + for (const row of rows) { + if (!rowsByCircuitId.has(row.circuitId)) { + rowsByCircuitId.set(row.circuitId, []); + } + rowsByCircuitId.get(row.circuitId)!.push(row); } - rowsByCircuitId.get(row.circuitId)!.push(row); - } - const tree: CircuitTreeResponse = { - circuitListId, - sections: sections.map((section) => ({ - id: section.id, - key: section.key, - displayName: section.displayName, - prefix: section.prefix, - sortOrder: section.sortOrder, - circuits: [], - })), - }; - const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section])); + const tree: CircuitTreeResponse = { + circuitListId, + sections: sections.map((section) => ({ + id: section.id, + key: section.key, + displayName: section.displayName, + prefix: section.prefix, + sortOrder: section.sortOrder, + circuits: [], + })), + }; + const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section])); - for (const circuit of circuits) { - const section = sectionById.get(circuit.sectionId); - if (!section || section.circuitListId !== circuit.circuitListId) { - continue; + for (const circuit of circuits) { + const section = sectionById.get(circuit.sectionId); + if (!section || section.circuitListId !== circuit.circuitListId) { + continue; + } + const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({ + id: row.id, + linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined, + legacyConsumerId: row.legacyConsumerId ?? undefined, + sortOrder: row.sortOrder, + name: row.name, + displayName: row.displayName, + phaseType: row.phaseType ?? undefined, + connectionKind: row.connectionKind ?? undefined, + costGroup: row.costGroup ?? undefined, + category: row.category ?? undefined, + level: row.level ?? undefined, + roomId: row.roomId ?? undefined, + roomNumberSnapshot: row.roomNumberSnapshot ?? undefined, + roomNameSnapshot: row.roomNameSnapshot ?? undefined, + quantity: row.quantity, + powerPerUnit: row.powerPerUnit, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi ?? undefined, + remark: row.remark ?? undefined, + overriddenFields: row.overriddenFields ?? undefined, + rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor), + })); + const circuitTotalPower = calculateCircuitTotalPower(deviceRows); + + sectionBlocks.get(section.id)?.circuits.push({ + id: circuit.id, + circuitListId: circuit.circuitListId, + sectionId: circuit.sectionId, + equipmentIdentifier: circuit.equipmentIdentifier, + displayName: circuit.displayName ?? undefined, + sortOrder: circuit.sortOrder, + protectionType: circuit.protectionType ?? undefined, + protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined, + protectionCharacteristic: circuit.protectionCharacteristic ?? undefined, + cableType: circuit.cableType ?? undefined, + cableCrossSection: circuit.cableCrossSection ?? undefined, + cableLength: circuit.cableLength ?? undefined, + rcdAssignment: circuit.rcdAssignment ?? undefined, + terminalDesignation: circuit.terminalDesignation ?? undefined, + voltage: circuit.voltage ?? undefined, + status: circuit.status ?? undefined, + isReserve: Boolean(circuit.isReserve), + remark: circuit.remark ?? undefined, + circuitTotalPower, + deviceRows, + }); } - const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({ - id: row.id, - linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined, - legacyConsumerId: row.legacyConsumerId ?? undefined, - sortOrder: row.sortOrder, - name: row.name, - displayName: row.displayName, - phaseType: row.phaseType ?? undefined, - connectionKind: row.connectionKind ?? undefined, - costGroup: row.costGroup ?? undefined, - category: row.category ?? undefined, - level: row.level ?? undefined, - roomId: row.roomId ?? undefined, - roomNumberSnapshot: row.roomNumberSnapshot ?? undefined, - roomNameSnapshot: row.roomNameSnapshot ?? undefined, - quantity: row.quantity, - powerPerUnit: row.powerPerUnit, - simultaneityFactor: row.simultaneityFactor, - cosPhi: row.cosPhi ?? undefined, - remark: row.remark ?? undefined, - overriddenFields: row.overriddenFields ?? undefined, - rowTotalPower: rowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor), - })); - const circuitTotalPower = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0); - sectionBlocks.get(section.id)?.circuits.push({ - id: circuit.id, - circuitListId: circuit.circuitListId, - sectionId: circuit.sectionId, - equipmentIdentifier: circuit.equipmentIdentifier, - displayName: circuit.displayName ?? undefined, - sortOrder: circuit.sortOrder, - protectionType: circuit.protectionType ?? undefined, - protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined, - protectionCharacteristic: circuit.protectionCharacteristic ?? undefined, - cableType: circuit.cableType ?? undefined, - cableCrossSection: circuit.cableCrossSection ?? undefined, - cableLength: circuit.cableLength ?? undefined, - rcdAssignment: circuit.rcdAssignment ?? undefined, - terminalDesignation: circuit.terminalDesignation ?? undefined, - voltage: circuit.voltage ?? undefined, - status: circuit.status ?? undefined, - isReserve: Boolean(circuit.isReserve), - remark: circuit.remark ?? undefined, - circuitTotalPower, - deviceRows, - }); + return res.json(tree); + } catch (error) { + if (isMissingCircuitTreeSchemaError(error)) { + return res.json({ + circuitListId, + sections: [], + warning: + "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", + }); + } + throw error; } - - return res.json({ ...tree, migrationReport }); } - diff --git a/src/server/controllers/circuit.controller.ts b/src/server/controllers/circuit.controller.ts new file mode 100644 index 0000000..415059f --- /dev/null +++ b/src/server/controllers/circuit.controller.ts @@ -0,0 +1,71 @@ +import type { Request, Response } from "express"; +import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; +import { createCircuitSchema, updateCircuitSchema } from "../../shared/validation/circuit.schemas.js"; + +const circuitWriteService = new CircuitWriteService(); + +export async function createCircuit(req: Request, res: Response) { + const { projectId, circuitListId } = req.params; + if (typeof projectId !== "string" || typeof circuitListId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + + const parsed = createCircuitSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + const created = await circuitWriteService.createCircuit(projectId, circuitListId, parsed.data); + return res.status(201).json(created); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to create circuit." }); + } +} + +export async function updateCircuit(req: Request, res: Response) { + const { circuitId } = req.params; + if (typeof circuitId !== "string") { + return res.status(400).json({ error: "Invalid circuitId" }); + } + + const parsed = updateCircuitSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + try { + const updated = await circuitWriteService.updateCircuit(circuitId, parsed.data); + return res.json(updated); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update circuit." }); + } +} + +export async function deleteCircuit(req: Request, res: Response) { + const { circuitId } = req.params; + if (typeof circuitId !== "string") { + return res.status(400).json({ error: "Invalid circuitId" }); + } + + try { + await circuitWriteService.deleteCircuit(circuitId); + return res.status(204).send(); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete circuit." }); + } +} + +export async function getNextCircuitIdentifier(req: Request, res: Response) { + const { sectionId } = req.params; + if (typeof sectionId !== "string") { + return res.status(400).json({ error: "Invalid sectionId" }); + } + try { + const nextIdentifier = await circuitWriteService.getNextIdentifier(sectionId); + return res.json({ sectionId, nextIdentifier }); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to get identifier." }); + } +} + diff --git a/src/server/index.ts b/src/server/index.ts index 6c82374..c61643a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,7 @@ import express from "express"; +import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js"; +import { circuitRouter } from "./routes/circuit.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 { projectDeviceRouter } from "./routes/project-device.routes.js"; @@ -15,6 +18,9 @@ app.get("/health", (_req, res) => { }); app.use("/api/projects", projectRouter); +app.use("/api", circuitRouter); +app.use("/api", circuitDeviceRowRouter); +app.use("/api", circuitSectionRouter); app.use("/api/consumers", consumerRouter); app.use("/api/global-devices", globalDeviceRouter); app.use("/api/project-devices", projectDeviceRouter); diff --git a/src/server/routes/circuit-device-row.routes.ts b/src/server/routes/circuit-device-row.routes.ts new file mode 100644 index 0000000..2262134 --- /dev/null +++ b/src/server/routes/circuit-device-row.routes.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { + deleteCircuitDeviceRow, + updateCircuitDeviceRow, +} from "../controllers/circuit-device-row.controller.js"; + +export const circuitDeviceRowRouter = Router(); + +circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow); +circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow); + diff --git a/src/server/routes/circuit-section.routes.ts b/src/server/routes/circuit-section.routes.ts new file mode 100644 index 0000000..d358a0c --- /dev/null +++ b/src/server/routes/circuit-section.routes.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; +import { renumberCircuitSection } from "../controllers/circuit-section.controller.js"; + +export const circuitSectionRouter = Router(); + +circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection); + diff --git a/src/server/routes/circuit.routes.ts b/src/server/routes/circuit.routes.ts new file mode 100644 index 0000000..12702c4 --- /dev/null +++ b/src/server/routes/circuit.routes.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; +import { + createCircuit, + deleteCircuit, + getNextCircuitIdentifier, + updateCircuit, +} from "../controllers/circuit.controller.js"; +import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js"; + +export const circuitRouter = Router(); + +circuitRouter.post("/projects/:projectId/circuit-lists/:circuitListId/circuits", createCircuit); +circuitRouter.patch("/circuits/:circuitId", updateCircuit); +circuitRouter.delete("/circuits/:circuitId", deleteCircuit); +circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier); +circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow); + diff --git a/src/shared/validation/circuit.schemas.ts b/src/shared/validation/circuit.schemas.ts new file mode 100644 index 0000000..32fb097 --- /dev/null +++ b/src/shared/validation/circuit.schemas.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +export const createCircuitSchema = z.object({ + sectionId: z.string().min(1), + equipmentIdentifier: z.string().min(1), + displayName: z.string().optional(), + sortOrder: z.number(), + protectionType: z.string().optional(), + protectionRatedCurrent: z.number().min(0).optional(), + protectionCharacteristic: z.string().optional(), + cableType: z.string().optional(), + cableCrossSection: z.string().optional(), + cableLength: z.number().min(0).optional(), + rcdAssignment: z.string().optional(), + terminalDesignation: z.string().optional(), + voltage: z.number().positive().optional(), + status: z.string().optional(), + isReserve: z.boolean().optional(), + remark: z.string().optional(), +}); + +export const updateCircuitSchema = createCircuitSchema.partial().extend({ + sectionId: z.string().min(1).optional(), + equipmentIdentifier: z.string().min(1).optional(), + sortOrder: z.number().optional(), + isReserve: z.boolean().optional(), +}); + +export const createCircuitDeviceRowSchema = z.object({ + linkedProjectDeviceId: z.string().min(1).optional(), + name: z.string().min(1), + displayName: z.string().min(1), + phaseType: z.string().optional(), + connectionKind: z.string().optional(), + costGroup: z.string().optional(), + category: z.string().optional(), + level: z.string().optional(), + roomId: z.string().min(1).optional(), + roomNumberSnapshot: z.string().optional(), + roomNameSnapshot: z.string().optional(), + quantity: z.number().min(0), + powerPerUnit: z.number().min(0), + simultaneityFactor: z.number().min(0), + cosPhi: z.number().positive().optional(), + remark: z.string().optional(), + overriddenFields: z.string().optional(), + sortOrder: z.number().optional(), +}); + +export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial(); + +export type CreateCircuitInput = z.infer; +export type UpdateCircuitInput = z.infer; +export type CreateCircuitDeviceRowInput = z.infer; +export type UpdateCircuitDeviceRowInput = z.infer; + diff --git a/tests/circuit-numbering.service.test.ts b/tests/circuit-numbering.service.test.ts new file mode 100644 index 0000000..487c148 --- /dev/null +++ b/tests/circuit-numbering.service.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { CircuitNumberingService } from "../src/domain/services/circuit-numbering.service.js"; + +describe("circuit numbering service", () => { + it("uses highest numeric suffix + 1 and does not fill gaps", async () => { + const service = new CircuitNumberingService({ + sectionRepository: { + async findById() { + return { id: "s1", prefix: "-2F" } as never; + }, + }, + circuitRepository: { + async listBySection() { + return [ + { equipmentIdentifier: "-2F1" }, + { equipmentIdentifier: "-2F2" }, + { equipmentIdentifier: "-2F5" }, + { equipmentIdentifier: "-2FX" }, + { equipmentIdentifier: "-1F9" }, + ] as never[]; + }, + }, + }); + + const next = await service.getNextIdentifier("s1"); + assert.equal(next, "-2F6"); + }); +}); + diff --git a/tests/circuit-power-calculation.test.ts b/tests/circuit-power-calculation.test.ts new file mode 100644 index 0000000..035eee4 --- /dev/null +++ b/tests/circuit-power-calculation.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + calculateCircuitTotalPower, + calculateRowTotalPower, +} from "../src/domain/calculations/circuit-power-calculation.js"; + +describe("circuit power calculation", () => { + it("calculates row and circuit totals from device rows", () => { + assert.equal(calculateRowTotalPower(2, 1.5, 0.5), 1.5); + const total = calculateCircuitTotalPower([ + { quantity: 2, powerPerUnit: 1.5, simultaneityFactor: 0.5 }, + { quantity: 1, powerPerUnit: 3, simultaneityFactor: 1 }, + ]); + assert.equal(total, 4.5); + }); +}); + diff --git a/tests/circuit-tree.controller.test.ts b/tests/circuit-tree.controller.test.ts new file mode 100644 index 0000000..d649c3a --- /dev/null +++ b/tests/circuit-tree.controller.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isMissingCircuitTreeSchemaError } from "../src/server/controllers/circuit-tree.controller.js"; + +describe("circuit tree controller", () => { + it("detects missing circuit-first schema errors", () => { + assert.equal( + isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_sections")), + true + ); + assert.equal(isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuits")), true); + assert.equal( + isMissingCircuitTreeSchemaError(new Error("SqliteError: no such table: circuit_device_rows")), + true + ); + assert.equal(isMissingCircuitTreeSchemaError(new Error("Some other error")), false); + }); +}); + diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts new file mode 100644 index 0000000..eac9700 --- /dev/null +++ b/tests/circuit-write.rules.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { CircuitWriteService } from "../src/domain/services/circuit-write.service.js"; + +describe("circuit write service rules", () => { + it("rejects duplicate equipment identifiers in same circuit list", async () => { + const service = new CircuitWriteService({ + circuitListRepository: { + async findById() { + return { id: "list1", projectId: "p1" } as never; + }, + } as never, + circuitSectionRepository: { + async findById() { + return { id: "sec1", circuitListId: "list1" } as never; + }, + } as never, + circuitRepository: { + async existsByEquipmentIdentifier() { + return true; + }, + } as never, + }); + + await assert.rejects( + () => + service.createCircuit("p1", "list1", { + sectionId: "sec1", + equipmentIdentifier: "-2F1", + sortOrder: 10, + }), + /Duplicate equipmentIdentifier/ + ); + }); + + it("rejects section and list mismatch", async () => { + const service = new CircuitWriteService({ + circuitListRepository: { + async findById() { + return { id: "list1", projectId: "p1" } as never; + }, + } as never, + circuitSectionRepository: { + async findById() { + return { id: "sec1", circuitListId: "other" } as never; + }, + } as never, + circuitRepository: { + async existsByEquipmentIdentifier() { + return false; + }, + } as never, + }); + + await assert.rejects( + () => + service.createCircuit("p1", "list1", { + sectionId: "sec1", + equipmentIdentifier: "-2F1", + sortOrder: 10, + }), + /Section does not belong to circuit list/ + ); + }); + + it("deleting last device row keeps circuit and sets reserve", async () => { + let reserveFlag = false; + const service = new CircuitWriteService({ + deviceRowRepository: { + async findById() { + return { id: "r1", circuitId: "c1" } as never; + }, + async delete() { + return; + }, + async countByCircuit() { + return 0; + }, + } as never, + circuitRepository: { + async findById() { + return { + id: "c1", + sectionId: "s1", + circuitListId: "l1", + equipmentIdentifier: "-2F1", + sortOrder: 10, + isReserve: 0, + } as never; + }, + async update(_id: string, payload: { isReserve: boolean }) { + reserveFlag = payload.isReserve; + }, + } as never, + }); + + await service.deleteDeviceRow("r1"); + assert.equal(reserveFlag, true); + }); + + it("creating device row in reserve circuit clears reserve status", async () => { + let reserveFlag = true; + const service = new CircuitWriteService({ + circuitRepository: { + async findById() { + return { + id: "c1", + sectionId: "s1", + circuitListId: "l1", + equipmentIdentifier: "-2F1", + sortOrder: 10, + isReserve: 1, + } as never; + }, + async update(_id: string, payload: { isReserve: boolean }) { + reserveFlag = payload.isReserve; + }, + } as never, + deviceRowRepository: { + async countByCircuit() { + return 0; + }, + async create() { + return "row1"; + }, + async findById() { + return { id: "row1" } as never; + }, + } as never, + circuitListRepository: {} as never, + circuitSectionRepository: {} as never, + projectDeviceRepository: { + async findById() { + return { id: "pd1" } as never; + }, + } as never, + }); + + await service.createDeviceRow("c1", { + name: "Load", + displayName: "Load", + quantity: 1, + powerPerUnit: 1, + simultaneityFactor: 1, + }); + assert.equal(reserveFlag, false); + }); + + it("renumber affects only circuits in selected section and keeps row order untouched", async () => { + const updatedIds: string[] = []; + const service = new CircuitWriteService({ + circuitSectionRepository: { + async findById() { + return { id: "s1", circuitListId: "l1", prefix: "-2F" } as never; + }, + } as never, + circuitRepository: { + async listBySection() { + return [ + { id: "c1", sectionId: "s1", equipmentIdentifier: "-2F7", sortOrder: 10, isReserve: 0 }, + { id: "c2", sectionId: "s1", equipmentIdentifier: "-2F9", sortOrder: 20, isReserve: 1 }, + ] as never[]; + }, + async listByCircuitList() { + return [{ id: "x1", sectionId: "s2", equipmentIdentifier: "-3F1" }] as never[]; + }, + async update(circuitId: string) { + updatedIds.push(circuitId); + }, + } as never, + }); + + const result = await service.renumberSection("s1"); + assert.deepEqual(updatedIds, ["c1", "c2"]); + assert.equal(result.length, 2); + }); +}); +