Merge origin/main into feature/user-management

Renumber the users migration to 0013_users so it follows the upstream
0012_good_gateway (logical board feeds); AGENTS.md deferred-work list keeps
both the new feed items and the auth items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-28 21:46:21 +02:00
commit 95bfba2f67
63 changed files with 5546 additions and 443 deletions

View file

@ -0,0 +1,217 @@
export interface DistributionBoardLoadBoardInput {
id: string;
name: string;
boardNumber: string | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
export interface DistributionBoardLoadCircuitInput {
id: string;
distributionBoardId: string;
rows: Array<{
activePowerKw: number;
cosPhi: number | null;
}>;
}
export interface DownstreamDistributionBoardLoad {
distributionBoardId: string;
name: string;
boardNumber: string | null;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CalculatedCircuitLoad {
circuitId: string;
deviceActivePowerKw: number;
deviceApparentPowerKva: number;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
downstreamBoards: DownstreamDistributionBoardLoad[];
}
export interface CalculatedDistributionBoardLoad {
distributionBoardId: string;
activePowerBeforeSimultaneityKw: number;
apparentPowerBeforeSimultaneityKva: number;
demandActivePowerKw: number;
demandApparentPowerKva: number;
cosPhi: number;
}
export interface DistributionBoardLoadCalculation {
boards: Map<string, CalculatedDistributionBoardLoad>;
circuits: Map<string, CalculatedCircuitLoad>;
}
/**
* Calculates one project graph. A child board enters exactly one feeder
* circuit with its demand after the child's own board simultaneity factor.
* Board-only relations form a hierarchy but intentionally add no load.
*/
export function calculateDistributionBoardLoads(input: {
boards: DistributionBoardLoadBoardInput[];
circuits: DistributionBoardLoadCircuitInput[];
}): DistributionBoardLoadCalculation {
const boardById = new Map(input.boards.map((board) => [board.id, board]));
const circuitById = new Map(input.circuits.map((circuit) => [circuit.id, circuit]));
const circuitsByBoardId = groupBy(
input.circuits,
(circuit) => circuit.distributionBoardId
);
const childrenByCircuitId = new Map<string, DistributionBoardLoadBoardInput[]>();
for (const board of input.boards) {
assertBoardInput(board, boardById, circuitById);
if (board.feedingCircuitId === null) continue;
const children = childrenByCircuitId.get(board.feedingCircuitId) ?? [];
children.push(board);
childrenByCircuitId.set(board.feedingCircuitId, children);
}
const boards = new Map<string, CalculatedDistributionBoardLoad>();
const circuits = new Map<string, CalculatedCircuitLoad>();
const activePath = new Set<string>();
const calculateBoard = (boardId: string): CalculatedDistributionBoardLoad => {
const cached = boards.get(boardId);
if (cached) return cached;
if (activePath.has(boardId)) {
throw new Error("Distribution-board feeding graph contains a cycle.");
}
const board = boardById.get(boardId);
if (!board) throw new Error("Distribution board is missing from load graph.");
activePath.add(boardId);
let activePowerBeforeSimultaneityKw = 0;
let apparentPowerBeforeSimultaneityKva = 0;
for (const circuit of circuitsByBoardId.get(boardId) ?? []) {
let deviceActivePowerKw = 0;
let deviceApparentPowerKva = 0;
for (const row of circuit.rows) {
assertNonNegativeFinite(row.activePowerKw, "row active power");
const cosPhi = row.cosPhi ?? 1;
if (!Number.isFinite(cosPhi) || cosPhi <= 0 || cosPhi > 1) {
throw new Error("Row cos phi must be greater than zero and at most one.");
}
deviceActivePowerKw += row.activePowerKw;
deviceApparentPowerKva += row.activePowerKw / cosPhi;
}
const downstreamBoards = (childrenByCircuitId.get(circuit.id) ?? [])
.map((child) => {
const childLoad = calculateBoard(child.id);
return {
distributionBoardId: child.id,
name: child.name,
boardNumber: child.boardNumber,
activePowerKw: childLoad.demandActivePowerKw,
apparentPowerKva: childLoad.demandApparentPowerKva,
cosPhi: effectiveCosPhi(
childLoad.demandActivePowerKw,
childLoad.demandApparentPowerKva
),
};
});
const activePowerKw =
deviceActivePowerKw +
downstreamBoards.reduce((sum, child) => sum + child.activePowerKw, 0);
const apparentPowerKva =
deviceApparentPowerKva +
downstreamBoards.reduce((sum, child) => sum + child.apparentPowerKva, 0);
circuits.set(circuit.id, {
circuitId: circuit.id,
deviceActivePowerKw,
deviceApparentPowerKva,
activePowerKw,
apparentPowerKva,
cosPhi: effectiveCosPhi(activePowerKw, apparentPowerKva),
downstreamBoards,
});
activePowerBeforeSimultaneityKw += activePowerKw;
apparentPowerBeforeSimultaneityKva += apparentPowerKva;
}
activePath.delete(boardId);
const result = {
distributionBoardId: boardId,
activePowerBeforeSimultaneityKw,
apparentPowerBeforeSimultaneityKva,
demandActivePowerKw:
activePowerBeforeSimultaneityKw * board.simultaneityFactor,
demandApparentPowerKva:
apparentPowerBeforeSimultaneityKva * board.simultaneityFactor,
cosPhi: effectiveCosPhi(
activePowerBeforeSimultaneityKw,
apparentPowerBeforeSimultaneityKva
),
};
boards.set(boardId, result);
return result;
};
for (const board of input.boards) calculateBoard(board.id);
return { boards, circuits };
}
function assertBoardInput(
board: DistributionBoardLoadBoardInput,
boardById: Map<string, DistributionBoardLoadBoardInput>,
circuitById: Map<string, DistributionBoardLoadCircuitInput>
) {
if (
!Number.isFinite(board.simultaneityFactor) ||
board.simultaneityFactor < 0 ||
board.simultaneityFactor > 1
) {
throw new Error("Board simultaneity factor must be between zero and one.");
}
if (board.feedingDistributionBoardId === board.id) {
throw new Error("Distribution board cannot feed itself.");
}
if (
board.feedingDistributionBoardId !== null &&
!boardById.has(board.feedingDistributionBoardId)
) {
throw new Error("Feeding distribution board is missing from load graph.");
}
if (
board.feedingCircuitId !== null &&
board.feedingDistributionBoardId === null
) {
throw new Error("Feeding circuit requires a feeding distribution board.");
}
if (
board.feedingCircuitId !== null &&
circuitById.get(board.feedingCircuitId)?.distributionBoardId !==
board.feedingDistributionBoardId
) {
throw new Error("Feeding circuit belongs to another distribution board.");
}
}
function effectiveCosPhi(activePowerKw: number, apparentPowerKva: number) {
return activePowerKw > 0 && apparentPowerKva > 0
? activePowerKw / apparentPowerKva
: 1;
}
function assertNonNegativeFinite(value: number, label: string) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${label} must be a non-negative finite number.`);
}
}
function groupBy<T>(values: T[], keyOf: (value: T) => string) {
const groups = new Map<string, T[]>();
for (const value of values) {
const key = keyOf(value);
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}

View file

@ -40,7 +40,10 @@ export interface CircuitTreeCircuit {
isReserve: boolean;
remark?: string;
circuitTotalPower: number;
circuitApparentPower: number;
circuitCosPhi: number;
deviceRows: CircuitTreeDeviceRow[];
downstreamBoardLoads: CircuitTreeDownstreamBoardLoad[];
protectionDevice?: CircuitTreeProtectionDevice;
}
@ -59,6 +62,11 @@ export interface CircuitTreeSectionBlock {
export interface CircuitTreeResponse {
circuitListId: string;
distributionBoardId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
distributionBoardSupplyType?: DistributionBoardSupplyType;
feedingReference?: CircuitTreeFeedingReference;
currentRevision: number;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
@ -70,7 +78,36 @@ export interface CircuitTreeResponse {
footerComponents: CircuitTreeComponent[];
}
export interface CircuitTreeDownstreamBoardLoad {
distributionBoardId: string;
name: string;
boardNumber?: string;
supplyType?: DistributionBoardSupplyType;
activePowerKw: number;
apparentPowerKva: number;
cosPhi: number;
}
export interface CircuitTreeFeedingReference {
distributionBoardId: string;
circuitListId: string;
distributionBoardName: string;
distributionBoardNumber?: string;
circuitId?: string;
circuitEquipmentIdentifier?: string;
circuitDisplayName?: string;
circuitCategory?: CircuitGroupCategory;
}
export interface ProjectCircuitTreesResponse {
currentRevision: number;
trees: CircuitTreeResponse[];
}
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
import type {
DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type {
DistributionBoardComponentPlacement,
DistributionBoardComponentRole,

View file

@ -9,16 +9,18 @@ import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
"distribution-board.update" as const;
// The info-card fields were added additively to schema version 1: every
// previously stored command remains valid, and the inverse is still derived
// from the persisted row for exactly the changed fields.
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
// Version 2 adds logical feeding references. Version-1 commands remain
// executable and can only change the fields they originally knew.
export const distributionBoardUpdateCommandSchemaVersion = 2 as const;
const legacyDistributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues
extends Record<DistributionBoardInfoField, string | null> {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
}
export type DistributionBoardUpdateField =
@ -41,16 +43,20 @@ export interface DistributionBoardUpdateCommandPayload {
export interface DistributionBoardUpdateProjectCommand
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
schemaVersion:
| typeof legacyDistributionBoardUpdateCommandSchemaVersion
| typeof distributionBoardUpdateCommandSchemaVersion;
type: typeof distributionBoardUpdateCommandType;
}
export function createDistributionBoardUpdateProjectCommand(
distributionBoardId: string,
patch: DistributionBoardUpdatePatch
patch: DistributionBoardUpdatePatch,
schemaVersion: DistributionBoardUpdateProjectCommand["schemaVersion"] =
distributionBoardUpdateCommandSchemaVersion
): DistributionBoardUpdateProjectCommand {
const command: DistributionBoardUpdateProjectCommand = {
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
schemaVersion,
type: distributionBoardUpdateCommandType,
payload: {
distributionBoardId,
@ -68,7 +74,8 @@ export function assertDistributionBoardUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardUpdateProjectCommand {
if (
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion ||
(command.schemaVersion !== legacyDistributionBoardUpdateCommandSchemaVersion &&
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion) ||
command.type !== distributionBoardUpdateCommandType ||
!isPlainObject(command.payload)
) {
@ -93,7 +100,12 @@ export function assertDistributionBoardUpdateProjectCommand(
(change.field !== "floorId" &&
change.field !== "supplyType" &&
change.field !== "simultaneityFactor" &&
change.field !== "feedingDistributionBoardId" &&
change.field !== "feedingCircuitId" &&
!isInfoField(change.field)) ||
(command.schemaVersion === legacyDistributionBoardUpdateCommandSchemaVersion &&
(change.field === "feedingDistributionBoardId" ||
change.field === "feedingCircuitId")) ||
seen.has(change.field)
) {
throw new Error(
@ -105,7 +117,9 @@ export function assertDistributionBoardUpdateProjectCommand(
} else if (change.field === "floorId") {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
(typeof change.value !== "string" ||
!change.value.trim() ||
change.value !== change.value.trim())
) {
throw new Error("floorId must be a non-empty string or null.");
}
@ -118,6 +132,16 @@ export function assertDistributionBoardUpdateProjectCommand(
) {
throw new Error("supplyType is invalid.");
}
} else if (
change.field === "feedingDistributionBoardId" ||
change.field === "feedingCircuitId"
) {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
) {
throw new Error(`${change.field} must be a non-empty string or null.`);
}
} else if (
typeof change.value !== "number" ||
!Number.isFinite(change.value) ||

View file

@ -74,6 +74,8 @@ export interface DistributionBoardSubtreeSnapshot {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
feedingDistributionBoardId: string | null;
feedingCircuitId: string | null;
} & Record<DistributionBoardInfoField, string | null>;
circuitList: {
id: string;
@ -184,6 +186,9 @@ export function normalizeDistributionBoardSubtreeSnapshot(
for (const field of distributionBoardInfoFields) {
board[field] = board[field] ?? null;
}
board.feedingDistributionBoardId =
board.feedingDistributionBoardId ?? null;
board.feedingCircuitId = board.feedingCircuitId ?? null;
return { ...snapshot, distributionBoard: board };
}
@ -227,6 +232,18 @@ export function assertDistributionBoardSubtreeSnapshot(
assertNullableString(board[field], `distributionBoard.${field}`);
}
}
if (board.feedingDistributionBoardId !== undefined) {
assertNullableString(
board.feedingDistributionBoardId,
"distributionBoard.feedingDistributionBoardId"
);
}
if (board.feedingCircuitId !== undefined) {
assertNullableString(
board.feedingCircuitId,
"distributionBoard.feedingCircuitId"
);
}
for (const field of [
"id",
"projectId",

View file

@ -35,7 +35,8 @@ import {
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 8 as const;
export const projectStateSnapshotSchemaVersion = 9 as const;
const roomAreaProjectStateSnapshotSchemaVersion = 8 as const;
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
const coverProjectStateSnapshotSchemaVersion = 6 as const;
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
@ -112,7 +113,7 @@ const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable();
const distributionBoardSchema = legacyDistributionBoardSchema
const distributionBoardWithoutFeedingSchema = legacyDistributionBoardSchema
.extend({
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
@ -121,6 +122,13 @@ const distributionBoardSchema = legacyDistributionBoardSchema
})
.strict();
const distributionBoardSchema = distributionBoardWithoutFeedingSchema
.extend({
feedingDistributionBoardId: idSchema.nullable(),
feedingCircuitId: idSchema.nullable(),
})
.strict();
function withEmptyDistributionBoardInfo(
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
) {
@ -133,6 +141,16 @@ function withEmptyDistributionBoardInfo(
}));
}
function withEmptyDistributionBoardFeeding(
boards: Array<z.infer<typeof distributionBoardWithoutFeedingSchema>>
) {
return boards.map((board) => ({
...board,
feedingDistributionBoardId: null,
feedingCircuitId: null,
}));
}
const circuitListSchema = z
.object({
id: idSchema,
@ -553,11 +571,22 @@ export const projectStateSnapshotSchema = z
})
.strict();
const roomAreaProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(roomAreaProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
const buildingProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...buildingProjectStateSnapshotContents,
@ -568,7 +597,7 @@ const coverProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
distributionBoards: z.array(distributionBoardWithoutFeedingSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...previousProjectStateSnapshotContents,
@ -655,6 +684,27 @@ export function parseProjectStateSnapshot(
}
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const roomArea = upgradeToRoomAreaProjectStateSnapshot(value);
if (
roomArea !== null &&
typeof roomArea === "object" &&
!Array.isArray(roomArea) &&
(roomArea as { schemaVersion?: unknown }).schemaVersion ===
roomAreaProjectStateSnapshotSchemaVersion
) {
const previous = roomAreaProjectStateSnapshotSchema.parse(roomArea);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
distributionBoards: withEmptyDistributionBoardFeeding(
previous.distributionBoards
),
};
}
return roomArea;
}
function upgradeToRoomAreaProjectStateSnapshot(value: unknown): unknown {
const cover = upgradeToCoverProjectStateSnapshot(value);
if (
cover !== null &&
@ -666,7 +716,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const previous = buildingProjectStateSnapshotSchema.parse(cover);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: roomAreaProjectStateSnapshotSchemaVersion,
rooms: previous.rooms.map((room) => ({ ...room, areaM2: null })),
};
}
@ -683,7 +733,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
// building. Build version 7 first so the room-area upgrade above remains a
// single exact transition.
const previous = coverProjectStateSnapshotSchema.parse(cover);
return upgradePreviousProjectStateSnapshot({
return upgradeToRoomAreaProjectStateSnapshot({
...previous,
schemaVersion: buildingProjectStateSnapshotSchemaVersion,
buildings: [],
@ -873,6 +923,15 @@ function assertProjectStateSnapshotRelations(
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
const boardById = new Map(
snapshot.distributionBoards.map((entry) => [entry.id, entry])
);
const circuitListById = new Map(
snapshot.circuitLists.map((entry) => [entry.id, entry])
);
const circuitById = new Map(
snapshot.circuits.map((entry) => [entry.id, entry])
);
const componentById = new Map(
snapshot.distributionBoardComponents.map((entry) => [
entry.id,
@ -901,6 +960,47 @@ function assertProjectStateSnapshotRelations(
"Snapshot distribution board uses a disabled supply type."
);
}
if (board.feedingDistributionBoardId === board.id) {
throw new Error("Snapshot distribution board cannot feed itself.");
}
if (board.feedingDistributionBoardId !== null) {
assertReference(
boardIds,
board.feedingDistributionBoardId,
"feeding distribution board"
);
}
if (board.feedingCircuitId !== null) {
if (board.feedingDistributionBoardId === null) {
throw new Error(
"Snapshot feeding circuit requires a feeding distribution board."
);
}
assertReference(circuitIds, board.feedingCircuitId, "feeding circuit");
const feedingCircuit = circuitById.get(board.feedingCircuitId);
const feedingList = feedingCircuit
? circuitListById.get(feedingCircuit.circuitListId)
: undefined;
if (
!feedingList ||
feedingList.distributionBoardId !== board.feedingDistributionBoardId
) {
throw new Error(
"Snapshot feeding circuit belongs to another distribution board."
);
}
}
}
for (const board of snapshot.distributionBoards) {
const visited = new Set<string>([board.id]);
let currentId = board.feedingDistributionBoardId;
while (currentId !== null) {
if (visited.has(currentId)) {
throw new Error("Snapshot distribution-board feeding graph contains a cycle.");
}
visited.add(currentId);
currentId = boardById.get(currentId)?.feedingDistributionBoardId ?? null;
}
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");

View file

@ -223,6 +223,14 @@ export function remapProjectState(
...board,
id: requiredId(boardIds, board.id),
projectId: targetProjectId,
feedingDistributionBoardId:
board.feedingDistributionBoardId === null
? null
: requiredId(boardIds, board.feedingDistributionBoardId),
feedingCircuitId:
board.feedingCircuitId === null
? null
: requiredId(circuitIds, board.feedingCircuitId),
floorId:
board.floorId === null
? null