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.
|
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
|
## Drag-and-Drop Rules
|
||||||
|
|
||||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
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:generate` – Migrationen generieren
|
||||||
- `npm run db:migrate` – Migrationen ausführen
|
- `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
|
## Ergänzende Dokumente
|
||||||
|
|
||||||
- Anforderungen (Quelle): [docs/electrical-load-balance-requirements-context-dump.md](docs/electrical-load-balance-requirements-context-dump.md)
|
- 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:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/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",
|
"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: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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"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 {
|
.table td select.form-select-sm {
|
||||||
min-width: 8rem;
|
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,
|
"when": 1777680000000,
|
||||||
"tag": "0007_consumer_device_link",
|
"tag": "0007_consumer_device_link",
|
||||||
"breakpoints": true
|
"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 crypto from "node:crypto";
|
||||||
import { asc, inArray } from "drizzle-orm";
|
import { asc, eq, inArray } from "drizzle-orm";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
|
||||||
export class CircuitDeviceRowRepository {
|
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[]) {
|
async listByCircuitList(circuitIds: string[]) {
|
||||||
if (!circuitIds.length) {
|
if (!circuitIds.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -26,6 +36,7 @@ export class CircuitDeviceRowRepository {
|
|||||||
connectionKind?: string;
|
connectionKind?: string;
|
||||||
costGroup?: string;
|
costGroup?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
level?: string;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
roomNumberSnapshot?: string;
|
roomNumberSnapshot?: string;
|
||||||
roomNameSnapshot?: string;
|
roomNameSnapshot?: string;
|
||||||
@@ -34,9 +45,11 @@ export class CircuitDeviceRowRepository {
|
|||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
cosPhi?: number;
|
cosPhi?: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
overriddenFields?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
await db.insert(circuitDeviceRows).values({
|
await db.insert(circuitDeviceRows).values({
|
||||||
id: crypto.randomUUID(),
|
id,
|
||||||
circuitId: input.circuitId,
|
circuitId: input.circuitId,
|
||||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
linkedProjectDeviceId: input.linkedProjectDeviceId ?? null,
|
||||||
legacyConsumerId: input.legacyConsumerId ?? null,
|
legacyConsumerId: input.legacyConsumerId ?? null,
|
||||||
@@ -47,6 +60,7 @@ export class CircuitDeviceRowRepository {
|
|||||||
connectionKind: input.connectionKind ?? null,
|
connectionKind: input.connectionKind ?? null,
|
||||||
costGroup: input.costGroup ?? null,
|
costGroup: input.costGroup ?? null,
|
||||||
category: input.category ?? null,
|
category: input.category ?? null,
|
||||||
|
level: input.level ?? null,
|
||||||
roomId: input.roomId ?? null,
|
roomId: input.roomId ?? null,
|
||||||
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
roomNumberSnapshot: input.roomNumberSnapshot ?? null,
|
||||||
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
roomNameSnapshot: input.roomNameSnapshot ?? null,
|
||||||
@@ -55,7 +69,58 @@ export class CircuitDeviceRowRepository {
|
|||||||
simultaneityFactor: input.simultaneityFactor,
|
simultaneityFactor: input.simultaneityFactor,
|
||||||
cosPhi: input.cosPhi ?? null,
|
cosPhi: input.cosPhi ?? null,
|
||||||
remark: input.remark ?? 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);
|
.limit(1);
|
||||||
return row ?? null;
|
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";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
|
||||||
export class CircuitSectionRepository {
|
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) {
|
async listByCircuitList(circuitListId: string) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
@@ -39,4 +44,3 @@ export class CircuitSectionRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import crypto from "node:crypto";
|
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 { db } from "../client.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
|
||||||
export class CircuitRepository {
|
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) {
|
async listByCircuitList(circuitListId: string) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
@@ -26,6 +31,10 @@ export class CircuitRepository {
|
|||||||
cableLength?: number;
|
cableLength?: number;
|
||||||
voltage?: number;
|
voltage?: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
rcdAssignment?: string;
|
||||||
|
terminalDesignation?: string;
|
||||||
|
status?: string;
|
||||||
|
isReserve?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
await db.insert(circuits).values({
|
await db.insert(circuits).values({
|
||||||
@@ -41,10 +50,86 @@ export class CircuitRepository {
|
|||||||
cableType: input.cableType ?? null,
|
cableType: input.cableType ?? null,
|
||||||
cableCrossSection: input.cableCrossSection ?? null,
|
cableCrossSection: input.cableCrossSection ?? null,
|
||||||
cableLength: input.cableLength ?? null,
|
cableLength: input.cableLength ?? null,
|
||||||
|
rcdAssignment: input.rcdAssignment ?? null,
|
||||||
|
terminalDesignation: input.terminalDesignation ?? null,
|
||||||
voltage: input.voltage ?? null,
|
voltage: input.voltage ?? null,
|
||||||
|
status: input.status ?? null,
|
||||||
|
isReserve: input.isReserve ? 1 : 0,
|
||||||
remark: input.remark ?? null,
|
remark: input.remark ?? null,
|
||||||
});
|
});
|
||||||
return id;
|
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;
|
powerFactor?: number;
|
||||||
note?: string;
|
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,
|
ProjectDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
UpdateConsumerInput,
|
UpdateConsumerInput,
|
||||||
|
CircuitTreeResponseDto,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
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`);
|
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) {
|
export function listFloors(projectId: string) {
|
||||||
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
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 { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||||
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||||
|
import {
|
||||||
|
calculateCircuitTotalPower,
|
||||||
|
calculateRowTotalPower,
|
||||||
|
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.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 circuitListRepository = new CircuitListRepository();
|
||||||
const circuitSectionRepository = new CircuitSectionRepository();
|
const circuitSectionRepository = new CircuitSectionRepository();
|
||||||
const circuitRepository = new CircuitRepository();
|
const circuitRepository = new CircuitRepository();
|
||||||
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
||||||
const legacyConsumerMigrationService = new LegacyConsumerMigrationService();
|
|
||||||
|
|
||||||
function rowTotalPower(quantity: number, powerPerUnit: number, simultaneityFactor: number): number {
|
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||||
return quantity * powerPerUnit * simultaneityFactor;
|
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) {
|
export async function getCircuitTree(req: Request, res: Response) {
|
||||||
@@ -27,87 +36,97 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
return res.status(404).json({ error: "Circuit list not found" });
|
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 sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||||
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
const circuits = await circuitRepository.listByCircuitList(circuitListId);
|
||||||
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id));
|
||||||
|
|
||||||
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
const sectionById = new Map(sections.map((section) => [section.id, section]));
|
||||||
const rowsByCircuitId = new Map<string, typeof rows>();
|
const rowsByCircuitId = new Map<string, typeof rows>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!rowsByCircuitId.has(row.circuitId)) {
|
if (!rowsByCircuitId.has(row.circuitId)) {
|
||||||
rowsByCircuitId.set(row.circuitId, []);
|
rowsByCircuitId.set(row.circuitId, []);
|
||||||
|
}
|
||||||
|
rowsByCircuitId.get(row.circuitId)!.push(row);
|
||||||
}
|
}
|
||||||
rowsByCircuitId.get(row.circuitId)!.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree: CircuitTreeResponse = {
|
const tree: CircuitTreeResponse = {
|
||||||
circuitListId,
|
circuitListId,
|
||||||
sections: sections.map((section) => ({
|
sections: sections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
key: section.key,
|
key: section.key,
|
||||||
displayName: section.displayName,
|
displayName: section.displayName,
|
||||||
prefix: section.prefix,
|
prefix: section.prefix,
|
||||||
sortOrder: section.sortOrder,
|
sortOrder: section.sortOrder,
|
||||||
circuits: [],
|
circuits: [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section]));
|
||||||
|
|
||||||
for (const circuit of circuits) {
|
for (const circuit of circuits) {
|
||||||
const section = sectionById.get(circuit.sectionId);
|
const section = sectionById.get(circuit.sectionId);
|
||||||
if (!section || section.circuitListId !== circuit.circuitListId) {
|
if (!section || section.circuitListId !== circuit.circuitListId) {
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
||||||
|
legacyConsumerId: row.legacyConsumerId ?? undefined,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
name: row.name,
|
||||||
|
displayName: row.displayName,
|
||||||
|
phaseType: row.phaseType ?? undefined,
|
||||||
|
connectionKind: row.connectionKind ?? undefined,
|
||||||
|
costGroup: row.costGroup ?? undefined,
|
||||||
|
category: row.category ?? undefined,
|
||||||
|
level: row.level ?? undefined,
|
||||||
|
roomId: row.roomId ?? undefined,
|
||||||
|
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
||||||
|
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
||||||
|
quantity: row.quantity,
|
||||||
|
powerPerUnit: row.powerPerUnit,
|
||||||
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
|
cosPhi: row.cosPhi ?? undefined,
|
||||||
|
remark: row.remark ?? undefined,
|
||||||
|
overriddenFields: row.overriddenFields ?? undefined,
|
||||||
|
rowTotalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||||
|
}));
|
||||||
|
const circuitTotalPower = calculateCircuitTotalPower(deviceRows);
|
||||||
|
|
||||||
|
sectionBlocks.get(section.id)?.circuits.push({
|
||||||
|
id: circuit.id,
|
||||||
|
circuitListId: circuit.circuitListId,
|
||||||
|
sectionId: circuit.sectionId,
|
||||||
|
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||||
|
displayName: circuit.displayName ?? undefined,
|
||||||
|
sortOrder: circuit.sortOrder,
|
||||||
|
protectionType: circuit.protectionType ?? undefined,
|
||||||
|
protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined,
|
||||||
|
protectionCharacteristic: circuit.protectionCharacteristic ?? undefined,
|
||||||
|
cableType: circuit.cableType ?? undefined,
|
||||||
|
cableCrossSection: circuit.cableCrossSection ?? undefined,
|
||||||
|
cableLength: circuit.cableLength ?? undefined,
|
||||||
|
rcdAssignment: circuit.rcdAssignment ?? undefined,
|
||||||
|
terminalDesignation: circuit.terminalDesignation ?? undefined,
|
||||||
|
voltage: circuit.voltage ?? undefined,
|
||||||
|
status: circuit.status ?? undefined,
|
||||||
|
isReserve: Boolean(circuit.isReserve),
|
||||||
|
remark: circuit.remark ?? undefined,
|
||||||
|
circuitTotalPower,
|
||||||
|
deviceRows,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
|
||||||
legacyConsumerId: row.legacyConsumerId ?? undefined,
|
|
||||||
sortOrder: row.sortOrder,
|
|
||||||
name: row.name,
|
|
||||||
displayName: row.displayName,
|
|
||||||
phaseType: row.phaseType ?? undefined,
|
|
||||||
connectionKind: row.connectionKind ?? undefined,
|
|
||||||
costGroup: row.costGroup ?? undefined,
|
|
||||||
category: row.category ?? undefined,
|
|
||||||
level: row.level ?? undefined,
|
|
||||||
roomId: row.roomId ?? undefined,
|
|
||||||
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
|
||||||
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
|
||||||
quantity: row.quantity,
|
|
||||||
powerPerUnit: row.powerPerUnit,
|
|
||||||
simultaneityFactor: row.simultaneityFactor,
|
|
||||||
cosPhi: row.cosPhi ?? undefined,
|
|
||||||
remark: row.remark ?? undefined,
|
|
||||||
overriddenFields: row.overriddenFields ?? undefined,
|
|
||||||
rowTotalPower: rowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
|
||||||
}));
|
|
||||||
const circuitTotalPower = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
|
|
||||||
|
|
||||||
sectionBlocks.get(section.id)?.circuits.push({
|
return res.json(tree);
|
||||||
id: circuit.id,
|
} catch (error) {
|
||||||
circuitListId: circuit.circuitListId,
|
if (isMissingCircuitTreeSchemaError(error)) {
|
||||||
sectionId: circuit.sectionId,
|
return res.json({
|
||||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
circuitListId,
|
||||||
displayName: circuit.displayName ?? undefined,
|
sections: [],
|
||||||
sortOrder: circuit.sortOrder,
|
warning:
|
||||||
protectionType: circuit.protectionType ?? undefined,
|
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||||
protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined,
|
});
|
||||||
protectionCharacteristic: circuit.protectionCharacteristic ?? undefined,
|
}
|
||||||
cableType: circuit.cableType ?? undefined,
|
throw error;
|
||||||
cableCrossSection: circuit.cableCrossSection ?? undefined,
|
|
||||||
cableLength: circuit.cableLength ?? undefined,
|
|
||||||
rcdAssignment: circuit.rcdAssignment ?? undefined,
|
|
||||||
terminalDesignation: circuit.terminalDesignation ?? undefined,
|
|
||||||
voltage: circuit.voltage ?? undefined,
|
|
||||||
status: circuit.status ?? undefined,
|
|
||||||
isReserve: Boolean(circuit.isReserve),
|
|
||||||
remark: circuit.remark ?? undefined,
|
|
||||||
circuitTotalPower,
|
|
||||||
deviceRows,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ ...tree, migrationReport });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 { consumerRouter } from "./routes/consumer.routes.js";
|
||||||
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
import { globalDeviceRouter } from "./routes/global-device.routes.js";
|
||||||
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||||
@@ -15,6 +18,9 @@ app.get("/health", (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use("/api/projects", projectRouter);
|
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/consumers", consumerRouter);
|
||||||
app.use("/api/global-devices", globalDeviceRouter);
|
app.use("/api/global-devices", globalDeviceRouter);
|
||||||
app.use("/api/project-devices", projectDeviceRouter);
|
app.use("/api/project-devices", projectDeviceRouter);
|
||||||
|
|||||||
@@ -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