Phase 1A done
This commit is contained in:
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"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",
|
"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",
|
"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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,10 @@ export class ConsumerRepository {
|
|||||||
return db.select().from(consumers).where(eq(consumers.projectId, projectId));
|
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) {
|
async create(input: CreateConsumerInput) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const normalizedName = input.name?.trim() || "Unbenannter Eintrag";
|
const normalizedName = input.name?.trim() || "Unbenannter Eintrag";
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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)]
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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)]
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface CircuitSection {
|
||||||
|
id: string;
|
||||||
|
circuitListId: string;
|
||||||
|
key: string;
|
||||||
|
displayName: string;
|
||||||
|
prefix: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<ReturnType<ConsumerRepository["listByCircuitList"]>>[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<LegacyMigrationReport> {
|
||||||
|
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<string, LegacyConsumerRow[]>();
|
||||||
|
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<string, number>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, typeof rows>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
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 { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
|
import { createDistributionBoardSchema } from "../../shared/validation/consumer.schemas.js";
|
||||||
|
|
||||||
const circuitListRepository = new CircuitListRepository();
|
const circuitListRepository = new CircuitListRepository();
|
||||||
|
const circuitSectionRepository = new CircuitSectionRepository();
|
||||||
const distributionBoardRepository = new DistributionBoardRepository();
|
const distributionBoardRepository = new DistributionBoardRepository();
|
||||||
|
|
||||||
export async function listDistributionBoardsByProject(req: Request, res: Response) {
|
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);
|
const board = await distributionBoardRepository.create(projectId, parsed.data.name);
|
||||||
await circuitListRepository.createForDistributionBoard({
|
const list = await circuitListRepository.createForDistributionBoard({
|
||||||
projectId,
|
projectId,
|
||||||
distributionBoardId: board.id,
|
distributionBoardId: board.id,
|
||||||
name: `${board.name} Stromkreisliste`,
|
name: `${board.name} Stromkreisliste`,
|
||||||
});
|
});
|
||||||
|
await circuitSectionRepository.createDefaults(list.id);
|
||||||
return res.status(201).json(board);
|
return res.status(201).json(board);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
|
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
|
||||||
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
||||||
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
|
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
|
||||||
|
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ projectRouter.put("/:projectId", updateProjectSettings);
|
|||||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||||
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
||||||
|
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
|
||||||
projectRouter.get("/:projectId/floors", listFloorsByProject);
|
projectRouter.get("/:projectId/floors", listFloorsByProject);
|
||||||
projectRouter.post("/:projectId/floors", createFloor);
|
projectRouter.post("/:projectId/floors", createFloor);
|
||||||
projectRouter.get("/:projectId/rooms", listRoomsByProject);
|
projectRouter.get("/:projectId/rooms", listRoomsByProject);
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user