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