Added 1B, 2 and added bootstrap again for site

This commit is contained in:
2026-05-03 22:04:45 +02:00
parent b8995b3a1b
commit d1ce485572
37 changed files with 1842 additions and 89 deletions
+10
View File
@@ -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.
+10
View File
@@ -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)
+142
View File
@@ -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 -- <circuitId>
```
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 -- <rowId>
```
+9 -3
View File
@@ -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": "",
+25
View File
@@ -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);
});
+19
View File
@@ -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}`);
+36
View File
@@ -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);
});
+31
View File
@@ -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.");
+40
View File
@@ -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 -- <circuitId>");
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);
});
+25
View File
@@ -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 -- <rowId>");
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);
});
+29
View File
@@ -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;
}
@@ -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 (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 className="h4 mb-1">Circuit Tree Preview</h1>
<p className="text-secondary mb-0">
Read-only preview of section blocks, circuits and device rows.
</p>
</div>
<Link
href={`/projects/${projectId}/circuit-lists`}
className="btn btn-outline-secondary btn-sm"
>
Back to legacy editor
</Link>
</div>
<CircuitTreePreview projectId={projectId} circuitListId={circuitListId} />
</main>
);
}
+7
View File
@@ -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
}
]
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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 {
}
}
}
+86 -1
View File
@@ -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));
}
}
@@ -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
);
}
@@ -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<CircuitSectionRepository, "findById">;
private readonly circuitRepository: Pick<CircuitRepository, "listBySection">;
constructor(deps?: {
sectionRepository?: Pick<CircuitSectionRepository, "findById">;
circuitRepository?: Pick<CircuitRepository, "listBySection">;
}) {
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}`;
}
}
@@ -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);
}
}
@@ -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<CircuitTreeResponseDto | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="alert alert-info">Loading circuit tree...</div>;
}
if (error) {
return <div className="alert alert-danger">{error}</div>;
}
if (!data || !data.sections.length) {
return <div className="alert alert-secondary">No sections or circuits available.</div>;
}
const hasAnyCircuits = data.sections.some((section) => section.circuits.length > 0);
if (!hasAnyCircuits) {
return <div className="alert alert-secondary">Sections exist, but no circuits were found yet.</div>;
}
return (
<div className="card shadow-sm">
<div className="card-body">
<div className="table-responsive">
<table className="table table-sm align-middle circuit-tree-table">
<thead>
<tr>
<th>Equipment identifier</th>
<th>Display name</th>
<th>Phase type</th>
<th>Connection kind</th>
<th>Cost group</th>
<th>Category</th>
<th>Level</th>
<th>Room number</th>
<th>Room name</th>
<th className="text-end">Quantity</th>
<th className="text-end">Power / unit</th>
<th className="text-end">Simultaneity</th>
<th className="text-end">cosPhi</th>
<th className="text-end">Row total</th>
<th className="text-end">Circuit total</th>
<th>Protection type</th>
<th className="text-end">Protection current</th>
<th>Protection characteristic</th>
<th>Cable type</th>
<th>Cable cross-section</th>
<th className="text-end">Cable length</th>
<th>Remark</th>
</tr>
</thead>
<tbody>
{data.sections.map((section) => (
<SectionRows key={section.id} section={section} />
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number] }) {
const { section } = props;
return (
<>
<tr className="section-row">
<td colSpan={22}>
<strong>{section.displayName}</strong>
</td>
</tr>
{section.circuits.map((circuit) => {
if (circuit.deviceRows.length === 0) {
return (
<tr key={circuit.id} className="reserve-row">
<td>{circuit.equipmentIdentifier}</td>
<td>{circuit.displayName?.trim() || "Reserve"}</td>
<td colSpan={12}>-</td>
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
<td>{circuit.protectionType ?? "-"}</td>
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
<td>{circuit.protectionCharacteristic ?? "-"}</td>
<td>{circuit.cableType ?? "-"}</td>
<td>{circuit.cableCrossSection ?? "-"}</td>
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
<td>{circuit.remark ?? "-"}</td>
</tr>
);
}
if (circuit.deviceRows.length === 1) {
const row = circuit.deviceRows[0];
return (
<tr key={circuit.id} className={circuit.isReserve ? "reserve-row" : ""}>
<td>{circuit.equipmentIdentifier}</td>
<td>{row.displayName || row.name}</td>
<td>{row.phaseType ?? "-"}</td>
<td>{row.connectionKind ?? "-"}</td>
<td>{row.costGroup ?? "-"}</td>
<td>{row.category ?? "-"}</td>
<td>{row.level ?? "-"}</td>
<td>{row.roomNumberSnapshot ?? "-"}</td>
<td>{row.roomNameSnapshot ?? "-"}</td>
<td className="text-end">{formatNumber(row.quantity, 0)}</td>
<td className="text-end">{formatNumber(row.powerPerUnit)}</td>
<td className="text-end">{formatNumber(row.simultaneityFactor)}</td>
<td className="text-end">{formatNumber(row.cosPhi)}</td>
<td className="text-end">{formatNumber(row.rowTotalPower)}</td>
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
<td>{circuit.protectionType ?? "-"}</td>
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
<td>{circuit.protectionCharacteristic ?? "-"}</td>
<td>{circuit.cableType ?? "-"}</td>
<td>{circuit.cableCrossSection ?? "-"}</td>
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
<td>{row.remark ?? circuit.remark ?? "-"}</td>
</tr>
);
}
return (
<Fragment key={circuit.id}>
<tr key={`${circuit.id}-summary`} className="summary-row">
<td>{circuit.equipmentIdentifier}</td>
<td>{renderCircuitSummaryLabel(circuit)}</td>
<td colSpan={12}>-</td>
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
<td>{circuit.protectionType ?? "-"}</td>
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
<td>{circuit.protectionCharacteristic ?? "-"}</td>
<td>{circuit.cableType ?? "-"}</td>
<td>{circuit.cableCrossSection ?? "-"}</td>
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
<td>{circuit.remark ?? "-"}</td>
</tr>
{circuit.deviceRows.map((row) => (
<tr key={row.id} className="device-row">
<td className="text-muted"> </td>
<td className="indented-cell">{row.displayName || row.name}</td>
<td>{row.phaseType ?? "-"}</td>
<td>{row.connectionKind ?? "-"}</td>
<td>{row.costGroup ?? "-"}</td>
<td>{row.category ?? "-"}</td>
<td>{row.level ?? "-"}</td>
<td>{row.roomNumberSnapshot ?? "-"}</td>
<td>{row.roomNameSnapshot ?? "-"}</td>
<td className="text-end">{formatNumber(row.quantity, 0)}</td>
<td className="text-end">{formatNumber(row.powerPerUnit)}</td>
<td className="text-end">{formatNumber(row.simultaneityFactor)}</td>
<td className="text-end">{formatNumber(row.cosPhi)}</td>
<td className="text-end">{formatNumber(row.rowTotalPower)}</td>
<td className="text-end">-</td>
<td>-</td>
<td className="text-end">-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td className="text-end">-</td>
<td>{row.remark ?? "-"}</td>
</tr>
))}
</Fragment>
);
})}
<tr className="placeholder-row">
<td>-frei-</td>
<td colSpan={21}>free placeholder</td>
</tr>
</>
);
}
+73
View File
@@ -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;
}
+5
View File
@@ -13,6 +13,7 @@ import type {
ProjectDto,
RoomDto,
UpdateConsumerInput,
CircuitTreeResponseDto,
} from "../types";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
@@ -77,6 +78,10 @@ export function listCircuitLists(projectId: string) {
return request<CircuitListDto[]>(`/api/projects/${projectId}/circuit-lists`);
}
export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
}
export function listFloors(projectId: string) {
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
}
@@ -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." });
}
}
@@ -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." });
}
}
@@ -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,7 +36,7 @@ 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);
try {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
const circuits = await circuitRepository.listByCircuitList(circuitListId);
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
@@ -80,9 +89,9 @@ export async function getCircuitTree(req: Request, res: Response) {
cosPhi: row.cosPhi ?? undefined,
remark: row.remark ?? undefined,
overriddenFields: row.overriddenFields ?? undefined,
rowTotalPower: rowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
}));
const circuitTotalPower = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
sectionBlocks.get(section.id)?.circuits.push({
id: circuit.id,
@@ -108,6 +117,16 @@ export async function getCircuitTree(req: Request, res: Response) {
});
}
return res.json({ ...tree, migrationReport });
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;
}
}
@@ -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." });
}
}
+6
View File
@@ -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);
@@ -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);
@@ -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);
+17
View File
@@ -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);
+56
View File
@@ -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<typeof createCircuitSchema>;
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
+30
View File
@@ -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");
});
});
+18
View File
@@ -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);
});
});
+19
View File
@@ -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);
});
});
+178
View File
@@ -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);
});
});