forked from jappel/leistungsbilanz-ts
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:
parent
85cbdbf8e0
commit
e48042bde3
35 changed files with 3781 additions and 41 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
1
src/db/migrations/0018_project_socket_limit.sql
Normal file
1
src/db/migrations/0018_project_socket_limit.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `projects` ADD `max_sockets_per_circuit` integer;
|
||||
3217
src/db/migrations/meta/0018_snapshot.json
Normal file
3217
src/db/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,17 +54,24 @@ 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;
|
||||
? { ...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,7 +96,10 @@ export class ProjectSettingsProjectCommandRepository
|
|||
: command.schemaVersion ===
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
||||
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
|
||||
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
||||
: command.schemaVersion ===
|
||||
withoutSocketLimitProjectSettingsUpdateCommandSchemaVersion
|
||||
? createWithoutSocketLimitProjectSettingsUpdateCommand(inverseValues)
|
||||
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
||||
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||
const updated = tx
|
||||
.update(projects)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class ProjectRepository {
|
|||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
maxSocketsPerCircuit: null,
|
||||
currentRevision: 0,
|
||||
};
|
||||
await db.insert(projects).values(project);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
86
src/frontend/components/revit-device-type-card.tsx
Normal file
86
src/frontend/components/revit-device-type-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export interface ProjectDto {
|
|||
workStage: string | null;
|
||||
projectLead: string | null;
|
||||
projectEditor: string | null;
|
||||
maxSocketsPerCircuit: number | null;
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
19
src/shared/constants/socket-limit.ts
Normal file
19
src/shared/constants/socket-limit.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
61
src/shared/revit/socket-device-types.ts
Normal file
61
src/shared/revit/socket-device-types.ts
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -1081,6 +1081,7 @@ describe("project command service", () => {
|
|||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
maxSocketsPerCircuit: null,
|
||||
}),
|
||||
});
|
||||
assert.equal(updated.history.currentRevision, 1);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue