From b8995b3a1b2ae1bf25429de81809e5f92e070d1e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 3 May 2026 21:16:52 +0200 Subject: [PATCH] Phase 1A done --- package.json | 4 +- .../migrations/0008_circuit_first_model.sql | 99 ++++++ .../circuit-device-row.repository.ts | 61 ++++ .../circuit-section.repository.ts | 42 +++ src/db/repositories/circuit.repository.ts | 50 +++ src/db/repositories/consumer.repository.ts | 4 + src/db/schema/circuit-device-rows.ts | 35 +++ src/db/schema/circuit-sections.ts | 21 ++ src/db/schema/circuits.ts | 33 ++ .../legacy-consumer-circuit-migrations.ts | 19 ++ .../legacy-consumer-migration-report.ts | 24 ++ src/domain/models/circuit-device-row.model.ts | 24 ++ src/domain/models/circuit-section.model.ts | 9 + src/domain/models/circuit-tree.model.ts | 72 +++++ src/domain/models/circuit.model.ts | 21 ++ .../legacy-consumer-migration-planner.ts | 57 ++++ .../legacy-consumer-migration.service.ts | 289 ++++++++++++++++++ .../controllers/circuit-tree.controller.ts | 113 +++++++ .../distribution-board.controller.ts | 5 +- src/server/routes/project.routes.ts | 2 + .../legacy-consumer-migration-planner.test.ts | 57 ++++ 21 files changed, 1038 insertions(+), 3 deletions(-) create mode 100644 src/db/migrations/0008_circuit_first_model.sql create mode 100644 src/db/repositories/circuit-device-row.repository.ts create mode 100644 src/db/repositories/circuit-section.repository.ts create mode 100644 src/db/repositories/circuit.repository.ts create mode 100644 src/db/schema/circuit-device-rows.ts create mode 100644 src/db/schema/circuit-sections.ts create mode 100644 src/db/schema/circuits.ts create mode 100644 src/db/schema/legacy-consumer-circuit-migrations.ts create mode 100644 src/db/schema/legacy-consumer-migration-report.ts create mode 100644 src/domain/models/circuit-device-row.model.ts create mode 100644 src/domain/models/circuit-section.model.ts create mode 100644 src/domain/models/circuit-tree.model.ts create mode 100644 src/domain/models/circuit.model.ts create mode 100644 src/domain/services/legacy-consumer-migration-planner.ts create mode 100644 src/domain/services/legacy-consumer-migration.service.ts create mode 100644 src/server/controllers/circuit-tree.controller.ts create mode 100644 tests/legacy-consumer-migration-planner.test.ts diff --git a/package.json b/package.json index b2cc779..cf84033 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts", - "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.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", + "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", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate" }, diff --git a/src/db/migrations/0008_circuit_first_model.sql b/src/db/migrations/0008_circuit_first_model.sql new file mode 100644 index 0000000..485a911 --- /dev/null +++ b/src/db/migrations/0008_circuit_first_model.sql @@ -0,0 +1,99 @@ +CREATE TABLE `circuit_sections` ( + `id` text PRIMARY KEY NOT NULL, + `circuit_list_id` text NOT NULL, + `key` text NOT NULL, + `display_name` text NOT NULL, + `prefix` text NOT NULL, + `sort_order` integer NOT NULL DEFAULT 0, + FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `circuit_sections_list_key_unique` ON `circuit_sections` (`circuit_list_id`,`key`); +--> statement-breakpoint +CREATE UNIQUE INDEX `circuit_sections_list_prefix_unique` ON `circuit_sections` (`circuit_list_id`,`prefix`); +--> statement-breakpoint + +CREATE TABLE `circuits` ( + `id` text PRIMARY KEY NOT NULL, + `circuit_list_id` text NOT NULL, + `section_id` text NOT NULL, + `equipment_identifier` text NOT NULL, + `display_name` text, + `sort_order` integer NOT NULL DEFAULT 0, + `protection_type` text, + `protection_rated_current` real, + `protection_characteristic` text, + `cable_type` text, + `cable_cross_section` text, + `cable_length` real, + `rcd_assignment` text, + `terminal_designation` text, + `voltage` real, + `status` text, + `is_reserve` integer NOT NULL DEFAULT 0, + `remark` text, + FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`section_id`) REFERENCES `circuit_sections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `circuits_list_equipment_identifier_unique` ON `circuits` (`circuit_list_id`,`equipment_identifier`); +--> statement-breakpoint + +CREATE TABLE `circuit_device_rows` ( + `id` text PRIMARY KEY NOT NULL, + `circuit_id` text NOT NULL, + `linked_project_device_id` text, + `legacy_consumer_id` text, + `sort_order` integer NOT NULL DEFAULT 0, + `name` text NOT NULL, + `display_name` text NOT NULL, + `phase_type` text, + `connection_kind` text, + `cost_group` text, + `category` text, + `level` text, + `room_id` text, + `room_number_snapshot` text, + `room_name_snapshot` text, + `quantity` integer NOT NULL, + `power_per_unit` real NOT NULL, + `simultaneity_factor` real NOT NULL, + `cos_phi` real, + `remark` text, + `overridden_fields` text, + FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`linked_project_device_id`) REFERENCES `project_devices`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint + +CREATE TABLE `legacy_consumer_circuit_migrations` ( + `consumer_id` text PRIMARY KEY NOT NULL, + `circuit_id` text NOT NULL, + `circuit_device_row_id` text NOT NULL, + `circuit_list_id` text NOT NULL, + `created_at_iso` text NOT NULL, + FOREIGN KEY (`circuit_id`) REFERENCES `circuits`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`circuit_device_row_id`) REFERENCES `circuit_device_rows`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint + +CREATE TABLE `legacy_consumer_migration_reports` ( + `id` text PRIMARY KEY NOT NULL, + `circuit_list_id` text NOT NULL, + `legacy_consumer_count` integer NOT NULL, + `created_circuit_count` integer NOT NULL, + `created_device_row_count` integer NOT NULL, + `duplicate_grouped_count` integer NOT NULL, + `generated_identifier_count` integer NOT NULL, + `unassigned_row_count` integer NOT NULL, + `warnings_json` text NOT NULL, + `generated_identifiers_json` text NOT NULL, + `duplicate_groups_json` text NOT NULL, + `created_at_iso` text NOT NULL, + FOREIGN KEY (`circuit_list_id`) REFERENCES `circuit_lists`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `legacy_consumer_migration_reports_list_unique` ON `legacy_consumer_migration_reports` (`circuit_list_id`); + diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts new file mode 100644 index 0000000..d36bf7a --- /dev/null +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -0,0 +1,61 @@ +import crypto from "node:crypto"; +import { asc, inArray } from "drizzle-orm"; +import { db } from "../client.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; + +export class CircuitDeviceRowRepository { + async listByCircuitList(circuitIds: string[]) { + if (!circuitIds.length) { + return []; + } + return db + .select() + .from(circuitDeviceRows) + .where(inArray(circuitDeviceRows.circuitId, circuitIds)) + .orderBy(asc(circuitDeviceRows.sortOrder)); + } + + async create(input: { + circuitId: string; + linkedProjectDeviceId?: string; + legacyConsumerId?: string; + sortOrder: number; + name: string; + displayName: string; + phaseType?: string; + connectionKind?: string; + costGroup?: string; + category?: string; + roomId?: string; + roomNumberSnapshot?: string; + roomNameSnapshot?: string; + quantity: number; + powerPerUnit: number; + simultaneityFactor: number; + cosPhi?: number; + remark?: string; + }) { + await db.insert(circuitDeviceRows).values({ + id: crypto.randomUUID(), + circuitId: input.circuitId, + linkedProjectDeviceId: input.linkedProjectDeviceId ?? null, + legacyConsumerId: input.legacyConsumerId ?? null, + sortOrder: input.sortOrder, + name: input.name, + displayName: input.displayName, + phaseType: input.phaseType ?? null, + connectionKind: input.connectionKind ?? null, + costGroup: input.costGroup ?? null, + category: input.category ?? 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: null, + }); + } +} diff --git a/src/db/repositories/circuit-section.repository.ts b/src/db/repositories/circuit-section.repository.ts new file mode 100644 index 0000000..b17425a --- /dev/null +++ b/src/db/repositories/circuit-section.repository.ts @@ -0,0 +1,42 @@ +import crypto from "node:crypto"; +import { and, asc, eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { circuitSections } from "../schema/circuit-sections.js"; + +export class CircuitSectionRepository { + async listByCircuitList(circuitListId: string) { + return db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, circuitListId)) + .orderBy(asc(circuitSections.sortOrder)); + } + + async createDefaults(circuitListId: string) { + const defaults = [ + { key: "lighting", displayName: "Lighting", prefix: "-1F", sortOrder: 10 }, + { key: "single_phase", displayName: "Single-phase circuits", prefix: "-2F", sortOrder: 20 }, + { key: "three_phase", displayName: "Three-phase circuits", prefix: "-3F", sortOrder: 30 }, + { key: "unassigned", displayName: "Unassigned", prefix: "-UF", sortOrder: 90 }, + ]; + + for (const entry of defaults) { + const existing = await db + .select({ id: circuitSections.id }) + .from(circuitSections) + .where( + and(eq(circuitSections.circuitListId, circuitListId), eq(circuitSections.key, entry.key)) + ) + .limit(1); + if (existing.length) { + continue; + } + await db.insert(circuitSections).values({ + id: crypto.randomUUID(), + circuitListId, + ...entry, + }); + } + } +} + diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts new file mode 100644 index 0000000..450244b --- /dev/null +++ b/src/db/repositories/circuit.repository.ts @@ -0,0 +1,50 @@ +import crypto from "node:crypto"; +import { asc, eq } from "drizzle-orm"; +import { db } from "../client.js"; +import { circuits } from "../schema/circuits.js"; + +export class CircuitRepository { + async listByCircuitList(circuitListId: string) { + return db + .select() + .from(circuits) + .where(eq(circuits.circuitListId, circuitListId)) + .orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); + } + + async create(input: { + circuitListId: string; + sectionId: string; + equipmentIdentifier: string; + displayName?: string; + sortOrder: number; + protectionType?: string; + protectionRatedCurrent?: number; + protectionCharacteristic?: string; + cableType?: string; + cableCrossSection?: string; + cableLength?: number; + voltage?: number; + remark?: string; + }) { + const id = crypto.randomUUID(); + await db.insert(circuits).values({ + id, + circuitListId: input.circuitListId, + sectionId: input.sectionId, + equipmentIdentifier: input.equipmentIdentifier, + displayName: input.displayName ?? null, + sortOrder: input.sortOrder, + protectionType: input.protectionType ?? null, + protectionRatedCurrent: input.protectionRatedCurrent ?? null, + protectionCharacteristic: input.protectionCharacteristic ?? null, + cableType: input.cableType ?? null, + cableCrossSection: input.cableCrossSection ?? null, + cableLength: input.cableLength ?? null, + voltage: input.voltage ?? null, + remark: input.remark ?? null, + }); + return id; + } +} + diff --git a/src/db/repositories/consumer.repository.ts b/src/db/repositories/consumer.repository.ts index 7973ae0..fc92e40 100644 --- a/src/db/repositories/consumer.repository.ts +++ b/src/db/repositories/consumer.repository.ts @@ -12,6 +12,10 @@ export class ConsumerRepository { return db.select().from(consumers).where(eq(consumers.projectId, projectId)); } + async listByCircuitList(circuitListId: string) { + return db.select().from(consumers).where(eq(consumers.circuitListId, circuitListId)); + } + async create(input: CreateConsumerInput) { const id = crypto.randomUUID(); const normalizedName = input.name?.trim() || "Unbenannter Eintrag"; diff --git a/src/db/schema/circuit-device-rows.ts b/src/db/schema/circuit-device-rows.ts new file mode 100644 index 0000000..6fefd18 --- /dev/null +++ b/src/db/schema/circuit-device-rows.ts @@ -0,0 +1,35 @@ +import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { circuits } from "./circuits.js"; +import { projectDevices } from "./project-devices.js"; +import { rooms } from "./rooms.js"; + +export const circuitDeviceRows = sqliteTable("circuit_device_rows", { + id: text("id").primaryKey(), + circuitId: text("circuit_id") + .notNull() + .references(() => circuits.id, { onDelete: "cascade" }), + linkedProjectDeviceId: text("linked_project_device_id").references(() => projectDevices.id, { + onDelete: "set null", + }), + legacyConsumerId: text("legacy_consumer_id"), + sortOrder: integer("sort_order").notNull().default(0), + name: text("name").notNull(), + displayName: text("display_name").notNull(), + phaseType: text("phase_type"), + connectionKind: text("connection_kind"), + costGroup: text("cost_group"), + category: text("category"), + level: text("level"), + roomId: text("room_id").references(() => rooms.id, { + onDelete: "set null", + }), + roomNumberSnapshot: text("room_number_snapshot"), + roomNameSnapshot: text("room_name_snapshot"), + quantity: integer("quantity").notNull(), + powerPerUnit: real("power_per_unit").notNull(), + simultaneityFactor: real("simultaneity_factor").notNull(), + cosPhi: real("cos_phi"), + remark: text("remark"), + overriddenFields: text("overridden_fields"), +}); + diff --git a/src/db/schema/circuit-sections.ts b/src/db/schema/circuit-sections.ts new file mode 100644 index 0000000..cb11231 --- /dev/null +++ b/src/db/schema/circuit-sections.ts @@ -0,0 +1,21 @@ +import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { circuitLists } from "./circuit-lists.js"; + +export const circuitSections = sqliteTable( + "circuit_sections", + { + id: text("id").primaryKey(), + circuitListId: text("circuit_list_id") + .notNull() + .references(() => circuitLists.id, { onDelete: "cascade" }), + key: text("key").notNull(), + displayName: text("display_name").notNull(), + prefix: text("prefix").notNull(), + sortOrder: integer("sort_order").notNull().default(0), + }, + (table) => [ + unique("circuit_sections_list_key_unique").on(table.circuitListId, table.key), + unique("circuit_sections_list_prefix_unique").on(table.circuitListId, table.prefix), + ] +); + diff --git a/src/db/schema/circuits.ts b/src/db/schema/circuits.ts new file mode 100644 index 0000000..b65fb97 --- /dev/null +++ b/src/db/schema/circuits.ts @@ -0,0 +1,33 @@ +import { integer, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { circuitLists } from "./circuit-lists.js"; +import { circuitSections } from "./circuit-sections.js"; + +export const circuits = sqliteTable( + "circuits", + { + id: text("id").primaryKey(), + circuitListId: text("circuit_list_id") + .notNull() + .references(() => circuitLists.id, { onDelete: "cascade" }), + sectionId: text("section_id") + .notNull() + .references(() => circuitSections.id, { onDelete: "cascade" }), + equipmentIdentifier: text("equipment_identifier").notNull(), + displayName: text("display_name"), + sortOrder: integer("sort_order").notNull().default(0), + protectionType: text("protection_type"), + protectionRatedCurrent: real("protection_rated_current"), + protectionCharacteristic: text("protection_characteristic"), + cableType: text("cable_type"), + cableCrossSection: text("cable_cross_section"), + cableLength: real("cable_length"), + rcdAssignment: text("rcd_assignment"), + terminalDesignation: text("terminal_designation"), + voltage: real("voltage"), + status: text("status"), + isReserve: integer("is_reserve").notNull().default(0), + remark: text("remark"), + }, + (table) => [unique("circuits_list_equipment_identifier_unique").on(table.circuitListId, table.equipmentIdentifier)] +); + diff --git a/src/db/schema/legacy-consumer-circuit-migrations.ts b/src/db/schema/legacy-consumer-circuit-migrations.ts new file mode 100644 index 0000000..04f6bbb --- /dev/null +++ b/src/db/schema/legacy-consumer-circuit-migrations.ts @@ -0,0 +1,19 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { circuitDeviceRows } from "./circuit-device-rows.js"; +import { circuitLists } from "./circuit-lists.js"; +import { circuits } from "./circuits.js"; + +export const legacyConsumerCircuitMigrations = sqliteTable("legacy_consumer_circuit_migrations", { + consumerId: text("consumer_id").primaryKey(), + circuitId: text("circuit_id") + .notNull() + .references(() => circuits.id, { onDelete: "cascade" }), + circuitDeviceRowId: text("circuit_device_row_id") + .notNull() + .references(() => circuitDeviceRows.id, { onDelete: "cascade" }), + circuitListId: text("circuit_list_id") + .notNull() + .references(() => circuitLists.id, { onDelete: "cascade" }), + createdAtIso: text("created_at_iso").notNull(), +}); + diff --git a/src/db/schema/legacy-consumer-migration-report.ts b/src/db/schema/legacy-consumer-migration-report.ts new file mode 100644 index 0000000..0084ef9 --- /dev/null +++ b/src/db/schema/legacy-consumer-migration-report.ts @@ -0,0 +1,24 @@ +import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; +import { circuitLists } from "./circuit-lists.js"; + +export const legacyConsumerMigrationReports = sqliteTable( + "legacy_consumer_migration_reports", + { + id: text("id").primaryKey(), + circuitListId: text("circuit_list_id") + .notNull() + .references(() => circuitLists.id, { onDelete: "cascade" }), + legacyConsumerCount: integer("legacy_consumer_count").notNull(), + createdCircuitCount: integer("created_circuit_count").notNull(), + createdDeviceRowCount: integer("created_device_row_count").notNull(), + duplicateGroupedCount: integer("duplicate_grouped_count").notNull(), + generatedIdentifierCount: integer("generated_identifier_count").notNull(), + unassignedRowCount: integer("unassigned_row_count").notNull(), + warningsJson: text("warnings_json").notNull(), + generatedIdentifiersJson: text("generated_identifiers_json").notNull(), + duplicateGroupsJson: text("duplicate_groups_json").notNull(), + createdAtIso: text("created_at_iso").notNull(), + }, + (table) => [unique("legacy_consumer_migration_reports_list_unique").on(table.circuitListId)] +); + diff --git a/src/domain/models/circuit-device-row.model.ts b/src/domain/models/circuit-device-row.model.ts new file mode 100644 index 0000000..f3adfa8 --- /dev/null +++ b/src/domain/models/circuit-device-row.model.ts @@ -0,0 +1,24 @@ +export interface CircuitDeviceRow { + id: string; + circuitId: 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; +} + diff --git a/src/domain/models/circuit-section.model.ts b/src/domain/models/circuit-section.model.ts new file mode 100644 index 0000000..bba2c4e --- /dev/null +++ b/src/domain/models/circuit-section.model.ts @@ -0,0 +1,9 @@ +export interface CircuitSection { + id: string; + circuitListId: string; + key: string; + displayName: string; + prefix: string; + sortOrder: number; +} + diff --git a/src/domain/models/circuit-tree.model.ts b/src/domain/models/circuit-tree.model.ts new file mode 100644 index 0000000..144d37a --- /dev/null +++ b/src/domain/models/circuit-tree.model.ts @@ -0,0 +1,72 @@ +export interface CircuitTreeDeviceRow { + 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 CircuitTreeCircuit { + 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: CircuitTreeDeviceRow[]; +} + +export interface CircuitTreeSectionBlock { + id: string; + key: string; + displayName: string; + prefix: string; + sortOrder: number; + circuits: CircuitTreeCircuit[]; +} + +export interface CircuitTreeResponse { + circuitListId: string; + sections: CircuitTreeSectionBlock[]; +} + +export interface LegacyMigrationReport { + circuitListId: string; + legacyConsumerCount: number; + createdCircuitCount: number; + createdDeviceRowCount: number; + groupedDuplicateCircuitNumbers: Array<{ normalizedCircuitNumber: string; count: number }>; + generatedIdentifiers: string[]; + unassignedRows: Array<{ consumerId: string; reason: string }>; + warnings: string[]; +} + diff --git a/src/domain/models/circuit.model.ts b/src/domain/models/circuit.model.ts new file mode 100644 index 0000000..4e7b25e --- /dev/null +++ b/src/domain/models/circuit.model.ts @@ -0,0 +1,21 @@ +export interface Circuit { + 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; +} + diff --git a/src/domain/services/legacy-consumer-migration-planner.ts b/src/domain/services/legacy-consumer-migration-planner.ts new file mode 100644 index 0000000..b60bc8a --- /dev/null +++ b/src/domain/services/legacy-consumer-migration-planner.ts @@ -0,0 +1,57 @@ +export interface LegacyConsumerForPlanning { + id: string; + circuitNumber: string | null; + category: string | null; + phaseType: string | null; + phaseCount: number | null; +} + +export function normalizeCircuitNumber(value: string | null): string | null { + if (!value) { + return null; + } + const trimmed = value.trim().toUpperCase(); + if (!trimmed) { + return null; + } + if (!/^-\d+F\d+$/.test(trimmed)) { + return null; + } + return trimmed; +} + +export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanning): string | null { + const category = (consumer.category ?? "").toLowerCase(); + if (category.includes("light") || category.includes("beleuchtung")) { + return "lighting"; + } + if (consumer.phaseCount === 3) { + return "three_phase"; + } + if (consumer.phaseCount === 1) { + return "single_phase"; + } + + const phaseType = (consumer.phaseType ?? "").toLowerCase(); + if (phaseType.includes("three") || phaseType.includes("3")) { + return "three_phase"; + } + if (phaseType.includes("single") || phaseType.includes("1")) { + return "single_phase"; + } + return null; +} + +export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: string): string | null { + if (equipmentIdentifier.startsWith("-1F")) { + return "lighting"; + } + if (equipmentIdentifier.startsWith("-2F")) { + return "single_phase"; + } + if (equipmentIdentifier.startsWith("-3F")) { + return "three_phase"; + } + return null; +} + diff --git a/src/domain/services/legacy-consumer-migration.service.ts b/src/domain/services/legacy-consumer-migration.service.ts new file mode 100644 index 0000000..f98e203 --- /dev/null +++ b/src/domain/services/legacy-consumer-migration.service.ts @@ -0,0 +1,289 @@ +import crypto from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { db } from "../../db/client.js"; +import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; +import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; +import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; +import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; +import { ConsumerRepository } from "../../db/repositories/consumer.repository.js"; +import { RoomRepository } from "../../db/repositories/room.repository.js"; +import { circuitDeviceRows } from "../../db/schema/circuit-device-rows.js"; +import { legacyConsumerCircuitMigrations } from "../../db/schema/legacy-consumer-circuit-migrations.js"; +import { legacyConsumerMigrationReports } from "../../db/schema/legacy-consumer-migration-report.js"; +import type { LegacyMigrationReport } from "../models/circuit-tree.model.js"; +import { + inferSectionKeyFromEquipmentIdentifier, + inferSectionKeyFromLegacyInput, + normalizeCircuitNumber, +} from "./legacy-consumer-migration-planner.js"; + +type LegacyConsumerRow = Awaited>[number]; + +function parseEquipmentSequence(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 LegacyConsumerMigrationService { + private readonly circuitListRepository = new CircuitListRepository(); + private readonly sectionRepository = new CircuitSectionRepository(); + private readonly circuitRepository = new CircuitRepository(); + private readonly circuitDeviceRowRepository = new CircuitDeviceRowRepository(); + private readonly consumerRepository = new ConsumerRepository(); + private readonly roomRepository = new RoomRepository(); + + async migrateCircuitList(projectId: string, circuitListId: string): Promise { + const list = await this.circuitListRepository.findById(projectId, circuitListId); + if (!list) { + throw new Error("Circuit list not found in project."); + } + + await this.sectionRepository.createDefaults(circuitListId); + const sections = await this.sectionRepository.listByCircuitList(circuitListId); + const sectionByKey = new Map(sections.map((section) => [section.key, section])); + const unassignedSection = sectionByKey.get("unassigned"); + if (!unassignedSection) { + throw new Error("Unassigned section is required."); + } + + const existingCircuits = await this.circuitRepository.listByCircuitList(circuitListId); + const usedEquipmentIdentifiers = new Set( + existingCircuits.map((circuit) => circuit.equipmentIdentifier.toUpperCase()) + ); + + const legacyConsumers = await this.consumerRepository.listByCircuitList(circuitListId); + const rooms = await this.roomRepository.listByProject(projectId); + const roomById = new Map(rooms.map((room) => [room.id, room])); + + const report: LegacyMigrationReport = { + circuitListId, + legacyConsumerCount: legacyConsumers.length, + createdCircuitCount: 0, + createdDeviceRowCount: 0, + groupedDuplicateCircuitNumbers: [], + generatedIdentifiers: [], + unassignedRows: [], + warnings: [], + }; + + const migratedRows = await db + .select({ consumerId: legacyConsumerCircuitMigrations.consumerId }) + .from(legacyConsumerCircuitMigrations) + .where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId)); + const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId)); + const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id)); + + const byNormalizedCircuitNumber = new Map(); + const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = []; + for (const consumer of consumersToMigrate) { + const normalized = normalizeCircuitNumber(consumer.circuitNumber ?? null); + if (!normalized) { + withoutNormalizedCircuitNumber.push(consumer); + continue; + } + if (!byNormalizedCircuitNumber.has(normalized)) { + byNormalizedCircuitNumber.set(normalized, []); + } + byNormalizedCircuitNumber.get(normalized)!.push(consumer); + } + + report.groupedDuplicateCircuitNumbers = [...byNormalizedCircuitNumber.entries()] + .filter(([, grouped]) => grouped.length > 1) + .map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length })); + + const groups: Array<{ + equipmentIdentifier: string | null; + consumers: LegacyConsumerRow[]; + inferredSectionKey: string | null; + isGeneratedIdentifier: boolean; + }> = []; + + for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) { + groups.push({ + equipmentIdentifier: normalizedCircuitNumber, + consumers: grouped, + inferredSectionKey: inferSectionKeyFromEquipmentIdentifier(normalizedCircuitNumber), + isGeneratedIdentifier: false, + }); + } + + for (const consumer of withoutNormalizedCircuitNumber) { + groups.push({ + equipmentIdentifier: null, + consumers: [consumer], + inferredSectionKey: inferSectionKeyFromLegacyInput(consumer), + isGeneratedIdentifier: true, + }); + } + + let nextSortOrder = existingCircuits.length ? Math.max(...existingCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10; + + const maxBySectionPrefix = new Map(); + for (const circuit of existingCircuits) { + const section = sections.find((entry) => entry.id === circuit.sectionId); + if (!section) { + continue; + } + const sequence = parseEquipmentSequence(circuit.equipmentIdentifier.toUpperCase(), section.prefix.toUpperCase()); + if (sequence === null) { + continue; + } + const current = maxBySectionPrefix.get(section.prefix.toUpperCase()) ?? 0; + maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence)); + } + + for (const group of groups) { + const representative = group.consumers[0]; + let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null; + if (!section) { + section = unassignedSection; + } + + let equipmentIdentifier = group.equipmentIdentifier; + if (!equipmentIdentifier || usedEquipmentIdentifiers.has(equipmentIdentifier.toUpperCase())) { + const prefix = section.prefix.toUpperCase(); + const current = maxBySectionPrefix.get(prefix) ?? 0; + const generatedSequence = current + 1; + maxBySectionPrefix.set(prefix, generatedSequence); + equipmentIdentifier = `${section.prefix}${generatedSequence}`; + report.generatedIdentifiers.push(equipmentIdentifier); + } + + if (!group.inferredSectionKey && group.isGeneratedIdentifier) { + for (const consumer of group.consumers) { + report.unassignedRows.push({ + consumerId: consumer.id, + reason: "Missing or invalid circuit number and no section inferred from phase/category.", + }); + } + } + + usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase()); + + const circuitId = await this.circuitRepository.create({ + circuitListId, + sectionId: section.id, + equipmentIdentifier, + displayName: representative.description ?? representative.name, + sortOrder: nextSortOrder, + protectionType: representative.protectionType ?? undefined, + protectionRatedCurrent: representative.protectionRatedCurrent ?? undefined, + protectionCharacteristic: representative.protectionCharacteristic ?? undefined, + cableType: representative.cableType ?? undefined, + cableCrossSection: representative.cableCrossSection ?? undefined, + voltage: representative.voltageV ?? undefined, + remark: undefined, + }); + report.createdCircuitCount += 1; + nextSortOrder += 10; + + let rowSortOrder = 10; + for (const consumer of group.consumers) { + const room = consumer.roomId ? roomById.get(consumer.roomId) : undefined; + let noteRemark = consumer.note?.trim() || consumer.comment?.trim() || undefined; + if (consumer.deviceType && consumer.deviceType.trim()) { + const legacyDeviceTypeNote = `Legacy deviceType: ${consumer.deviceType.trim()}`; + noteRemark = noteRemark ? `${noteRemark} | ${legacyDeviceTypeNote}` : legacyDeviceTypeNote; + } + + await this.circuitDeviceRowRepository.create({ + circuitId, + linkedProjectDeviceId: consumer.projectDeviceId ?? undefined, + legacyConsumerId: consumer.id, + sortOrder: rowSortOrder, + name: consumer.name, + displayName: consumer.description ?? consumer.name, + phaseType: consumer.phaseType ?? undefined, + connectionKind: undefined, + costGroup: consumer.tradeOrCostGroup ?? undefined, + category: consumer.category ?? undefined, + roomId: consumer.roomId ?? undefined, + roomNumberSnapshot: room?.roomNumber, + roomNameSnapshot: room?.roomName, + quantity: consumer.quantity, + powerPerUnit: consumer.installedPowerPerUnitKw, + simultaneityFactor: consumer.demandFactor, + cosPhi: consumer.powerFactor ?? undefined, + remark: noteRemark, + }); + report.createdDeviceRowCount += 1; + rowSortOrder += 10; + + const [createdRow] = await db + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(and(eq(circuitDeviceRows.circuitId, circuitId), eq(circuitDeviceRows.legacyConsumerId, consumer.id))) + .orderBy(circuitDeviceRows.sortOrder) + .limit(1); + if (!createdRow) { + throw new Error("Failed to resolve created circuit device row."); + } + + await db.insert(legacyConsumerCircuitMigrations).values({ + consumerId: consumer.id, + circuitId, + circuitDeviceRowId: createdRow.id, + circuitListId, + createdAtIso: new Date().toISOString(), + }); + } + } + + if (consumersToMigrate.some((consumer) => Boolean(consumer.comment?.trim()))) { + report.warnings.push( + "Legacy comment field was mapped to circuit_device_rows.remark because circuit-level vs row-level intent is ambiguous." + ); + } + + for (const row of report.unassignedRows) { + report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`); + } + + const existingReport = await db + .select({ id: legacyConsumerMigrationReports.id }) + .from(legacyConsumerMigrationReports) + .where(eq(legacyConsumerMigrationReports.circuitListId, circuitListId)) + .limit(1); + if (existingReport.length) { + await db + .update(legacyConsumerMigrationReports) + .set({ + legacyConsumerCount: report.legacyConsumerCount, + createdCircuitCount: report.createdCircuitCount, + createdDeviceRowCount: report.createdDeviceRowCount, + duplicateGroupedCount: report.groupedDuplicateCircuitNumbers.length, + generatedIdentifierCount: report.generatedIdentifiers.length, + unassignedRowCount: report.unassignedRows.length, + warningsJson: JSON.stringify(report.warnings), + generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers), + duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers), + createdAtIso: new Date().toISOString(), + }) + .where(eq(legacyConsumerMigrationReports.id, existingReport[0].id)); + } else { + await db.insert(legacyConsumerMigrationReports).values({ + id: crypto.randomUUID(), + circuitListId, + legacyConsumerCount: report.legacyConsumerCount, + createdCircuitCount: report.createdCircuitCount, + createdDeviceRowCount: report.createdDeviceRowCount, + duplicateGroupedCount: report.groupedDuplicateCircuitNumbers.length, + generatedIdentifierCount: report.generatedIdentifiers.length, + unassignedRowCount: report.unassignedRows.length, + warningsJson: JSON.stringify(report.warnings), + generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers), + duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers), + createdAtIso: new Date().toISOString(), + }); + } + + return report; + } +} diff --git a/src/server/controllers/circuit-tree.controller.ts b/src/server/controllers/circuit-tree.controller.ts new file mode 100644 index 0000000..6c09100 --- /dev/null +++ b/src/server/controllers/circuit-tree.controller.ts @@ -0,0 +1,113 @@ +import type { Request, Response } from "express"; +import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; +import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; +import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; +import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; +import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js"; +import { LegacyConsumerMigrationService } from "../../domain/services/legacy-consumer-migration.service.js"; + +const circuitListRepository = new CircuitListRepository(); +const circuitSectionRepository = new CircuitSectionRepository(); +const circuitRepository = new CircuitRepository(); +const circuitDeviceRowRepository = new CircuitDeviceRowRepository(); +const legacyConsumerMigrationService = new LegacyConsumerMigrationService(); + +function rowTotalPower(quantity: number, powerPerUnit: number, simultaneityFactor: number): number { + return quantity * powerPerUnit * simultaneityFactor; +} + +export async function getCircuitTree(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 list = await circuitListRepository.findById(projectId, circuitListId); + if (!list) { + return res.status(404).json({ error: "Circuit list not found" }); + } + + const migrationReport = await legacyConsumerMigrationService.migrateCircuitList(projectId, circuitListId); + const sections = await circuitSectionRepository.listByCircuitList(circuitListId); + const circuits = await circuitRepository.listByCircuitList(circuitListId); + const rows = await circuitDeviceRowRepository.listByCircuitList(circuits.map((entry) => entry.id)); + + const sectionById = new Map(sections.map((section) => [section.id, section])); + const rowsByCircuitId = new Map(); + for (const row of rows) { + if (!rowsByCircuitId.has(row.circuitId)) { + rowsByCircuitId.set(row.circuitId, []); + } + rowsByCircuitId.get(row.circuitId)!.push(row); + } + + const tree: CircuitTreeResponse = { + circuitListId, + sections: sections.map((section) => ({ + id: section.id, + key: section.key, + displayName: section.displayName, + prefix: section.prefix, + sortOrder: section.sortOrder, + circuits: [], + })), + }; + const sectionBlocks = new Map(tree.sections.map((section) => [section.id, section])); + + for (const circuit of circuits) { + const section = sectionById.get(circuit.sectionId); + if (!section || section.circuitListId !== circuit.circuitListId) { + continue; + } + const deviceRows = (rowsByCircuitId.get(circuit.id) ?? []).map((row) => ({ + id: row.id, + linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined, + legacyConsumerId: row.legacyConsumerId ?? undefined, + sortOrder: row.sortOrder, + name: row.name, + displayName: row.displayName, + phaseType: row.phaseType ?? undefined, + connectionKind: row.connectionKind ?? undefined, + costGroup: row.costGroup ?? undefined, + category: row.category ?? undefined, + level: row.level ?? undefined, + roomId: row.roomId ?? undefined, + roomNumberSnapshot: row.roomNumberSnapshot ?? undefined, + roomNameSnapshot: row.roomNameSnapshot ?? undefined, + quantity: row.quantity, + powerPerUnit: row.powerPerUnit, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi ?? undefined, + remark: row.remark ?? undefined, + overriddenFields: row.overriddenFields ?? undefined, + rowTotalPower: rowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor), + })); + const circuitTotalPower = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0); + + sectionBlocks.get(section.id)?.circuits.push({ + id: circuit.id, + circuitListId: circuit.circuitListId, + sectionId: circuit.sectionId, + equipmentIdentifier: circuit.equipmentIdentifier, + displayName: circuit.displayName ?? undefined, + sortOrder: circuit.sortOrder, + protectionType: circuit.protectionType ?? undefined, + protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined, + protectionCharacteristic: circuit.protectionCharacteristic ?? undefined, + cableType: circuit.cableType ?? undefined, + cableCrossSection: circuit.cableCrossSection ?? undefined, + cableLength: circuit.cableLength ?? undefined, + rcdAssignment: circuit.rcdAssignment ?? undefined, + terminalDesignation: circuit.terminalDesignation ?? undefined, + voltage: circuit.voltage ?? undefined, + status: circuit.status ?? undefined, + isReserve: Boolean(circuit.isReserve), + remark: circuit.remark ?? undefined, + circuitTotalPower, + deviceRows, + }); + } + + return res.json({ ...tree, migrationReport }); +} + diff --git a/src/server/controllers/distribution-board.controller.ts b/src/server/controllers/distribution-board.controller.ts index 3c464f9..cc340db 100644 --- a/src/server/controllers/distribution-board.controller.ts +++ b/src/server/controllers/distribution-board.controller.ts @@ -1,9 +1,11 @@ import type { Request, Response } from "express"; import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; +import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js"; const circuitListRepository = new CircuitListRepository(); +const circuitSectionRepository = new CircuitSectionRepository(); const distributionBoardRepository = new DistributionBoardRepository(); export async function listDistributionBoardsByProject(req: Request, res: Response) { @@ -28,10 +30,11 @@ export async function createDistributionBoard(req: Request, res: Response) { } const board = await distributionBoardRepository.create(projectId, parsed.data.name); - await circuitListRepository.createForDistributionBoard({ + const list = await circuitListRepository.createForDistributionBoard({ projectId, distributionBoardId: board.id, name: `${board.name} Stromkreisliste`, }); + await circuitSectionRepository.createDefaults(list.id); return res.status(201).json(board); } diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index f056eda..f2fc216 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -12,6 +12,7 @@ import { import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js"; import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js"; import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; +import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; export const projectRouter = Router(); @@ -22,6 +23,7 @@ projectRouter.put("/:projectId", updateProjectSettings); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject); +projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree); projectRouter.get("/:projectId/floors", listFloorsByProject); projectRouter.post("/:projectId/floors", createFloor); projectRouter.get("/:projectId/rooms", listRoomsByProject); diff --git a/tests/legacy-consumer-migration-planner.test.ts b/tests/legacy-consumer-migration-planner.test.ts new file mode 100644 index 0000000..e541e73 --- /dev/null +++ b/tests/legacy-consumer-migration-planner.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + inferSectionKeyFromEquipmentIdentifier, + inferSectionKeyFromLegacyInput, + normalizeCircuitNumber, +} from "../src/domain/services/legacy-consumer-migration-planner.js"; + +describe("legacy consumer migration planner", () => { + it("normalizes valid circuit numbers and rejects invalid formats", () => { + assert.equal(normalizeCircuitNumber(" -2f12 "), "-2F12"); + assert.equal(normalizeCircuitNumber("2F12"), null); + assert.equal(normalizeCircuitNumber(""), null); + assert.equal(normalizeCircuitNumber(null), null); + }); + + it("infers section from equipment identifier prefix", () => { + assert.equal(inferSectionKeyFromEquipmentIdentifier("-1F4"), "lighting"); + assert.equal(inferSectionKeyFromEquipmentIdentifier("-2F8"), "single_phase"); + assert.equal(inferSectionKeyFromEquipmentIdentifier("-3F1"), "three_phase"); + assert.equal(inferSectionKeyFromEquipmentIdentifier("-9F1"), null); + }); + + it("infers section from category/phase when circuit number is missing", () => { + assert.equal( + inferSectionKeyFromLegacyInput({ + id: "1", + circuitNumber: null, + category: "Beleuchtung", + phaseType: null, + phaseCount: null, + }), + "lighting" + ); + assert.equal( + inferSectionKeyFromLegacyInput({ + id: "2", + circuitNumber: null, + category: null, + phaseType: "three-phase", + phaseCount: null, + }), + "three_phase" + ); + assert.equal( + inferSectionKeyFromLegacyInput({ + id: "3", + circuitNumber: null, + category: null, + phaseType: null, + phaseCount: null, + }), + null + ); + }); +}); +