Derive project device phases from category

This commit is contained in:
2026-07-31 14:37:45 +02:00
parent 5fb81bab04
commit 1e3c721cfa
9 changed files with 101 additions and 80 deletions
@@ -1,6 +1,7 @@
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
export interface ProjectDevicePlacementSource {
category?: string | null;
phaseType: "single_phase" | "three_phase";
category: CircuitGroupCategory;
}
export interface ProjectDevicePlacementSection {
@@ -14,11 +15,13 @@ export type DefaultCircuitSectionKey = "lighting" | "single_phase" | "three_phas
export function inferProjectDeviceSectionKey(
device: ProjectDevicePlacementSource
): DefaultCircuitSectionKey {
const category = (device.category ?? "").trim().toLowerCase();
if (category.includes("light") || category.includes("beleuchtung")) {
return "lighting";
}
return device.phaseType;
return device.category;
}
export function resolveProjectDevicePhaseType(
category: CircuitGroupCategory
): "single_phase" | "three_phase" {
return category === "three_phase" ? "three_phase" : "single_phase";
}
export function isProjectDevicePlacementValid(
@@ -3258,7 +3258,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
<strong>{device.displayName || device.name}</strong>
<span>Name: {device.name}</span>
<span>Phasenart: {formatPhaseTypeLabel(device.phaseType)}</span>
<span>Anzahl: {formatValue(device.quantity, "quantity")}</span>
<span>
Leistung/Gerät: {formatValue(device.powerPerUnit, "powerPerUnit")} kW
@@ -3271,7 +3270,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
Gesamtleistung: {formatValue(device.totalPower, "rowTotalPower")} kW
</span>
<span>Kostengruppe: {device.costGroup || "-"}</span>
<span>Kategorie: {device.category || "-"}</span>
<span>Kategorie: {circuitGroupCategoryLabels[device.category]}</span>
</button>
))}
{searchableProjectDevices.length === 0 ? <p className="notice muted">Keine passenden Projektgeräte gefunden.</p> : null}
@@ -7,6 +7,11 @@ import type {
ProjectDeviceDto,
} from "../types";
import { FormModal } from "./form-modal";
import {
circuitGroupCategories,
circuitGroupCategoryLabels,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group";
interface ProjectDeviceModalProps {
globalDevices: GlobalDeviceDto[];
@@ -37,14 +42,12 @@ export function ProjectDeviceModal({
await onSave({
name: values.name.trim(),
displayName: values.displayName.trim() || values.name.trim(),
phaseType:
values.phaseType === "three_phase" ? "three_phase" : "single_phase",
connectionKind: optionalString(values.connectionKind),
costGroup: optionalString(values.costGroup),
category: optionalString(values.category),
quantity: Number(values.quantity),
powerPerUnit: Number(values.powerPerUnit),
simultaneityFactor: Number(values.simultaneityFactor),
category: values.category as CircuitGroupCategory,
quantity: parseDecimal(values.quantity),
powerPerUnit: parseDecimal(values.powerPerUnit),
simultaneityFactor: parseDecimal(values.simultaneityFactor),
cosPhi: optionalNumber(values.cosPhi),
remark: optionalString(values.remark),
});
@@ -52,10 +55,10 @@ export function ProjectDeviceModal({
const isValid =
values.name.trim().length > 0 &&
Number(values.quantity) >= 0 &&
Number(values.powerPerUnit) >= 0 &&
Number(values.simultaneityFactor) >= 0 &&
Number(values.simultaneityFactor) <= 1;
isNumberInRange(values.quantity, 0) &&
isNumberInRange(values.powerPerUnit, 0) &&
isNumberInRange(values.simultaneityFactor, 0, 1) &&
(!values.cosPhi.trim() || isNumberInRange(values.cosPhi, 0, 1));
return (
<FormModal
@@ -116,12 +119,25 @@ export function ProjectDeviceModal({
onChange={(value) => update("displayName", value)}
value={values.displayName}
/>
<TextField
id="device-category"
label="Kategorie"
onChange={(value) => update("category", value)}
value={values.category}
/>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="device-category">
Kategorie
</label>
<select
className="form-select"
id="device-category"
onChange={(event) =>
update("category", event.target.value as CircuitGroupCategory)
}
value={values.category}
>
{circuitGroupCategories.map((category) => (
<option key={category} value={category}>
{circuitGroupCategoryLabels[category]}
</option>
))}
</select>
</div>
<TextField
id="device-connection"
label="Anschlussart"
@@ -134,20 +150,6 @@ export function ProjectDeviceModal({
onChange={(value) => update("costGroup", value)}
value={values.costGroup}
/>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="device-phase-type">
Phasenart
</label>
<select
className="form-select"
id="device-phase-type"
onChange={(event) => update("phaseType", event.target.value)}
value={values.phaseType}
>
<option value="single_phase">1-phasig</option>
<option value="three_phase">3-phasig</option>
</select>
</div>
<NumberField
id="device-quantity"
label="Anzahl"
@@ -244,7 +246,8 @@ function NumberField({
min={min}
onChange={(event) => onChange(event.target.value)}
step={step}
type="number"
inputMode="decimal"
type="text"
value={value}
/>
</div>
@@ -255,10 +258,9 @@ function toFormValues(device?: ProjectDeviceDto) {
return {
name: device?.name ?? "",
displayName: device?.displayName ?? "",
phaseType: device?.phaseType ?? "single_phase",
connectionKind: device?.connectionKind ?? "",
costGroup: device?.costGroup ?? "",
category: device?.category ?? "",
category: device?.category ?? "single_phase",
quantity: String(device?.quantity ?? 1),
powerPerUnit: String(device?.powerPerUnit ?? 0.1),
simultaneityFactor: String(device?.simultaneityFactor ?? 1),
@@ -272,5 +274,14 @@ function optionalString(value: string) {
}
function optionalNumber(value: string) {
return value.trim() ? Number(value) : undefined;
return value.trim() ? parseDecimal(value) : undefined;
}
function parseDecimal(value: string) {
return Number(value.trim().replace(",", "."));
}
function isNumberInRange(value: string, min: number, max = Number.POSITIVE_INFINITY) {
const parsed = parseDecimal(value);
return Number.isFinite(parsed) && parsed >= min && parsed <= max;
}
+2 -3
View File
@@ -173,7 +173,7 @@ export interface ProjectDeviceDto {
phaseType: "single_phase" | "three_phase";
connectionKind: string | null;
costGroup: string | null;
category: string | null;
category: CircuitGroupCategory;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
@@ -218,10 +218,9 @@ export interface CreateGlobalDeviceInput {
export interface CreateProjectDeviceInput {
name: string;
displayName: string;
phaseType: "single_phase" | "three_phase";
connectionKind?: string;
costGroup?: string;
category?: string;
category: CircuitGroupCategory;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
@@ -23,6 +23,8 @@ import {
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
import { resolveProjectVoltage } from "../../domain/services/project-voltage.service.js";
import { resolveProjectDevicePhaseType } from "../../domain/services/project-device-placement.service.js";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
export async function listProjectDevicesByProject(req: Request, res: Response) {
const { projectId } = req.params;
@@ -156,8 +158,10 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
name: source.name,
displayName: source.displayName,
phaseType: source.phaseCount === 3 ? "three_phase" : "single_phase",
category: source.category ?? undefined,
category: inferImportedProjectDeviceCategory(
source.category,
source.phaseCount
),
quantity: source.quantity,
powerPerUnit: source.installedPowerPerUnitKw,
simultaneityFactor: source.demandFactor,
@@ -205,22 +209,34 @@ function toProjectDeviceValues(
threePhaseVoltageV: number;
}
) {
const phaseType = resolveProjectDevicePhaseType(input.category);
return {
name: input.name,
displayName: input.displayName,
phaseType: input.phaseType,
phaseType,
connectionKind: input.connectionKind ?? null,
costGroup: input.costGroup ?? null,
category: input.category ?? null,
category: input.category,
quantity: input.quantity,
powerPerUnit: input.powerPerUnit,
simultaneityFactor: input.simultaneityFactor,
cosPhi: input.cosPhi ?? null,
remark: input.remark ?? null,
voltageV: resolveProjectVoltage(input.phaseType, project),
voltageV: resolveProjectVoltage(phaseType, project),
};
}
function inferImportedProjectDeviceCategory(
category: string | null,
phaseCount: number | null
): CircuitGroupCategory {
const normalized = category?.trim().toLowerCase() ?? "";
if (normalized.includes("light") || normalized.includes("beleuchtung")) {
return "lighting";
}
return phaseCount === 3 ? "three_phase" : "single_phase";
}
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
@@ -1,14 +1,14 @@
import { z } from "zod";
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
import { circuitGroupCategories } from "../constants/circuit-group.js";
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(),
category: z.enum(circuitGroupCategories),
quantity: z.number().min(0),
powerPerUnit: z.number().min(0),
simultaneityFactor: z.number().min(0).max(1),
+12 -21
View File
@@ -3,36 +3,27 @@ import { describe, it } from "node:test";
import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
resolveProjectDevicePhaseType,
} from "../src/domain/services/project-device-placement.service.js";
describe("project device placement", () => {
it("routes lighting categories to the lighting section before phase classification", () => {
assert.equal(
inferProjectDeviceSectionKey({ category: "Lighting", phaseType: "three_phase" }),
"lighting"
);
assert.equal(
inferProjectDeviceSectionKey({ category: "Beleuchtung", phaseType: "single_phase" }),
"lighting"
);
});
it("routes non-lighting devices by phase type", () => {
assert.equal(
inferProjectDeviceSectionKey({ category: "Socket", phaseType: "single_phase" }),
"single_phase"
);
assert.equal(
inferProjectDeviceSectionKey({ category: "Motor", phaseType: "three_phase" }),
"three_phase"
);
it("routes project devices by their required category", () => {
assert.equal(inferProjectDeviceSectionKey({ category: "lighting" }), "lighting");
assert.equal(inferProjectDeviceSectionKey({ category: "single_phase" }), "single_phase");
assert.equal(inferProjectDeviceSectionKey({ category: "three_phase" }), "three_phase");
});
it("accepts only the inferred default section", () => {
const device = { category: "Motor", phaseType: "three_phase" as const };
const device = { category: "three_phase" as const };
assert.equal(isProjectDevicePlacementValid(device, { key: "three_phase" }), true);
assert.equal(isProjectDevicePlacementValid(device, { key: "single_phase" }), false);
assert.equal(isProjectDevicePlacementValid(device, { key: "unassigned" }), false);
});
it("derives the electrical phase type from the category", () => {
assert.equal(resolveProjectDevicePhaseType("lighting"), "single_phase");
assert.equal(resolveProjectDevicePhaseType("single_phase"), "single_phase");
assert.equal(resolveProjectDevicePhaseType("three_phase"), "three_phase");
});
});
+4 -5
View File
@@ -14,10 +14,9 @@ describe("project device circuit-first schema", () => {
const result = createProjectDeviceSchema.safeParse({
name: "E-Line Pro",
displayName: "Office lighting",
phaseType: "single_phase",
connectionKind: "fixed",
costGroup: "440",
category: "Lighting",
category: "lighting",
quantity: 6,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
@@ -28,7 +27,7 @@ describe("project device circuit-first schema", () => {
assert.equal(result.success, true);
});
it("requires the circuit-first power and phase fields", () => {
it("requires the circuit-first power and category fields", () => {
const result = createProjectDeviceSchema.safeParse({
name: "Test device",
displayName: "Test device",
@@ -45,7 +44,7 @@ describe("project device circuit-first schema", () => {
const result = createProjectDeviceSchema.safeParse({
name: "Pumpe",
displayName: "Pumpe",
phaseType: "three_phase",
category: "three_phase",
quantity: 1,
powerPerUnit: 2,
simultaneityFactor: 1,
@@ -59,7 +58,7 @@ describe("project device circuit-first schema", () => {
const device = {
name: "E-Line Pro",
displayName: "Bürobeleuchtung",
phaseType: "single_phase" as const,
category: "lighting" as const,
quantity: 6,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
+4 -1
View File
@@ -283,7 +283,6 @@ describe("project device modal presentation", () => {
"Kategorie",
"Anschlussart",
"Kostengruppe",
"Phasenart",
"Anzahl",
"Leistung je Stück [kW]",
"Gleichzeitigkeitsfaktor",
@@ -292,6 +291,10 @@ describe("project device modal presentation", () => {
assert.match(markup, new RegExp(label.replace("[", "\\[").replace("]", "\\]")));
}
assert.doesNotMatch(markup, /Spannung \[V\]/);
assert.doesNotMatch(markup, /Phasenart/);
assert.match(markup, /Beleuchtung/);
assert.match(markup, /1-phasig/);
assert.match(markup, /3-phasig/);
});
});