Added 1B, 2 and added bootstrap again for site
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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
@@ -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": "",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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." });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user