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",
"project_lead",
"project_editor",
"max_sockets_per_circuit",
];
const projectColumns = new Set(
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 { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-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 { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal";
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>
{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>
{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}
{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}

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,
"tag": "0017_circuit_protection_none",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1793391088159,
"tag": "0018_project_socket_limit",
"breakpoints": true
}
]
}

View file

@ -4,6 +4,7 @@ import {
baselineProjectSettingsUpdateCommandSchemaVersion,
createProjectSettingsUpdateProjectCommand,
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.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
// inverse of the same version.
const command = input.command;
const currentSocketLimit = {
maxSocketsPerCircuit: current.maxSocketsPerCircuit,
};
const target: ProjectSettingsValues =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? {
...command.payload,
isPublicBuilding: current.isPublicBuilding,
...currentCoverValues,
...currentSocketLimit,
}
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentCoverValues }
? { ...command.payload, ...currentCoverValues, ...currentSocketLimit }
: command.schemaVersion ===
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentSocketLimit }
: command.payload;
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
@ -80,6 +88,7 @@ export class ProjectSettingsProjectCommandRepository
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
...currentCoverValues,
...currentSocketLimit,
};
const inverse =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
@ -87,6 +96,9 @@ export class ProjectSettingsProjectCommandRepository
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
: command.schemaVersion ===
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion
? createWithoutSocketLimitProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues);
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
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(
values: ProjectSettingsValues
) {
@ -158,6 +181,7 @@ function projectSettingsEqual(
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV &&
projectCoverFields.every((field) => current[field] === target[field]) &&
current.maxSocketsPerCircuit === target.maxSocketsPerCircuit &&
sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes

View file

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

View file

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

View file

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

View file

@ -25,5 +25,7 @@ export const projects = sqliteTable("projects", {
workStage: text("work_stage"),
projectLead: text("project_lead"),
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),
});

View file

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

View file

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

View file

@ -5,7 +5,12 @@ import {
type ExternalXlsxTransportConfiguration,
} 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 type ExternalQuantityRule = (typeof externalQuantityRules)[number];
@ -21,6 +26,8 @@ export interface ExternalDeviceTypeConfiguration {
name: string;
category: CircuitGroupCategory;
defaults: ExternalDeviceTypeDefaults;
// Required from schema version 2, absent in version 1.
isSocket?: boolean;
}
export interface ExternalFamilyTypeRule {
@ -32,7 +39,9 @@ export interface ExternalFamilyTypeRule {
export interface ExternalModelConfiguration
extends ExternalXlsxTransportConfiguration {
schemaVersion: typeof externalModelConfigurationSchemaVersion;
schemaVersion:
| typeof externalModelConfigurationSchemaVersion
| typeof legacyExternalModelConfigurationSchemaVersion;
deviceTypes: ExternalDeviceTypeConfiguration[];
familyTypeRules: ExternalFamilyTypeRule[];
}
@ -49,7 +58,11 @@ export function createDefaultExternalModelConfiguration(): ExternalModelConfigur
export function assertExternalModelConfiguration(
value: unknown
): 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.");
}
assertExactKeys(value, [
@ -72,7 +85,11 @@ export function assertExternalModelConfiguration(
const deviceTypeIds = new Set<string>();
const deviceTypeNames = new Set<string>();
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(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.`);
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`);
assertNonEmptyString(value.name, `Geräteart ${index + 1}.name`);
if (!(["lighting", "single_phase", "three_phase"] as unknown[]).includes(value.category)) {

View file

@ -13,6 +13,7 @@ import {
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover";
import { maxSocketsPerCircuitUpperBound } from "../../shared/constants/socket-limit";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput
@ -26,6 +27,7 @@ export interface ProjectSettingsInput
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
maxSocketsPerCircuit: number | null;
}
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
@ -93,6 +95,11 @@ export function ProjectSettingsModal({
projectLead: project.projectLead ?? "",
projectEditor: project.projectEditor ?? "",
});
const [maxSocketsPerCircuit, setMaxSocketsPerCircuit] = useState(
project.maxSocketsPerCircuit === null
? ""
: String(project.maxSocketsPerCircuit)
);
const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@ -117,6 +124,7 @@ export function ProjectSettingsModal({
workStage: toNullableString(coverValues.workStage),
projectLead: toNullableString(coverValues.projectLead),
projectEditor: toNullableString(coverValues.projectEditor),
maxSocketsPerCircuit: toNullableSocketLimit(maxSocketsPerCircuit),
});
}
@ -124,7 +132,8 @@ export function ProjectSettingsModal({
name.trim().length > 0 &&
Number(singlePhaseVoltageV) > 0 &&
Number(threePhaseVoltageV) > 0 &&
enabledDistributionBoardSupplyTypes.length > 0;
enabledDistributionBoardSupplyTypes.length > 0 &&
isSocketLimitInputValid(maxSocketsPerCircuit);
function toggleSupplyType(supplyType: DistributionBoardSupplyType) {
if (
@ -335,6 +344,30 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV}
/>
</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">
<hr className="my-2" />
<h3 className="h6">Deckblatt</h3>
@ -487,3 +520,19 @@ export function ProjectSettingsModal({
function toNullableString(value: string) {
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;
const next = structuredClone(configuration);
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 });
setConfiguration(next); await loadPlan(file, next);
}

View file

@ -44,7 +44,7 @@ export function RevitInitialImportWizard({ projectId, expectedRevision, initialC
if (!file) return;
const deviceTypeId = createClientId();
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 });
setConfiguration(next);
await loadPlan(file, next);

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { createExternalModelConfigurationUpdateProjectCommand } from "../../domain/models/external-model-configuration-project-command.model.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 { externalModelConfigurationRepository } from "../composition/application-repositories.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 result = externalModelConfigurationRepository.getByProject(projectId);
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) {

View file

@ -70,6 +70,10 @@ export async function updateProjectSettings(req: Request, res: Response) {
command: createProjectSettingsUpdateProjectCommand({
...settings,
...coverValues,
maxSocketsPerCircuit:
settings.maxSocketsPerCircuit === undefined
? current.maxSocketsPerCircuit
: settings.maxSocketsPerCircuit,
}),
});
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,
type ProjectCoverField,
} from "../constants/project-cover.js";
import { maxSocketsPerCircuitUpperBound } from "../constants/socket-limit.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
// Empty text is stored as null so "not entered" has one representation.
@ -53,6 +54,14 @@ export const updateProjectSettingsSchema = z
workStage: optionalCoverValueSchema("workStage"),
projectLead: optionalCoverValueSchema("projectLead"),
projectEditor: optionalCoverValueSchema("projectEditor"),
// Socket limit per circuit; null = no limit, omitted = unchanged.
maxSocketsPerCircuit: z
.number()
.int()
.min(1)
.max(maxSocketsPerCircuitUpperBound)
.nullable()
.optional(),
})
.strict();

View file

@ -29,7 +29,7 @@ import { createRevitXlsxFixture, externalXlsxTestConfiguration, loadXlsxWorkbook
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
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: [] };
describe("external follow-up import project command", () => {

View file

@ -14,6 +14,10 @@ import {
assertExternalModelConfiguration,
createDefaultExternalModelConfiguration,
} 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 ruleId = "20000000-0000-4000-8000-000000000001";
@ -25,6 +29,7 @@ function configuration() {
name: "Steckdose",
category: "single_phase",
defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" },
isSocket: true,
});
value.familyTypeRules.push({
id: ruleId,
@ -61,6 +66,27 @@ describe("external model configuration", () => {
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", () => {
const context = createTestDatabase();
try {

View file

@ -12,7 +12,7 @@ const deviceTypeA = "10000000-0000-4000-8000-000000000001";
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
const newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003";
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() {
const bytes = await createRevitXlsxFixture();

View file

@ -23,8 +23,8 @@ function configured() {
...createDefaultExternalModelConfiguration(),
...externalXlsxTestConfiguration,
deviceTypes: [
{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: null, cosPhi: 0.9, costGroup: "440" } },
{ id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 0.8, cosPhi: null, costGroup: null } },
{ 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 }, isSocket: false },
],
familyTypeRules: [
{ 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,
projectLead: null,
projectEditor: null,
maxSocketsPerCircuit: null,
}),
});
assert.equal(updated.history.currentRevision, 1);

View file

@ -56,6 +56,7 @@ function getSettings(context: DatabaseContext) {
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
maxSocketsPerCircuit: projects.maxSocketsPerCircuit,
currentRevision: projects.currentRevision,
})
.from(projects)
@ -91,6 +92,7 @@ function settings(
workStage: null,
projectLead: null,
projectEditor: null,
maxSocketsPerCircuit: null,
...overrides,
};
}
@ -231,6 +233,7 @@ describe("project settings project-command repository", () => {
workStage: null,
projectLead: null,
projectEditor: null,
maxSocketsPerCircuit: null,
},
},
]);
@ -307,6 +310,7 @@ describe("project settings project-command repository", () => {
workStage: null,
projectLead: null,
projectEditor: null,
maxSocketsPerCircuit: null,
});
const undoStep = history.getNextCommand("project-1", "undo");
@ -339,6 +343,7 @@ describe("project settings project-command repository", () => {
workStage: null,
projectLead: null,
projectEditor: null,
maxSocketsPerCircuit: null,
currentRevision: 2,
});
assert.deepEqual(getDerivedVoltages(context), {
@ -382,6 +387,7 @@ describe("project settings project-command repository", () => {
workStage: null,
projectLead: null,
projectEditor: null,
maxSocketsPerCircuit: null,
currentRevision: 3,
});
assert.deepEqual(getDerivedVoltages(context), {
@ -495,7 +501,7 @@ describe("project settings project-command repository", () => {
projectLead: "P. Leitung",
projectEditor: "B. Arbeiter",
});
assert.equal(forward.inverse.schemaVersion, 3);
assert.equal(forward.inverse.schemaVersion, 4);
const undoStep = new ProjectHistoryRepository(
context.db
@ -543,6 +549,7 @@ describe("project settings project-command repository", () => {
workStage: _workStage,
projectLead: _projectLead,
projectEditor: _projectEditor,
maxSocketsPerCircuit: _maxSocketsPerCircuit,
...versionTwoPayload
} = settings({ name: "Stand aus Version 2" });
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", () => {
const context = createTestDatabase();
try {

View file

@ -474,7 +474,7 @@ describe("project snapshot repository", () => {
for (const room of payload.rooms) {
delete room.areaM2;
}
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor", "maxSocketsPerCircuit"]) {
delete payload.project[field];
}
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 { 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<
T extends { distributionBoards: object[] },
T extends { project: object; distributionBoards: object[] },
>(snapshot: T): T {
withoutVersionElevenFields(snapshot);
const record = snapshot as Record<string, unknown>;
record.externalCsvConfiguration = null;
delete record.externalModelConfiguration;
@ -29,7 +38,7 @@ function withoutVersionNineFields<
/** Removes the fields introduced by snapshot schema version 7. */
function withoutVersionSevenFields<
T extends { floors: object[]; distributionBoards: object[] },
T extends { project: object; floors: object[]; distributionBoards: object[] },
>(snapshot: T): T {
withoutVersionNineFields(snapshot);
delete (snapshot as Record<string, unknown>).buildings;
@ -82,6 +91,7 @@ function minimalSnapshot() {
workStage: "LPH 5" as string | null,
projectLead: "P. Leitung" as string | null,
projectEditor: null as string | null,
maxSocketsPerCircuit: null as number | null,
},
distributionBoards: [],
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", () => {
const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 3 as typeof previous.schemaVersion;

View file

@ -48,7 +48,7 @@ const circuitId = "circuit-e2e";
const rowId = "row-e2e";
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
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", () => {
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 = {
...createDefaultExternalModelConfiguration(),
...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 }],
};