Align project devices with circuit model

This commit is contained in:
2026-07-22 19:31:08 +02:00
parent 689cfd3c65
commit 9d07ed9856
16 changed files with 301 additions and 106 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
.next/
data/*.db
data/backups/*.db
+4 -1
View File
@@ -10,7 +10,8 @@ This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in
npm run db:backup
```
2. Apply pending schema migrations (includes `0008_circuit_first_model`)
2. Apply pending schema migrations (includes `0008_circuit_first_model` and the additive
`0009_project_device_circuit_fields` transition)
```bash
npm run db:migrate
@@ -52,6 +53,8 @@ WHERE type = 'table'
ORDER BY name;
```
The verification command also checks the circuit-first project-device columns added by migration `0009`.
```sql
SELECT circuit_list_id, key, prefix, sort_order
FROM circuit_sections
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -2
View File
@@ -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 tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js",
+21
View File
@@ -18,14 +18,35 @@ const rows = db
const existing = new Set(rows.map((row) => row.name));
const missing = requiredTables.filter((name) => !existing.has(name));
const requiredProjectDeviceColumns = [
"phase_type",
"connection_kind",
"cost_group",
"power_per_unit",
"simultaneity_factor",
"cos_phi",
"remark",
];
const projectDeviceColumns = new Set(
db.prepare("PRAGMA table_info(project_devices)").all().map((column) => column.name)
);
const missingProjectDeviceColumns = requiredProjectDeviceColumns.filter(
(name) => !projectDeviceColumns.has(name)
);
console.log("Database:", dbPath);
console.log("Required tables:", requiredTables.join(", "));
console.log("Existing tables:", [...existing].join(", ") || "(none)");
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
if (missing.length > 0) {
console.error("Missing tables:", missing.join(", "));
process.exit(1);
}
if (missingProjectDeviceColumns.length > 0) {
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
process.exit(1);
}
console.log("Circuit-first schema verification passed.");
+103 -43
View File
@@ -35,14 +35,15 @@ import type {
const emptyProjectDevice: CreateProjectDeviceInput = {
name: "",
displayName: "",
phaseType: "single_phase",
connectionKind: "",
costGroup: "",
category: "",
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
voltageV: 230,
phaseCount: 1,
powerFactor: 1,
note: "",
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 1,
remark: "",
};
function toOptionalNumber(value: string) {
@@ -73,14 +74,15 @@ export default function ProjectDetailPage() {
const [projectDeviceForm, setProjectDeviceForm] = useState<Record<string, string>>({
name: "",
displayName: "",
phaseType: "single_phase",
connectionKind: "",
costGroup: "",
category: "",
quantity: "1",
installedPowerPerUnitKw: "0.1",
demandFactor: "1",
voltageV: "230",
phaseCount: "1",
powerFactor: "1",
note: "",
powerPerUnit: "0.1",
simultaneityFactor: "1",
cosPhi: "1",
remark: "",
});
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -230,14 +232,15 @@ export default function ProjectDetailPage() {
const payload: CreateProjectDeviceInput = {
name: projectDeviceForm.name.trim(),
displayName: projectDeviceForm.displayName.trim() || projectDeviceForm.name.trim(),
phaseType: projectDeviceForm.phaseType === "three_phase" ? "three_phase" : "single_phase",
connectionKind: projectDeviceForm.connectionKind.trim() || undefined,
costGroup: projectDeviceForm.costGroup.trim() || undefined,
category: projectDeviceForm.category.trim() || undefined,
quantity: Number(projectDeviceForm.quantity),
installedPowerPerUnitKw: Number(projectDeviceForm.installedPowerPerUnitKw),
demandFactor: Number(projectDeviceForm.demandFactor),
voltageV: toOptionalNumber(projectDeviceForm.voltageV),
phaseCount: projectDeviceForm.phaseCount === "3" ? 3 : 1,
powerFactor: toOptionalNumber(projectDeviceForm.powerFactor),
note: projectDeviceForm.note.trim() || undefined,
powerPerUnit: Number(projectDeviceForm.powerPerUnit),
simultaneityFactor: Number(projectDeviceForm.simultaneityFactor),
cosPhi: toOptionalNumber(projectDeviceForm.cosPhi),
remark: projectDeviceForm.remark.trim() || undefined,
};
setIsSaving(true);
@@ -248,14 +251,15 @@ export default function ProjectDetailPage() {
setProjectDeviceForm({
name: emptyProjectDevice.name,
displayName: emptyProjectDevice.displayName,
phaseType: emptyProjectDevice.phaseType,
connectionKind: emptyProjectDevice.connectionKind ?? "",
costGroup: emptyProjectDevice.costGroup ?? "",
category: emptyProjectDevice.category ?? "",
quantity: String(emptyProjectDevice.quantity),
installedPowerPerUnitKw: String(emptyProjectDevice.installedPowerPerUnitKw),
demandFactor: String(emptyProjectDevice.demandFactor),
voltageV: String(emptyProjectDevice.voltageV ?? ""),
phaseCount: String(emptyProjectDevice.phaseCount ?? 1),
powerFactor: String(emptyProjectDevice.powerFactor ?? ""),
note: emptyProjectDevice.note ?? "",
powerPerUnit: String(emptyProjectDevice.powerPerUnit),
simultaneityFactor: String(emptyProjectDevice.simultaneityFactor),
cosPhi: String(emptyProjectDevice.cosPhi ?? ""),
remark: emptyProjectDevice.remark ?? "",
});
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden.");
@@ -292,14 +296,16 @@ export default function ProjectDetailPage() {
const payload: CreateProjectDeviceInput = {
name: key === "name" ? value : device.name,
displayName: key === "displayName" ? value : device.displayName,
phaseType: device.phaseType,
connectionKind: device.connectionKind ?? undefined,
costGroup: device.costGroup ?? undefined,
category: key === "category" ? value : device.category ?? undefined,
quantity: device.quantity,
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
demandFactor: device.demandFactor,
powerPerUnit: device.powerPerUnit,
simultaneityFactor: device.simultaneityFactor,
cosPhi: device.cosPhi ?? undefined,
remark: device.remark ?? undefined,
voltageV: device.voltageV ?? undefined,
phaseCount: device.phaseCount ?? undefined,
powerFactor: device.powerFactor ?? undefined,
note: device.note ?? undefined,
};
try {
@@ -591,7 +597,7 @@ export default function ProjectDetailPage() {
</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateProjectDevice}>
<div className="col-12 col-md-3">
<div className="col-12 col-md-2">
<input
className="form-control"
placeholder="Interner Name"
@@ -601,7 +607,7 @@ export default function ProjectDetailPage() {
}
/>
</div>
<div className="col-12 col-md-3">
<div className="col-12 col-md-2">
<input
className="form-control"
placeholder="Anzeigename"
@@ -621,11 +627,32 @@ export default function ProjectDetailPage() {
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
placeholder="Anschlussart"
value={projectDeviceForm.connectionKind}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, connectionKind: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
placeholder="Kostengruppe"
value={projectDeviceForm.costGroup}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, costGroup: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
min="0"
title="Anzahl"
value={projectDeviceForm.quantity}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, quantity: event.target.value }))
@@ -638,11 +665,12 @@ export default function ProjectDetailPage() {
type="number"
step="0.01"
min="0"
value={projectDeviceForm.installedPowerPerUnitKw}
title="Leistung je Stück [kW]"
value={projectDeviceForm.powerPerUnit}
onChange={(event) =>
setProjectDeviceForm((current) => ({
...current,
installedPowerPerUnitKw: event.target.value,
powerPerUnit: event.target.value,
}))
}
/>
@@ -654,25 +682,51 @@ export default function ProjectDetailPage() {
step="0.01"
min="0"
max="1"
value={projectDeviceForm.demandFactor}
title="Gleichzeitigkeitsfaktor"
value={projectDeviceForm.simultaneityFactor}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
setProjectDeviceForm((current) => ({ ...current, simultaneityFactor: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
step="0.01"
min="0"
max="1"
title="cos Phi"
value={projectDeviceForm.cosPhi}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, cosPhi: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<select
className="form-select"
value={projectDeviceForm.phaseCount}
title="Phasenart"
value={projectDeviceForm.phaseType}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
setProjectDeviceForm((current) => ({ ...current, phaseType: event.target.value }))
}
>
<option value="1">1-ph</option>
<option value="3">3-ph</option>
<option value="single_phase">1-ph</option>
<option value="three_phase">3-ph</option>
</select>
</div>
<div className="col-6 col-md-2">
<div className="col-12 col-md-10">
<input
className="form-control"
placeholder="Bemerkung"
value={projectDeviceForm.remark}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, remark: event.target.value }))
}
/>
</div>
<div className="col-12 col-md-2">
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
Gerät anlegen
</button>
@@ -707,10 +761,13 @@ export default function ProjectDetailPage() {
<tr>
<th>Interner Name</th>
<th>Anzeigename</th>
<th>Phasenart</th>
<th>Kategorie</th>
<th>Kostengruppe</th>
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Gesamtleistung [kW]</th>
<th>Aktionen</th>
</tr>
</thead>
@@ -739,6 +796,7 @@ export default function ProjectDetailPage() {
}
/>
</td>
<td>{device.phaseType === "three_phase" ? "3-ph" : "1-ph"}</td>
<td>
<input
className="form-control form-control-sm"
@@ -750,9 +808,11 @@ export default function ProjectDetailPage() {
}
/>
</td>
<td>{device.costGroup ?? "-"}</td>
<td>{device.quantity}</td>
<td>{device.installedPowerPerUnitKw}</td>
<td>{device.demandFactor}</td>
<td>{device.powerPerUnit}</td>
<td>{device.simultaneityFactor}</td>
<td>{device.totalPower}</td>
<td>
<div className="btn-group btn-group-sm">
<button
@@ -777,7 +837,7 @@ export default function ProjectDetailPage() {
))}
{!projectDevices.length ? (
<tr>
<td colSpan={7} className="text-center text-secondary py-4">
<td colSpan={10} className="text-center text-secondary py-4">
Noch keine Projektgeräte vorhanden.
</td>
</tr>
@@ -0,0 +1,24 @@
ALTER TABLE `project_devices` ADD COLUMN `phase_type` text NOT NULL DEFAULT 'single_phase';
--> statement-breakpoint
UPDATE `project_devices`
SET `phase_type` = CASE WHEN `phase_count` = 3 THEN 'three_phase' ELSE 'single_phase' END;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `connection_kind` text;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `cost_group` text;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `power_per_unit` real NOT NULL DEFAULT 0;
--> statement-breakpoint
UPDATE `project_devices` SET `power_per_unit` = `installed_power_per_unit_kw`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `simultaneity_factor` real NOT NULL DEFAULT 1;
--> statement-breakpoint
UPDATE `project_devices` SET `simultaneity_factor` = `demand_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `cos_phi` real;
--> statement-breakpoint
UPDATE `project_devices` SET `cos_phi` = `power_factor`;
--> statement-breakpoint
ALTER TABLE `project_devices` ADD COLUMN `remark` text;
--> statement-breakpoint
UPDATE `project_devices` SET `remark` = `note`;
+7
View File
@@ -64,6 +64,13 @@
"when": 1777800000000,
"tag": "0008_circuit_first_model",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1784743200000,
"tag": "0009_project_device_circuit_fields",
"breakpoints": true
}
]
}
@@ -2,14 +2,23 @@ import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../client.js";
import { projectDevices } from "../schema/project-devices.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type {
CreateProjectDeviceInput,
UpdateProjectDeviceInput,
} from "../../shared/validation/project-device.schemas.js";
function withTotalPower(row: typeof projectDevices.$inferSelect) {
return {
...row,
totalPower: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
};
}
export class ProjectDeviceRepository {
async listByProject(projectId: string) {
return db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
const rows = await db.select().from(projectDevices).where(eq(projectDevices.projectId, projectId));
return rows.map(withTotalPower);
}
async create(projectId: string, input: CreateProjectDeviceInput) {
@@ -19,16 +28,27 @@ export class ProjectDeviceRepository {
projectId,
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
installedPowerPerUnitKw: input.powerPerUnit,
demandFactor: input.simultaneityFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
phaseCount: input.phaseType === "three_phase" ? 3 : 1,
powerFactor: input.cosPhi ?? null,
note: input.remark ?? null,
});
return { id, projectId, ...input };
const created = await this.findById(projectId, id);
if (!created) {
throw new Error("Failed to create project device.");
}
return created;
}
async findById(projectId: string, projectDeviceId: string) {
@@ -39,7 +59,7 @@ export class ProjectDeviceRepository {
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
)
.limit(1);
return row ?? null;
return row ? withTotalPower(row) : null;
}
async update(projectId: string, projectDeviceId: string, input: UpdateProjectDeviceInput) {
@@ -48,14 +68,21 @@ export class ProjectDeviceRepository {
.set({
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
installedPowerPerUnitKw: input.powerPerUnit,
demandFactor: input.simultaneityFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
phaseCount: input.phaseType === "three_phase" ? 3 : 1,
powerFactor: input.cosPhi ?? null,
note: input.remark ?? null,
})
.where(
and(eq(projectDevices.id, projectDeviceId), eq(projectDevices.projectId, projectId))
+8
View File
@@ -8,8 +8,16 @@ export const projectDevices = sqliteTable("project_devices", {
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
displayName: text("display_name").notNull(),
phaseType: text("phase_type").notNull().default("single_phase"),
connectionKind: text("connection_kind"),
costGroup: text("cost_group"),
category: text("category"),
quantity: integer("quantity").notNull(),
powerPerUnit: real("power_per_unit").notNull().default(0),
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
cosPhi: real("cos_phi"),
remark: text("remark"),
// Legacy compatibility fields. Keep these synchronized until the Consumer editor is removed.
installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(),
demandFactor: real("demand_factor").notNull(),
voltageV: real("voltage_v"),
+18 -14
View File
@@ -1696,10 +1696,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
function resolvePhaseType(device: ProjectDeviceDto): string {
if (device.phaseCount === 3) {
return "three_phase";
}
return "single_phase";
return device.phaseType;
}
function resolveSelectedProjectDevice() {
@@ -1819,11 +1816,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
name: device.name,
displayName: device.displayName,
phaseType: resolvePhaseType(device),
connectionKind: device.connectionKind ?? undefined,
costGroup: device.costGroup ?? undefined,
quantity: device.quantity,
powerPerUnit: device.installedPowerPerUnitKw,
simultaneityFactor: device.demandFactor,
cosPhi: device.powerFactor ?? undefined,
powerPerUnit: device.powerPerUnit,
simultaneityFactor: device.simultaneityFactor,
cosPhi: device.cosPhi ?? undefined,
category: device.category ?? undefined,
remark: device.remark ?? undefined,
})) as { id: string };
setActiveSectionId(sectionId);
@@ -1837,11 +1837,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
name: device.name,
displayName: device.displayName,
phaseType: resolvePhaseType(device),
connectionKind: device.connectionKind ?? undefined,
costGroup: device.costGroup ?? undefined,
quantity: device.quantity,
powerPerUnit: device.installedPowerPerUnitKw,
simultaneityFactor: device.demandFactor,
cosPhi: device.powerFactor ?? undefined,
powerPerUnit: device.powerPerUnit,
simultaneityFactor: device.simultaneityFactor,
cosPhi: device.cosPhi ?? undefined,
category: device.category ?? undefined,
remark: device.remark ?? undefined,
})) as { id: string };
return { rowId: createdRow.id };
}
@@ -2700,11 +2703,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
<strong>{device.displayName || device.name}</strong>
<span>Name: {device.name}</span>
<span>Phase: {device.phaseCount === 3 ? "three_phase" : "single_phase"}</span>
<span>Phase: {device.phaseType}</span>
<span>Qty: {device.quantity}</span>
<span>P/unit: {device.installedPowerPerUnitKw}</span>
<span>g: {device.demandFactor}</span>
<span>Cost group: -</span>
<span>P/unit: {device.powerPerUnit}</span>
<span>g: {device.simultaneityFactor}</span>
<span>Total: {device.totalPower}</span>
<span>Cost group: {device.costGroup || "-"}</span>
<span>Category: {device.category || "-"}</span>
</button>
))}
+16 -5
View File
@@ -91,8 +91,17 @@ export interface ProjectDeviceDto {
projectId: string;
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind: string | null;
costGroup: string | null;
category: string | null;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
totalPower: number;
cosPhi: number | null;
remark: string | null;
// Legacy aliases retained until the old Consumer editor is removed.
installedPowerPerUnitKw: number;
demandFactor: number;
voltageV: number | null;
@@ -159,14 +168,16 @@ export interface CreateGlobalDeviceInput {
export interface CreateProjectDeviceInput {
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind?: string;
costGroup?: string;
category?: string;
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
voltageV?: number;
phaseCount?: 1 | 3;
powerFactor?: number;
note?: string;
}
export interface CircuitTreeDeviceRowDto {
@@ -69,12 +69,12 @@ export async function copyProjectDeviceToGlobal(req: Request, res: Response) {
displayName: source.displayName,
category: source.category ?? undefined,
quantity: source.quantity,
installedPowerPerUnitKw: source.installedPowerPerUnitKw,
demandFactor: source.demandFactor,
installedPowerPerUnitKw: source.powerPerUnit,
demandFactor: source.simultaneityFactor,
voltageV: source.voltageV ?? undefined,
phaseCount: source.phaseCount === 1 || source.phaseCount === 3 ? source.phaseCount : undefined,
powerFactor: source.powerFactor ?? undefined,
note: source.note ?? undefined,
phaseCount: source.phaseType === "three_phase" ? 3 : 1,
powerFactor: source.cosPhi ?? undefined,
note: source.remark ?? undefined,
});
return res.status(201).json(created);
@@ -1,6 +1,5 @@
import type { Request, Response } from "express";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import {
createProjectDeviceSchema,
@@ -8,7 +7,6 @@ import {
} from "../../shared/validation/project-device.schemas.js";
const globalDeviceRepository = new GlobalDeviceRepository();
const consumerRepository = new ConsumerRepository();
const projectDeviceRepository = new ProjectDeviceRepository();
export async function listProjectDevicesByProject(req: Request, res: Response) {
@@ -45,16 +43,8 @@ export async function updateProjectDevice(req: Request, res: Response) {
}
await projectDeviceRepository.update(projectId, projectDeviceId, parsed.data);
await consumerRepository.syncLinkedConsumersFromProjectDevice(projectId, projectDeviceId, {
displayName: parsed.data.displayName,
category: parsed.data.category,
quantity: parsed.data.quantity,
installedPowerPerUnitKw: parsed.data.installedPowerPerUnitKw,
demandFactor: parsed.data.demandFactor,
phaseCount: parsed.data.phaseCount,
powerFactor: parsed.data.powerFactor,
note: parsed.data.note,
});
// Linked rows are synchronized only through the explicit review flow.
// Updating a project device must never silently overwrite local circuit-list values.
const row = await projectDeviceRepository.findById(projectId, projectDeviceId);
if (!row) {
return res.status(404).json({ error: "Project device not found" });
@@ -86,14 +76,14 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
const created = await projectDeviceRepository.create(projectId, {
name: source.name,
displayName: source.displayName,
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
category: source.category ?? undefined,
quantity: source.quantity,
installedPowerPerUnitKw: source.installedPowerPerUnitKw,
demandFactor: source.demandFactor,
powerPerUnit: source.installedPowerPerUnitKw,
simultaneityFactor: source.demandFactor,
cosPhi: source.powerFactor ?? undefined,
remark: source.note ?? undefined,
voltageV: source.voltageV ?? undefined,
phaseCount: source.phaseCount === 1 || source.phaseCount === 3 ? source.phaseCount : undefined,
powerFactor: source.powerFactor ?? undefined,
note: source.note ?? undefined,
});
return res.status(201).json(created);
@@ -3,14 +3,17 @@ import { z } from "zod";
export const createProjectDeviceSchema = z.object({
name: z.string().min(1),
displayName: z.string().min(1),
phaseType: z.enum(["single_phase", "three_phase"]),
connectionKind: z.string().optional(),
costGroup: z.string().optional(),
category: z.string().optional(),
quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1),
powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0).max(1),
cosPhi: z.number().min(0).max(1).optional(),
remark: z.string().optional(),
// Transitional metadata used when importing from the legacy global-device library.
voltageV: z.number().positive().optional(),
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(),
powerFactor: z.number().min(0).max(1).optional(),
note: z.string().optional(),
});
export const updateProjectDeviceSchema = createProjectDeviceSchema;
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createProjectDeviceSchema } from "../src/shared/validation/project-device.schemas.js";
describe("project device circuit-first schema", () => {
it("accepts circuit device fields", () => {
const result = createProjectDeviceSchema.safeParse({
name: "E-Line Pro",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "Lighting",
quantity: 6,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
});
assert.equal(result.success, true);
});
it("requires the circuit-first power and phase fields", () => {
const result = createProjectDeviceSchema.safeParse({
name: "Legacy device",
displayName: "Legacy device",
quantity: 1,
installedPowerPerUnitKw: 0.1,
demandFactor: 1,
phaseCount: 1,
});
assert.equal(result.success, false);
});
});