Add per-project socket limit and Revit socket device-type flag

- Project setting maxSocketsPerCircuit (migration 0018,
  project.update-settings schema 4, snapshot schema 11); older command
  and snapshot versions keep or upgrade the value to null.
- Revit configuration schema 2 marks device types as socket; version 1
  stays readable and derives the flag from socketCount family rules.
- Settings dialog field and device-type card on the Revit page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 21:18:08 +01:00
parent 85cbdbf8e0
commit e48042bde3
35 changed files with 3781 additions and 41 deletions

View file

@ -98,6 +98,7 @@ const requiredProjectColumns = [
"work_stage", "work_stage",
"project_lead", "project_lead",
"project_editor", "project_editor",
"max_sockets_per_circuit",
]; ];
const projectColumns = new Set( const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name) db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)

View file

@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { IconLink } from "../../../../frontend/components/icon-link"; import { IconLink } from "../../../../frontend/components/icon-link";
import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard"; import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard";
import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-import-wizard"; import { RevitFollowUpImportWizard } from "../../../../frontend/components/revit-follow-up-import-wizard";
import { RevitDeviceTypeCard } from "../../../../frontend/components/revit-device-type-card";
import { RevitObjectTable } from "../../../../frontend/components/revit-object-table"; import { RevitObjectTable } from "../../../../frontend/components/revit-object-table";
import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal"; import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration"; import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration";
@ -46,6 +47,7 @@ export default function RevitPage() {
<div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || !importStatus?.imported} onClick={() => setFollowUpOpen(true)} type="button"><Upload size={16} />Folgeimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div> <div className="page-header"><div><div className="kicker">Projekt · Revit</div><h1>{project?.name ?? "Revit-Anbindung"}</h1><p className="text-secondary mb-0">Linear-XLSX austauschen und Revit-Objekte verwalten</p></div><div className="d-flex gap-2"><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || Boolean(importStatus?.imported)} onClick={() => setImportOpen(true)} type="button"><Upload size={16} />Erstimport</button><button className="btn btn-success btn-with-icon" disabled={!project || !snapshot || !importStatus?.imported} onClick={() => setFollowUpOpen(true)} type="button"><Upload size={16} />Folgeimport</button><button className="btn btn-primary btn-with-icon" disabled={!project} onClick={() => setSettingsOpen(true)} type="button"><Settings size={16} />Einstellungen</button><IconLink href={`/projects/${projectId}`} icon={ArrowLeft} label="Zum Projekt" showLabel /></div></div>
{error ? <div className="alert alert-danger">{error}</div> : null} {error ? <div className="alert alert-danger">{error}</div> : null}
<div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div> <div className="row g-3 mt-1"><div className="col-md-6"><section className="card h-100"><div className="card-body"><FileSpreadsheet className="mb-3" size={28} /><h2 className="h5">XLSX-Konfiguration</h2><p className="text-secondary">{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}</p><span className={`badge ${snapshot ? "text-bg-success" : "text-bg-secondary"}`}>{snapshot ? "Konfiguriert" : "Offen"}</span></div></section></div><div className="col-md-6"><section className="card h-100"><div className="card-body"><h2 className="h5">Importstatus</h2>{importStatus?.imported ? <><p className="mb-1"><strong>{importStatus.objectCount} Revit-Objekte</strong>{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}</p><p className="text-secondary mb-0">Quelle: {importStatus.sourceName} · Fehlend: {importStatus.missingObjectCount}</p></> : <p className="text-secondary mb-0">Noch kein Linear-XLSX-Import vorhanden.</p>}</div></section></div></div>
{snapshot ? <RevitDeviceTypeCard configuration={snapshot.configuration} isSaving={saving} onSave={save} /> : null}
{importStatus?.imported ? <RevitObjectTable key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null} {importStatus?.imported ? <RevitObjectTable key={project?.currentRevision} projectId={projectId} onRevision={(revision) => setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null}
{settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null} {settingsOpen ? <RevitXlsxSettingsModal configuration={snapshot?.configuration ?? createDefaultRevitXlsxConfiguration()} isNewConfiguration={snapshot === null} isSaving={saving} onClose={() => setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null}
{importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null} {importOpen && project && snapshot ? <RevitInitialImportWizard projectId={projectId} expectedRevision={project.currentRevision} initialConfiguration={snapshot.configuration} onClose={() => setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null}

View file

@ -0,0 +1 @@
ALTER TABLE `projects` ADD `max_sockets_per_circuit` integer;

File diff suppressed because it is too large Load diff

View file

@ -127,6 +127,13 @@
"when": 1793359753805, "when": 1793359753805,
"tag": "0017_circuit_protection_none", "tag": "0017_circuit_protection_none",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1793391088159,
"tag": "0018_project_socket_limit",
"breakpoints": true
} }
] ]
} }

View file

@ -4,6 +4,7 @@ import {
baselineProjectSettingsUpdateCommandSchemaVersion, baselineProjectSettingsUpdateCommandSchemaVersion,
createProjectSettingsUpdateProjectCommand, createProjectSettingsUpdateProjectCommand,
withoutCoverProjectSettingsUpdateCommandSchemaVersion, withoutCoverProjectSettingsUpdateCommandSchemaVersion,
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues, type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js"; } from "../../domain/models/project-settings-project-command.model.js";
import { projectCoverFields } from "../../shared/constants/project-cover.js"; import { projectCoverFields } from "../../shared/constants/project-cover.js";
@ -53,16 +54,23 @@ export class ProjectSettingsProjectCommandRepository
// the current project state and are therefore also absent from the // the current project state and are therefore also absent from the
// inverse of the same version. // inverse of the same version.
const command = input.command; const command = input.command;
const currentSocketLimit = {
maxSocketsPerCircuit: current.maxSocketsPerCircuit,
};
const target: ProjectSettingsValues = const target: ProjectSettingsValues =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? { ? {
...command.payload, ...command.payload,
isPublicBuilding: current.isPublicBuilding, isPublicBuilding: current.isPublicBuilding,
...currentCoverValues, ...currentCoverValues,
...currentSocketLimit,
} }
: command.schemaVersion === : command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion withoutCoverProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentCoverValues } ? { ...command.payload, ...currentCoverValues, ...currentSocketLimit }
: command.schemaVersion ===
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentSocketLimit }
: command.payload; : command.payload;
if (projectSettingsEqual(current, target)) { if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change."); throw new Error("Project settings did not change.");
@ -80,6 +88,7 @@ export class ProjectSettingsProjectCommandRepository
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes, current.enabledDistributionBoardSupplyTypes,
...currentCoverValues, ...currentCoverValues,
...currentSocketLimit,
}; };
const inverse = const inverse =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
@ -87,6 +96,9 @@ export class ProjectSettingsProjectCommandRepository
: command.schemaVersion === : command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion withoutCoverProjectSettingsUpdateCommandSchemaVersion
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues) ? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
: command.schemaVersion ===
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion
? createWithoutSocketLimitProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues); : createProjectSettingsUpdateProjectCommand(inverseValues);
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target); assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx const updated = tx
@ -103,6 +115,17 @@ export class ProjectSettingsProjectCommandRepository
} }
} }
function createWithoutSocketLimitProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
const { maxSocketsPerCircuit: _omitted, ...payload } = values;
return {
schemaVersion: withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const,
payload,
};
}
function createWithoutCoverProjectSettingsUpdateCommand( function createWithoutCoverProjectSettingsUpdateCommand(
values: ProjectSettingsValues values: ProjectSettingsValues
) { ) {
@ -158,6 +181,7 @@ function projectSettingsEqual(
current.singlePhaseVoltageV === target.singlePhaseVoltageV && current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV && current.threePhaseVoltageV === target.threePhaseVoltageV &&
projectCoverFields.every((field) => current[field] === target[field]) && projectCoverFields.every((field) => current[field] === target[field]) &&
current.maxSocketsPerCircuit === target.maxSocketsPerCircuit &&
sameSupplyTypes( sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes, current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes target.enabledDistributionBoardSupplyTypes

View file

@ -325,6 +325,7 @@ function replaceProjectState(
workStage: state.project.workStage, workStage: state.project.workStage,
projectLead: state.project.projectLead, projectLead: state.project.projectLead,
projectEditor: state.project.projectEditor, projectEditor: state.project.projectEditor,
maxSocketsPerCircuit: state.project.maxSocketsPerCircuit,
}) })
.where(eq(projects.id, state.project.id)) .where(eq(projects.id, state.project.id))
.run(); .run();

View file

@ -51,6 +51,7 @@ export function readProjectStateSnapshot(
workStage: projects.workStage, workStage: projects.workStage,
projectLead: projects.projectLead, projectLead: projects.projectLead,
projectEditor: projects.projectEditor, projectEditor: projects.projectEditor,
maxSocketsPerCircuit: projects.maxSocketsPerCircuit,
currentRevision: projects.currentRevision, currentRevision: projects.currentRevision,
}) })
.from(projects) .from(projects)
@ -264,6 +265,7 @@ export function readProjectStateSnapshot(
workStage: project.workStage, workStage: project.workStage,
projectLead: project.projectLead, projectLead: project.projectLead,
projectEditor: project.projectEditor, projectEditor: project.projectEditor,
maxSocketsPerCircuit: project.maxSocketsPerCircuit,
}, },
distributionBoards: boardRows, distributionBoards: boardRows,
externalModelConfiguration, externalModelConfiguration,

View file

@ -35,6 +35,7 @@ export class ProjectRepository {
workStage: null, workStage: null,
projectLead: null, projectLead: null,
projectEditor: null, projectEditor: null,
maxSocketsPerCircuit: null,
currentRevision: 0, currentRevision: 0,
}; };
await db.insert(projects).values(project); await db.insert(projects).values(project);

View file

@ -25,5 +25,7 @@ export const projects = sqliteTable("projects", {
workStage: text("work_stage"), workStage: text("work_stage"),
projectLead: text("project_lead"), projectLead: text("project_lead"),
projectEditor: text("project_editor"), projectEditor: text("project_editor"),
// Client-specific highest effective socket count per circuit; null = none.
maxSocketsPerCircuit: integer("max_sockets_per_circuit"),
currentRevision: integer("current_revision").notNull().default(0), currentRevision: integer("current_revision").notNull().default(0),
}); });

View file

@ -8,13 +8,17 @@ import {
projectCoverFields, projectCoverFields,
type ProjectCoverField, type ProjectCoverField,
} from "../../shared/constants/project-cover.js"; } from "../../shared/constants/project-cover.js";
import { isValidMaxSocketsPerCircuit } from "../../shared/constants/socket-limit.js";
export const projectSettingsUpdateCommandType = export const projectSettingsUpdateCommandType =
"project.update-settings" as const; "project.update-settings" as const;
// Version 3 adds the print cover fields. Versions 2 (without cover fields) // Version 4 adds the per-project socket limit, version 3 the print cover
// and 1 (additionally without the public-building flag) stay executable; // fields. Versions 3 (without socket limit), 2 (additionally without cover
// their missing values keep the current project values. // fields) and 1 (additionally without the public-building flag) stay
export const projectSettingsUpdateCommandSchemaVersion = 3 as const; // executable; their missing values keep the current project values.
export const projectSettingsUpdateCommandSchemaVersion = 4 as const;
export const withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion =
3 as const;
export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const; export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const;
export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const; export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const;
@ -29,10 +33,17 @@ export interface ProjectSettingsValues
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[]; enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
// Highest effective socket count per circuit; null means no limit.
maxSocketsPerCircuit: number | null;
} }
export type WithoutCoverProjectSettingsValues = Omit< export type WithoutSocketLimitProjectSettingsValues = Omit<
ProjectSettingsValues, ProjectSettingsValues,
"maxSocketsPerCircuit"
>;
export type WithoutCoverProjectSettingsValues = Omit<
WithoutSocketLimitProjectSettingsValues,
ProjectCoverField ProjectCoverField
>; >;
@ -47,6 +58,12 @@ export interface CurrentProjectSettingsUpdateProjectCommand
type: typeof projectSettingsUpdateCommandType; type: typeof projectSettingsUpdateCommandType;
} }
export interface WithoutSocketLimitProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<WithoutSocketLimitProjectSettingsValues> {
schemaVersion: typeof withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export interface WithoutCoverProjectSettingsUpdateProjectCommand export interface WithoutCoverProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> { extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion; schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
@ -61,6 +78,7 @@ export interface BaselineProjectSettingsUpdateProjectCommand
export type ProjectSettingsUpdateProjectCommand = export type ProjectSettingsUpdateProjectCommand =
| CurrentProjectSettingsUpdateProjectCommand | CurrentProjectSettingsUpdateProjectCommand
| WithoutSocketLimitProjectSettingsUpdateProjectCommand
| WithoutCoverProjectSettingsUpdateProjectCommand | WithoutCoverProjectSettingsUpdateProjectCommand
| BaselineProjectSettingsUpdateProjectCommand; | BaselineProjectSettingsUpdateProjectCommand;
@ -82,6 +100,8 @@ export function assertProjectSettingsUpdateProjectCommand(
if ( if (
command.type !== projectSettingsUpdateCommandType || command.type !== projectSettingsUpdateCommandType ||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion && (command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !==
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== command.schemaVersion !==
withoutCoverProjectSettingsUpdateCommandSchemaVersion && withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== command.schemaVersion !==
@ -123,16 +143,23 @@ export function assertProjectSettingsUpdateProjectCommand(
: command.schemaVersion === : command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion withoutCoverProjectSettingsUpdateCommandSchemaVersion
? 9 ? 9
: 9 + projectCoverFields.length; : command.schemaVersion ===
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion
? 9 + projectCoverFields.length
: 10 + projectCoverFields.length;
if ( if (
Object.keys(payload).length !== expectedKeyCount || Object.keys(payload).length !== expectedKeyCount ||
(command.schemaVersion !== (command.schemaVersion !==
baselineProjectSettingsUpdateCommandSchemaVersion && baselineProjectSettingsUpdateCommandSchemaVersion &&
typeof payload.isPublicBuilding !== "boolean") || typeof payload.isPublicBuilding !== "boolean") ||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion && ((command.schemaVersion === projectSettingsUpdateCommandSchemaVersion ||
command.schemaVersion ===
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion) &&
!projectCoverFields.every((field) => !projectCoverFields.every((field) =>
isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field]) isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field])
)) )) ||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
!isValidMaxSocketsPerCircuit(payload.maxSocketsPerCircuit))
) { ) {
throw new Error( throw new Error(
"Project settings update command contains invalid values." "Project settings update command contains invalid values."

View file

@ -8,6 +8,7 @@ import {
projectCoverFieldMaxLengths, projectCoverFieldMaxLengths,
type ProjectCoverField, type ProjectCoverField,
} from "../../shared/constants/project-cover.js"; } from "../../shared/constants/project-cover.js";
import { maxSocketsPerCircuitUpperBound } from "../../shared/constants/socket-limit.js";
import { import {
circuitGroupCategories, circuitGroupCategories,
} from "../../shared/constants/circuit-group.js"; } from "../../shared/constants/circuit-group.js";
@ -43,7 +44,8 @@ import {
type ExternalModelStateSnapshot, type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js"; } from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 10 as const; export const projectStateSnapshotSchemaVersion = 11 as const;
const withoutSocketLimitProjectStateSnapshotSchemaVersion = 10 as const;
const legacyCsvProjectStateSnapshotSchemaVersion = 9 as const; const legacyCsvProjectStateSnapshotSchemaVersion = 9 as const;
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const; const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
const buildingProjectStateSnapshotSchemaVersion = 7 as const; const buildingProjectStateSnapshotSchemaVersion = 7 as const;
@ -87,7 +89,7 @@ const projectCoverValueSchema = (field: ProjectCoverField) =>
z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable(); z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable();
// Schema version 6 adds the print cover fields. // Schema version 6 adds the print cover fields.
const projectSchema = publicBuildingProjectSchema const coverProjectSchema = publicBuildingProjectSchema
.extend({ .extend({
siteAddress: projectCoverValueSchema("siteAddress"), siteAddress: projectCoverValueSchema("siteAddress"),
workStage: projectCoverValueSchema("workStage"), workStage: projectCoverValueSchema("workStage"),
@ -96,6 +98,18 @@ const projectSchema = publicBuildingProjectSchema
}) })
.strict(); .strict();
// Schema version 11 adds the per-project socket limit.
const projectSchema = coverProjectSchema
.extend({
maxSocketsPerCircuit: z
.number()
.int()
.min(1)
.max(maxSocketsPerCircuitUpperBound)
.nullable(),
})
.strict();
const emptyProjectCoverValues = { const emptyProjectCoverValues = {
siteAddress: null, siteAddress: null,
workStage: null, workStage: null,
@ -737,10 +751,21 @@ export const projectStateSnapshotSchema = z
}) })
.strict(); .strict();
const withoutSocketLimitProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(withoutSocketLimitProjectStateSnapshotSchemaVersion),
project: coverProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalModelConfiguration: externalModelConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const legacyCsvProjectStateSnapshotSchema = z const legacyCsvProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(legacyCsvProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(legacyCsvProjectStateSnapshotSchemaVersion),
project: projectSchema, project: coverProjectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: legacyExternalModelStateSchema, externalModel: legacyExternalModelStateSchema,
@ -751,7 +776,7 @@ const legacyCsvProjectStateSnapshotSchema = z
const roomAreaProjectStateSnapshotSchema = z const roomAreaProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
project: projectSchema, project: coverProjectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema), distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: legacyExternalModelStateSchema, externalModel: legacyExternalModelStateSchema,
@ -762,7 +787,7 @@ const roomAreaProjectStateSnapshotSchema = z
const buildingProjectStateSnapshotSchema = z const buildingProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion),
project: projectSchema, project: coverProjectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema), distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: legacyExternalModelStateSchema, externalModel: legacyExternalModelStateSchema,
@ -773,7 +798,7 @@ const buildingProjectStateSnapshotSchema = z
const coverProjectStateSnapshotSchema = z const coverProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
project: projectSchema, project: coverProjectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema), distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: legacyExternalModelStateSchema, externalModel: legacyExternalModelStateSchema,
@ -861,6 +886,28 @@ export function parseProjectStateSnapshot(
} }
function upgradePreviousProjectStateSnapshot(value: unknown): unknown { function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const withoutSocketLimit = upgradeToWithoutSocketLimitProjectStateSnapshot(value);
if (
withoutSocketLimit !== null &&
typeof withoutSocketLimit === "object" &&
!Array.isArray(withoutSocketLimit) &&
(withoutSocketLimit as { schemaVersion?: unknown }).schemaVersion ===
withoutSocketLimitProjectStateSnapshotSchemaVersion
) {
const previous =
withoutSocketLimitProjectStateSnapshotSchema.parse(withoutSocketLimit);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...previous.project, maxSocketsPerCircuit: null },
};
}
return withoutSocketLimit;
}
function upgradeToWithoutSocketLimitProjectStateSnapshot(
value: unknown
): unknown {
const legacyCsv = upgradeToLegacyCsvProjectStateSnapshot(value); const legacyCsv = upgradeToLegacyCsvProjectStateSnapshot(value);
if ( if (
legacyCsv !== null && legacyCsv !== null &&
@ -885,7 +932,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
} = previous; } = previous;
return { return {
...remaining, ...remaining,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: withoutSocketLimitProjectStateSnapshotSchemaVersion,
externalModelConfiguration: null, externalModelConfiguration: null,
externalModel: emptyExternalModelState(), externalModel: emptyExternalModelState(),
}; };

View file

@ -5,7 +5,12 @@ import {
type ExternalXlsxTransportConfiguration, type ExternalXlsxTransportConfiguration,
} from "../xlsx/external-xlsx-contracts.js"; } from "../xlsx/external-xlsx-contracts.js";
export const externalModelConfigurationSchemaVersion = 1 as const; // Version 2 adds `deviceTypes[].isSocket`. Version 1 stays readable wherever
// it is stored (configuration row, import-batch copies, history commands,
// snapshots); socket classification then derives from the family rules
// (`resolveSocketDeviceTypeIds`).
export const externalModelConfigurationSchemaVersion = 2 as const;
export const legacyExternalModelConfigurationSchemaVersion = 1 as const;
export const externalQuantityRules = ["one", "socketCount"] as const; export const externalQuantityRules = ["one", "socketCount"] as const;
export type ExternalQuantityRule = (typeof externalQuantityRules)[number]; export type ExternalQuantityRule = (typeof externalQuantityRules)[number];
@ -21,6 +26,8 @@ export interface ExternalDeviceTypeConfiguration {
name: string; name: string;
category: CircuitGroupCategory; category: CircuitGroupCategory;
defaults: ExternalDeviceTypeDefaults; defaults: ExternalDeviceTypeDefaults;
// Required from schema version 2, absent in version 1.
isSocket?: boolean;
} }
export interface ExternalFamilyTypeRule { export interface ExternalFamilyTypeRule {
@ -32,7 +39,9 @@ export interface ExternalFamilyTypeRule {
export interface ExternalModelConfiguration export interface ExternalModelConfiguration
extends ExternalXlsxTransportConfiguration { extends ExternalXlsxTransportConfiguration {
schemaVersion: typeof externalModelConfigurationSchemaVersion; schemaVersion:
| typeof externalModelConfigurationSchemaVersion
| typeof legacyExternalModelConfigurationSchemaVersion;
deviceTypes: ExternalDeviceTypeConfiguration[]; deviceTypes: ExternalDeviceTypeConfiguration[];
familyTypeRules: ExternalFamilyTypeRule[]; familyTypeRules: ExternalFamilyTypeRule[];
} }
@ -49,7 +58,11 @@ export function createDefaultExternalModelConfiguration(): ExternalModelConfigur
export function assertExternalModelConfiguration( export function assertExternalModelConfiguration(
value: unknown value: unknown
): asserts value is ExternalModelConfiguration { ): asserts value is ExternalModelConfiguration {
if (!isRecord(value) || value.schemaVersion !== externalModelConfigurationSchemaVersion) { if (
!isRecord(value) ||
(value.schemaVersion !== externalModelConfigurationSchemaVersion &&
value.schemaVersion !== legacyExternalModelConfigurationSchemaVersion)
) {
throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration."); throw new Error("Nicht unterstützte Revit-XLSX-Konfiguration.");
} }
assertExactKeys(value, [ assertExactKeys(value, [
@ -72,7 +85,11 @@ export function assertExternalModelConfiguration(
const deviceTypeIds = new Set<string>(); const deviceTypeIds = new Set<string>();
const deviceTypeNames = new Set<string>(); const deviceTypeNames = new Set<string>();
for (const [index, deviceType] of value.deviceTypes.entries()) { for (const [index, deviceType] of value.deviceTypes.entries()) {
assertDeviceType(deviceType, index); assertDeviceType(
deviceType,
index,
value.schemaVersion === externalModelConfigurationSchemaVersion
);
assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID"); assertUnique(deviceTypeIds, deviceType.id, "Geräteart-ID");
assertUnique(deviceTypeNames, deviceType.name, "Geräteartname"); assertUnique(deviceTypeNames, deviceType.name, "Geräteartname");
} }
@ -153,9 +170,22 @@ function assertColumns(
} }
} }
function assertDeviceType(value: unknown, index: number): asserts value is ExternalDeviceTypeConfiguration { function assertDeviceType(
value: unknown,
index: number,
hasSocketFlag: boolean
): asserts value is ExternalDeviceTypeConfiguration {
if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`); if (!isRecord(value)) throw new Error(`Geräteart ${index + 1} ist ungültig.`);
assertExactKeys(value, ["id", "name", "category", "defaults"], `Geräteart ${index + 1}`); assertExactKeys(
value,
hasSocketFlag
? ["id", "name", "category", "defaults", "isSocket"]
: ["id", "name", "category", "defaults"],
`Geräteart ${index + 1}`
);
if (hasSocketFlag && typeof value.isSocket !== "boolean") {
throw new Error(`Geräteart ${index + 1}.isSocket muss ja oder nein sein.`);
}
assertUuid(value.id, `Geräteart ${index + 1}.id`); assertUuid(value.id, `Geräteart ${index + 1}.id`);
assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`); assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`);
if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) { if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) {

View file

@ -13,6 +13,7 @@ import {
projectCoverFields, projectCoverFields,
type ProjectCoverField, type ProjectCoverField,
} from "../../shared/constants/project-cover"; } from "../../shared/constants/project-cover";
import { maxSocketsPerCircuitUpperBound } from "../../shared/constants/socket-limit";
import { FormModal } from "./form-modal"; import { FormModal } from "./form-modal";
export interface ProjectSettingsInput export interface ProjectSettingsInput
@ -26,6 +27,7 @@ export interface ProjectSettingsInput
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[]; enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
maxSocketsPerCircuit: number | null;
} }
const coverFieldPlaceholders: Record<ProjectCoverField, string> = { const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
@ -93,6 +95,11 @@ export function ProjectSettingsModal({
projectLead: project.projectLead ?? "", projectLead: project.projectLead ?? "",
projectEditor: project.projectEditor ?? "", projectEditor: project.projectEditor ?? "",
}); });
const [maxSocketsPerCircuit, setMaxSocketsPerCircuit] = useState(
project.maxSocketsPerCircuit === null
? ""
: String(project.maxSocketsPerCircuit)
);
const [transfer, setTransfer] = useState<unknown>(null); const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState(""); const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">( const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@ -117,6 +124,7 @@ export function ProjectSettingsModal({
workStage: toNullableString(coverValues.workStage), workStage: toNullableString(coverValues.workStage),
projectLead: toNullableString(coverValues.projectLead), projectLead: toNullableString(coverValues.projectLead),
projectEditor: toNullableString(coverValues.projectEditor), projectEditor: toNullableString(coverValues.projectEditor),
maxSocketsPerCircuit: toNullableSocketLimit(maxSocketsPerCircuit),
}); });
} }
@ -124,7 +132,8 @@ export function ProjectSettingsModal({
name.trim().length > 0 && name.trim().length > 0 &&
Number(singlePhaseVoltageV) > 0 && Number(singlePhaseVoltageV) > 0 &&
Number(threePhaseVoltageV) > 0 && Number(threePhaseVoltageV) > 0 &&
enabledDistributionBoardSupplyTypes.length > 0; enabledDistributionBoardSupplyTypes.length > 0 &&
isSocketLimitInputValid(maxSocketsPerCircuit);
function toggleSupplyType(supplyType: DistributionBoardSupplyType) { function toggleSupplyType(supplyType: DistributionBoardSupplyType) {
if ( if (
@ -335,6 +344,30 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV} value={threePhaseVoltageV}
/> />
</div> </div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="max-sockets-per-circuit">
Max. Steckdosen je Stromkreis
</label>
<input
className={`form-control ${
isSocketLimitInputValid(maxSocketsPerCircuit) ? "" : "is-invalid"
}`}
id="max-sockets-per-circuit"
max={maxSocketsPerCircuitUpperBound}
min="1"
onChange={(event) =>
setMaxSocketsPerCircuit(event.target.value)
}
placeholder="keine Grenze"
step="1"
type="number"
value={maxSocketsPerCircuit}
/>
<div className="form-text">
Vorgabe des Auftraggebers. Gilt bei der Zuordnung von
Revit-Steckdosen; leer bedeutet keine Grenze.
</div>
</div>
<div className="col-12"> <div className="col-12">
<hr className="my-2" /> <hr className="my-2" />
<h3 className="h6">Deckblatt</h3> <h3 className="h6">Deckblatt</h3>
@ -487,3 +520,19 @@ export function ProjectSettingsModal({
function toNullableString(value: string) { function toNullableString(value: string) {
return value.trim() || null; return value.trim() || null;
} }
function isSocketLimitInputValid(value: string) {
const trimmed = value.trim();
if (trimmed === "") return true;
const parsed = Number(trimmed);
return (
Number.isInteger(parsed) &&
parsed >= 1 &&
parsed <= maxSocketsPerCircuitUpperBound
);
}
function toNullableSocketLimit(value: string) {
const trimmed = value.trim();
return trimmed === "" ? null : Number(trimmed);
}

View file

@ -0,0 +1,86 @@
"use client";
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration";
import { circuitGroupCategoryLabels } from "../../shared/constants/circuit-group";
/**
* Device types of the Revit configuration with their socket flag. Only
* device types marked as socket count towards the project's socket limit
* per circuit.
*/
export function RevitDeviceTypeCard({
configuration,
isSaving,
onSave,
}: {
configuration: ExternalModelConfiguration;
isSaving: boolean;
onSave: (configuration: ExternalModelConfiguration) => Promise<void>;
}) {
const ruleCounts = new Map<string, number>();
for (const rule of configuration.familyTypeRules) {
ruleCounts.set(rule.deviceTypeId, (ruleCounts.get(rule.deviceTypeId) ?? 0) + 1);
}
function toggleSocket(deviceTypeId: string, isSocket: boolean) {
void onSave({
...configuration,
deviceTypes: configuration.deviceTypes.map((deviceType) =>
deviceType.id === deviceTypeId ? { ...deviceType, isSocket } : deviceType
),
});
}
return (
<section className="card mt-3">
<details className="card-body">
<summary className="h5 mb-0">
Gerätearten ({configuration.deviceTypes.length})
</summary>
<div className="mt-3">
{configuration.deviceTypes.length === 0 ? (
<p className="text-secondary mb-0">
Gerätearten entstehen bei der Familienzuordnung im Import.
</p>
) : (
<>
<p className="text-secondary small">
Als „Steckdose“ markierte Gerätearten zählen für den
Höchstwert je Stromkreis aus den Projekteinstellungen.
</p>
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Geräteart</th>
<th>Kategorie</th>
<th className="text-end">Familien</th>
<th className="text-center">Steckdose</th>
</tr>
</thead>
<tbody>
{configuration.deviceTypes.map((deviceType) => (
<tr key={deviceType.id}>
<td>{deviceType.name}</td>
<td>{circuitGroupCategoryLabels[deviceType.category]}</td>
<td className="text-end">{ruleCounts.get(deviceType.id) ?? 0}</td>
<td className="text-center">
<input
aria-label={`${deviceType.name} ist Steckdose`}
checked={deviceType.isSocket === true}
className="form-check-input"
disabled={isSaving}
onChange={(event) => toggleSocket(deviceType.id, event.target.checked)}
type="checkbox"
/>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</details>
</section>
);
}

View file

@ -40,7 +40,7 @@ export function RevitFollowUpImportWizard({ projectId, expectedRevision, initial
if (!file) return; if (!file) return;
const next = structuredClone(configuration); const next = structuredClone(configuration);
const targetTypeId = deviceTypeId ?? createClientId(); const targetTypeId = deviceTypeId ?? createClientId();
if (!deviceTypeId) next.deviceTypes.push({ id: targetTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } }); if (!deviceTypeId) next.deviceTypes.push({ id: targetTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null }, ...(next.schemaVersion === 2 ? { isSocket: quantityRule === "socketCount" } : {}) });
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId: targetTypeId, quantityRule }); next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId: targetTypeId, quantityRule });
setConfiguration(next); await loadPlan(file, next); setConfiguration(next); await loadPlan(file, next);
} }

View file

@ -44,7 +44,7 @@ export function RevitInitialImportWizard({ projectId, expectedRevision, initialC
if (!file) return; if (!file) return;
const deviceTypeId = createClientId(); const deviceTypeId = createClientId();
const next = structuredClone(configuration); const next = structuredClone(configuration);
next.deviceTypes.push({ id: deviceTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null } }); next.deviceTypes.push({ id: deviceTypeId, name: familyAndType, category, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: null, costGroup: null }, ...(next.schemaVersion === 2 ? { isSocket: quantityRule === "socketCount" } : {}) });
next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule }); next.familyTypeRules.push({ id: createClientId(), exactFamilyAndType: familyAndType, deviceTypeId, quantityRule });
setConfiguration(next); setConfiguration(next);
await loadPlan(file, next); await loadPlan(file, next);

View file

@ -194,6 +194,7 @@ export interface ProjectDto {
workStage: string | null; workStage: string | null;
projectLead: string | null; projectLead: string | null;
projectEditor: string | null; projectEditor: string | null;
maxSocketsPerCircuit: number | null;
currentRevision: number; currentRevision: number;
} }

View file

@ -446,7 +446,7 @@ export function updateProjectSettings(
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"]; enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
} & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor">> } & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor" | "maxSocketsPerCircuit">>
) { ) {
return request<ProjectSettingsCommandResultDto>( return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`, `/api/projects/${projectId}`,

View file

@ -2,7 +2,7 @@ import type { ExternalModelConfiguration } from "../../external-model/configurat
export function createDefaultRevitXlsxConfiguration(): ExternalModelConfiguration { export function createDefaultRevitXlsxConfiguration(): ExternalModelConfiguration {
return { return {
schemaVersion: 1, schemaVersion: 2,
layout: { headerRow: 1, firstDataRow: 3, firstDataColumn: 1 }, layout: { headerRow: 1, firstDataRow: 3, firstDataColumn: 1 },
circuitMode: "combined", circuitMode: "combined",
columns: { columns: {

View file

@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { createExternalModelConfigurationUpdateProjectCommand } from "../../domain/models/external-model-configuration-project-command.model.js"; import { createExternalModelConfigurationUpdateProjectCommand } from "../../domain/models/external-model-configuration-project-command.model.js";
import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js"; import { assertExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
import { withSocketDeviceTypeFlags } from "../../shared/revit/socket-device-types.js";
import { parseExternalXlsx } from "../../external-model/xlsx/external-xlsx-transport.js"; import { parseExternalXlsx } from "../../external-model/xlsx/external-xlsx-transport.js";
import { externalModelConfigurationRepository } from "../composition/application-repositories.js"; import { externalModelConfigurationRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
@ -11,7 +12,16 @@ export function getExternalModelConfiguration(req: Request, res: Response) {
const projectId = String(req.params.projectId ?? ""); const projectId = String(req.params.projectId ?? "");
const result = externalModelConfigurationRepository.getByProject(projectId); const result = externalModelConfigurationRepository.getByProject(projectId);
if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); if (!result.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." });
return res.json({ configuration: result.configuration }); // Clients always edit version 2; a stored version 1 is upgraded here so a
// later save writes the explicit socket flags.
return res.json({
configuration: result.configuration === null
? null
: {
...result.configuration,
configuration: withSocketDeviceTypeFlags(result.configuration.configuration),
},
});
} }
export function updateExternalModelConfiguration(req: Request, res: Response) { export function updateExternalModelConfiguration(req: Request, res: Response) {

View file

@ -70,6 +70,10 @@ export async function updateProjectSettings(req: Request, res: Response) {
command: createProjectSettingsUpdateProjectCommand({ command: createProjectSettingsUpdateProjectCommand({
...settings, ...settings,
...coverValues, ...coverValues,
maxSocketsPerCircuit:
settings.maxSocketsPerCircuit === undefined
? current.maxSocketsPerCircuit
: settings.maxSocketsPerCircuit,
}), }),
}); });
const project = await projectRepository.findById(projectId); const project = await projectRepository.findById(projectId);

View file

@ -0,0 +1,19 @@
/**
* Client-specific highest number of effective sockets per circuit. It is an
* ordinary project setting (`project.update-settings`, logical snapshots and
* project transfer); `null` means no limit. Only Revit objects whose device
* type is marked as socket count towards it.
*/
export const maxSocketsPerCircuitUpperBound = 1000;
export function isValidMaxSocketsPerCircuit(
value: unknown
): value is number | null {
return (
value === null ||
(typeof value === "number" &&
Number.isInteger(value) &&
value >= 1 &&
value <= maxSocketsPerCircuitUpperBound)
);
}

View file

@ -0,0 +1,61 @@
/**
* Socket classification of Revit device types, shared by API and frontend.
*
* Configuration schema version 2 stores an explicit `isSocket` flag per
* device type. Version 1 has no flag; there a device type counts as socket
* when one of its family rules takes the quantity from the socket-count
* column. This file has no imports because Turbopack resolves frontend
* imports without the `.js` suffix the API build needs.
*/
export const socketFlagConfigurationSchemaVersion = 2 as const;
export interface SocketClassificationConfiguration {
schemaVersion: number;
deviceTypes: ReadonlyArray<{ id: string; isSocket?: boolean }>;
familyTypeRules: ReadonlyArray<{ deviceTypeId: string; quantityRule: string }>;
}
export function deriveSocketDeviceTypeIds(
familyTypeRules: SocketClassificationConfiguration["familyTypeRules"]
): Set<string> {
return new Set(
familyTypeRules
.filter((rule) => rule.quantityRule === "socketCount")
.map((rule) => rule.deviceTypeId)
);
}
export function resolveSocketDeviceTypeIds(
configuration: SocketClassificationConfiguration | null
): Set<string> {
if (configuration === null) return new Set();
if (configuration.schemaVersion >= socketFlagConfigurationSchemaVersion) {
return new Set(
configuration.deviceTypes
.filter((deviceType) => deviceType.isSocket === true)
.map((deviceType) => deviceType.id)
);
}
return deriveSocketDeviceTypeIds(configuration.familyTypeRules);
}
/** Returns a version-2 configuration; version 1 derives the socket flags. */
export function withSocketDeviceTypeFlags<
T extends SocketClassificationConfiguration & {
deviceTypes: Array<{ id: string; isSocket?: boolean }>;
},
>(configuration: T): T {
if (configuration.schemaVersion >= socketFlagConfigurationSchemaVersion) {
return configuration;
}
const socketIds = deriveSocketDeviceTypeIds(configuration.familyTypeRules);
return {
...configuration,
schemaVersion: socketFlagConfigurationSchemaVersion,
deviceTypes: configuration.deviceTypes.map((deviceType) => ({
...deviceType,
isSocket: socketIds.has(deviceType.id),
})),
};
}

View file

@ -8,6 +8,7 @@ import {
projectCoverFieldMaxLengths, projectCoverFieldMaxLengths,
type ProjectCoverField, type ProjectCoverField,
} from "../constants/project-cover.js"; } from "../constants/project-cover.js";
import { maxSocketsPerCircuitUpperBound } from "../constants/socket-limit.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
// Empty text is stored as null so "not entered" has one representation. // Empty text is stored as null so "not entered" has one representation.
@ -53,6 +54,14 @@ export const updateProjectSettingsSchema = z
workStage: optionalCoverValueSchema("workStage"), workStage: optionalCoverValueSchema("workStage"),
projectLead: optionalCoverValueSchema("projectLead"), projectLead: optionalCoverValueSchema("projectLead"),
projectEditor: optionalCoverValueSchema("projectEditor"), projectEditor: optionalCoverValueSchema("projectEditor"),
// Socket limit per circuit; null = no limit, omitted = unchanged.
maxSocketsPerCircuit: z
.number()
.int()
.min(1)
.max(maxSocketsPerCircuitUpperBound)
.nullable()
.optional(),
}) })
.strict(); .strict();

View file

@ -29,7 +29,7 @@ import { createRevitXlsxFixture, externalXlsxTestConfiguration, loadXlsxWorkbook
const deviceTypeA = "10000000-0000-4000-8000-000000000001"; const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002"; const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] }; const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null }, isSocket: true }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null }, isSocket: false }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }; const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
describe("external follow-up import project command", () => { describe("external follow-up import project command", () => {

View file

@ -14,6 +14,10 @@ import {
assertExternalModelConfiguration, assertExternalModelConfiguration,
createDefaultExternalModelConfiguration, createDefaultExternalModelConfiguration,
} from "../src/external-model/configuration/external-model-configuration.js"; } from "../src/external-model/configuration/external-model-configuration.js";
import {
resolveSocketDeviceTypeIds,
withSocketDeviceTypeFlags,
} from "../src/shared/revit/socket-device-types.js";
const deviceTypeId = "10000000-0000-4000-8000-000000000001"; const deviceTypeId = "10000000-0000-4000-8000-000000000001";
const ruleId = "20000000-0000-4000-8000-000000000001"; const ruleId = "20000000-0000-4000-8000-000000000001";
@ -25,6 +29,7 @@ function configuration() {
name: "Steckdose", name: "Steckdose",
category: "single_phase", category: "single_phase",
defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" }, defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" },
isSocket: true,
}); });
value.familyTypeRules.push({ value.familyTypeRules.push({
id: ruleId, id: ruleId,
@ -61,6 +66,27 @@ describe("external model configuration", () => {
assert.throws(() => assertExternalModelConfiguration(unknownType), /unbekannte Ger.*teart/); assert.throws(() => assertExternalModelConfiguration(unknownType), /unbekannte Ger.*teart/);
}); });
it("requires the socket flag in version 2 and keeps version 1 readable", () => {
const missingFlag = configuration();
delete missingFlag.deviceTypes[0]!.isSocket;
assert.throws(() => assertExternalModelConfiguration(missingFlag), /unbekannte oder fehlende Felder/);
const legacy = { ...configuration(), schemaVersion: 1 as const };
assert.throws(() => assertExternalModelConfiguration(legacy), /unbekannte oder fehlende Felder/);
delete legacy.deviceTypes[0]!.isSocket;
assert.doesNotThrow(() => assertExternalModelConfiguration(legacy));
assert.deepEqual([...resolveSocketDeviceTypeIds(legacy)], [deviceTypeId]);
const upgraded = withSocketDeviceTypeFlags(legacy);
assert.equal(upgraded.schemaVersion, 2);
assert.equal(upgraded.deviceTypes[0]!.isSocket, true);
assert.doesNotThrow(() => assertExternalModelConfiguration(upgraded));
const notSocket = configuration();
notSocket.deviceTypes[0]!.isSocket = false;
assert.equal(resolveSocketDeviceTypeIds(notSocket).size, 0);
});
it("persists changes without an independent configuration revision", () => { it("persists changes without an independent configuration revision", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -12,7 +12,7 @@ const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002"; const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003"; const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003";
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }; const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] }; const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null }, isSocket: true }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null }, isSocket: false }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
async function initialState() { async function initialState() {
const bytes = await createRevitXlsxFixture(); const bytes = await createRevitXlsxFixture();

View file

@ -23,8 +23,8 @@ function configured() {
...createDefaultExternalModelConfiguration(), ...createDefaultExternalModelConfiguration(),
...externalXlsxTestConfiguration, ...externalXlsxTestConfiguration,
deviceTypes: [ deviceTypes: [
{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: null, cosPhi: 0.9, costGroup: "440" } }, { id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: null, cosPhi: 0.9, costGroup: "440" }, isSocket: true },
{ id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null }, isSocket: false },
], ],
familyTypeRules: [ familyTypeRules: [
{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const },

View file

@ -1081,6 +1081,7 @@ describe("project command service", () => {
workStage: null, workStage: null,
projectLead: null, projectLead: null,
projectEditor: null, projectEditor: null,
maxSocketsPerCircuit: null,
}), }),
}); });
assert.equal(updated.history.currentRevision, 1); assert.equal(updated.history.currentRevision, 1);

View file

@ -56,6 +56,7 @@ function getSettings(context: DatabaseContext) {
workStage: projects.workStage, workStage: projects.workStage,
projectLead: projects.projectLead, projectLead: projects.projectLead,
projectEditor: projects.projectEditor, projectEditor: projects.projectEditor,
maxSocketsPerCircuit: projects.maxSocketsPerCircuit,
currentRevision: projects.currentRevision, currentRevision: projects.currentRevision,
}) })
.from(projects) .from(projects)
@ -91,6 +92,7 @@ function settings(
workStage: null, workStage: null,
projectLead: null, projectLead: null,
projectEditor: null, projectEditor: null,
maxSocketsPerCircuit: null,
...overrides, ...overrides,
}; };
} }
@ -231,6 +233,7 @@ describe("project settings project-command repository", () => {
workStage: null, workStage: null,
projectLead: null, projectLead: null,
projectEditor: null, projectEditor: null,
maxSocketsPerCircuit: null,
}, },
}, },
]); ]);
@ -307,6 +310,7 @@ describe("project settings project-command repository", () => {
workStage: null, workStage: null,
projectLead: null, projectLead: null,
projectEditor: null, projectEditor: null,
maxSocketsPerCircuit: null,
}); });
const undoStep = history.getNextCommand("project-1", "undo"); const undoStep = history.getNextCommand("project-1", "undo");
@ -339,6 +343,7 @@ describe("project settings project-command repository", () => {
workStage: null, workStage: null,
projectLead: null, projectLead: null,
projectEditor: null, projectEditor: null,
maxSocketsPerCircuit: null,
currentRevision: 2, currentRevision: 2,
}); });
assert.deepEqual(getDerivedVoltages(context), { assert.deepEqual(getDerivedVoltages(context), {
@ -382,6 +387,7 @@ describe("project settings project-command repository", () => {
workStage: null, workStage: null,
projectLead: null, projectLead: null,
projectEditor: null, projectEditor: null,
maxSocketsPerCircuit: null,
currentRevision: 3, currentRevision: 3,
}); });
assert.deepEqual(getDerivedVoltages(context), { assert.deepEqual(getDerivedVoltages(context), {
@ -495,7 +501,7 @@ describe("project settings project-command repository", () => {
projectLead: "P. Leitung", projectLead: "P. Leitung",
projectEditor: "B. Arbeiter", projectEditor: "B. Arbeiter",
}); });
assert.equal(forward.inverse.schemaVersion, 3); assert.equal(forward.inverse.schemaVersion, 4);
const undoStep = new ProjectHistoryRepository( const undoStep = new ProjectHistoryRepository(
context.db context.db
@ -543,6 +549,7 @@ describe("project settings project-command repository", () => {
workStage: _workStage, workStage: _workStage,
projectLead: _projectLead, projectLead: _projectLead,
projectEditor: _projectEditor, projectEditor: _projectEditor,
maxSocketsPerCircuit: _maxSocketsPerCircuit,
...versionTwoPayload ...versionTwoPayload
} = settings({ name: "Stand aus Version 2" }); } = settings({ name: "Stand aus Version 2" });
const forward = repository.executeUpdate({ const forward = repository.executeUpdate({
@ -570,6 +577,83 @@ describe("project settings project-command repository", () => {
} }
}); });
it("persists the socket limit with undo and keeps it for version 3 commands", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const readLimit = () =>
context.db
.select({ maxSocketsPerCircuit: projects.maxSocketsPerCircuit })
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.maxSocketsPerCircuit;
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand(
settings({ maxSocketsPerCircuit: 8 })
),
});
assert.equal(readLimit(), 8);
assert.equal(forward.inverse.schemaVersion, 4);
assert.equal(
(forward.inverse.payload as { maxSocketsPerCircuit: unknown })
.maxSocketsPerCircuit,
null
);
const {
maxSocketsPerCircuit: _maxSocketsPerCircuit,
...versionThreePayload
} = settings({ name: "Stand aus Version 3" });
const versionThree = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: {
schemaVersion: 3,
type: "project.update-settings",
payload: versionThreePayload,
},
});
assert.equal(readLimit(), 8);
assert.equal(versionThree.inverse.schemaVersion, 3);
assert.equal(
"maxSocketsPerCircuit" in versionThree.inverse.payload,
false
);
const history = new ProjectHistoryRepository(context.db);
for (const expectedRevision of [2, 3]) {
const undoStep = history.getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: undoStep.command as never,
});
}
assert.equal(readLimit(), null);
for (const invalid of [0, 2.5, 1001]) {
assert.throws(
() =>
createProjectSettingsUpdateProjectCommand(
settings({ maxSocketsPerCircuit: invalid })
),
/invalid values/
);
}
} finally {
context.close();
}
});
it("persists the project supply-type selection and rejects disabling an in-use type", () => { it("persists the project supply-type selection and rejects disabling an in-use type", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -474,7 +474,7 @@ describe("project snapshot repository", () => {
for (const room of payload.rooms) { for (const room of payload.rooms) {
delete room.areaM2; delete room.areaM2;
} }
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor", "maxSocketsPerCircuit"]) {
delete payload.project[field]; delete payload.project[field];
} }
for (const board of payload.distributionBoards) { for (const board of payload.distributionBoards) {

View file

@ -13,10 +13,19 @@ import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js"; import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
import { zodIssues } from "./support/zod-assertions.js"; import { zodIssues } from "./support/zod-assertions.js";
/** Removes the fields introduced by snapshot schema version 9. */ /** Removes the field introduced by snapshot schema version 11. */
function withoutVersionElevenFields<T extends { project: object }>(
snapshot: T
): T {
delete (snapshot.project as Record<string, unknown>).maxSocketsPerCircuit;
return snapshot;
}
/** Removes the fields introduced by snapshot schema versions 9 to 11. */
function withoutVersionNineFields< function withoutVersionNineFields<
T extends { distributionBoards: object[] }, T extends { project: object; distributionBoards: object[] },
>(snapshot: T): T { >(snapshot: T): T {
withoutVersionElevenFields(snapshot);
const record = snapshot as Record<string, unknown>; const record = snapshot as Record<string, unknown>;
record.externalCsvConfiguration = null; record.externalCsvConfiguration = null;
delete record.externalModelConfiguration; delete record.externalModelConfiguration;
@ -29,7 +38,7 @@ function withoutVersionNineFields<
/** Removes the fields introduced by snapshot schema version 7. */ /** Removes the fields introduced by snapshot schema version 7. */
function withoutVersionSevenFields< function withoutVersionSevenFields<
T extends { floors: object[]; distributionBoards: object[] }, T extends { project: object; floors: object[]; distributionBoards: object[] },
>(snapshot: T): T { >(snapshot: T): T {
withoutVersionNineFields(snapshot); withoutVersionNineFields(snapshot);
delete (snapshot as Record<string, unknown>).buildings; delete (snapshot as Record<string, unknown>).buildings;
@ -82,6 +91,7 @@ function minimalSnapshot() {
workStage: "LPH 5" as string | null, workStage: "LPH 5" as string | null,
projectLead: "P. Leitung" as string | null, projectLead: "P. Leitung" as string | null,
projectEditor: null as string | null, projectEditor: null as string | null,
maxSocketsPerCircuit: null as number | null,
}, },
distributionBoards: [], distributionBoards: [],
externalModelConfiguration: null, externalModelConfiguration: null,
@ -351,6 +361,24 @@ describe("project state snapshot model", () => {
}); });
}); });
it("keeps the socket limit in version 11 and upgrades version 10 without a limit", () => {
const current = structuredClone(minimalSnapshot());
current.project.maxSocketsPerCircuit = 8;
assert.equal(parseProjectStateSnapshot(current).project.maxSocketsPerCircuit, 8);
const previous = withoutVersionElevenFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 10 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.project.maxSocketsPerCircuit, null);
const invalid = structuredClone(minimalSnapshot());
invalid.project.maxSocketsPerCircuit = 0;
assert.throws(() => parseProjectStateSnapshot(invalid));
const missing = withoutVersionElevenFields(structuredClone(minimalSnapshot()));
assert.throws(() => parseProjectStateSnapshot(missing));
});
it("upgrades snapshot version 3 with an empty external model", () => { it("upgrades snapshot version 3 with an empty external model", () => {
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot())); const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 3 as typeof previous.schemaVersion; previous.schemaVersion = 3 as typeof previous.schemaVersion;

View file

@ -48,7 +48,7 @@ const circuitId = "circuit-e2e";
const rowId = "row-e2e"; const rowId = "row-e2e";
const deviceTypeA = "10000000-0000-4000-8000-000000000001"; const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002"; const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, cosPhi: 0.9, costGroup: "440" } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] }; const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, cosPhi: 0.9, costGroup: "440" }, isSocket: true }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null }, isSocket: false }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
describe("Linear XLSX end-to-end", () => { describe("Linear XLSX end-to-end", () => {
it("survives return, follow-up, snapshot restore, duplicate and restart undo/redo", async () => { it("survives return, follow-up, snapshot restore, duplicate and restart undo/redo", async () => {

View file

@ -29,7 +29,7 @@ const deviceTypeId = "10000000-0000-4000-8000-000000000001";
const configuration = { const configuration = {
...createDefaultExternalModelConfiguration(), ...createDefaultExternalModelConfiguration(),
...externalXlsxTestConfiguration, ...externalXlsxTestConfiguration,
deviceTypes: [{ id: deviceTypeId, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, cosPhi: 0.9, costGroup: "440" } }], deviceTypes: [{ id: deviceTypeId, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: 125, simultaneityFactor: 1, cosPhi: 0.9, costGroup: "440" }, isSocket: true }],
familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId, quantityRule: "socketCount" as const }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId, quantityRule: "socketCount" as const }],
}; };