diff --git a/.gitignore b/.gitignore index e20a8cb..c92717b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .next/ data/*.db +data/backups/*.db diff --git a/docs/local-db-circuit-first-migration.md b/docs/local-db-circuit-first-migration.md index 72bd9cb..3830662 100644 --- a/docs/local-db-circuit-first-migration.md +++ b/docs/local-db-circuit-first-migration.md @@ -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 diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/package.json b/package.json index e957f0f..ddbff13 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 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", diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 1a3523c..b2117c4 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -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."); diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index cd20b3b..be23ef2 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -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>({ 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(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() {
-
+
-
+
+
+ + setProjectDeviceForm((current) => ({ ...current, connectionKind: event.target.value })) + } + /> +
+
+ + setProjectDeviceForm((current) => ({ ...current, costGroup: event.target.value })) + } + /> +
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 })) + } + /> +
+
+ + setProjectDeviceForm((current) => ({ ...current, cosPhi: event.target.value })) } />
-
+
+ + setProjectDeviceForm((current) => ({ ...current, remark: event.target.value })) + } + /> +
+
@@ -707,10 +761,13 @@ export default function ProjectDetailPage() { Interner Name Anzeigename + Phasenart Kategorie + Kostengruppe Anzahl Leistung je Stück [kW] GZF + Gesamtleistung [kW] Aktionen @@ -739,6 +796,7 @@ export default function ProjectDetailPage() { } /> + {device.phaseType === "three_phase" ? "3-ph" : "1-ph"} + {device.costGroup ?? "-"} {device.quantity} - {device.installedPowerPerUnitKw} - {device.demandFactor} + {device.powerPerUnit} + {device.simultaneityFactor} + {device.totalPower}
))} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index dbcb12e..226c577 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -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 { diff --git a/src/server/controllers/global-device.controller.ts b/src/server/controllers/global-device.controller.ts index fb00eba..d8f8546 100644 --- a/src/server/controllers/global-device.controller.ts +++ b/src/server/controllers/global-device.controller.ts @@ -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); diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts index b1ce30f..70c7642 100644 --- a/src/server/controllers/project-device.controller.ts +++ b/src/server/controllers/project-device.controller.ts @@ -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); diff --git a/src/shared/validation/project-device.schemas.ts b/src/shared/validation/project-device.schemas.ts index 2f85470..3b8bf48 100644 --- a/src/shared/validation/project-device.schemas.ts +++ b/src/shared/validation/project-device.schemas.ts @@ -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; diff --git a/tests/project-device-schema.test.ts b/tests/project-device-schema.test.ts new file mode 100644 index 0000000..b47eac0 --- /dev/null +++ b/tests/project-device-schema.test.ts @@ -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); + }); +});