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

@ -267,6 +267,15 @@ describe("cable sizing defaults from the circuit", () => {
sortOrder: 1,
isReserve: rows.length === 0,
circuitTotalPower: rows.reduce((sum, row) => sum + row.power, 0),
circuitApparentPower: rows.reduce(
(sum, row) => sum + row.power / (row.cosPhi ?? 1),
0
),
circuitCosPhi: rows.length
? rows.reduce((sum, row) => sum + row.power, 0) /
rows.reduce((sum, row) => sum + row.power / (row.cosPhi ?? 1), 0)
: 1,
downstreamBoardLoads: [],
deviceRows: rows.map((row, index) => ({
id: `row-${index}`,
sortOrder: index,
@ -288,6 +297,19 @@ describe("cable sizing defaults from the circuit", () => {
assert.equal(deriveCircuitCosPhi(circuit([])), 1);
});
it("uses the projected cos φ including downstream boards", () => {
const feeder = circuit([{ power: 1, cosPhi: 1 }]);
feeder.circuitCosPhi = 0.67;
feeder.downstreamBoardLoads = [{
distributionBoardId: "uv-2",
name: "UV 2",
activePowerKw: 3,
apparentPowerKva: 5,
cosPhi: 0.6,
}];
assert.equal(deriveCircuitCosPhi(feeder), 0.67);
});
it("derives the phase from the group category or three-phase rows", () => {
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "single_phase" }]), "single_phase"), 1);
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3);

View file

@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import {
@ -611,6 +612,62 @@ describe("circuit device-row move project-command repository", () => {
}
});
it("blocks undo from removing a generated circuit used as a feeder", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitDeviceRowMoveProjectCommandRepository(
fixture.context.db
);
const targetCircuit = createMoveTargetSnapshot(fixture);
const command = createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
}]
);
const moved = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
const downstream = new DistributionBoardFixtureRepository(
fixture.context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-02");
fixture.context.db
.update(distributionBoards)
.set({
feedingDistributionBoardId: fixture.circuitListId,
feedingCircuitId: targetCircuit.id,
})
.where(eq(distributionBoards.id, downstream.id))
.run();
assert.throws(
() => store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: moved.revision.changeSetId,
command: moved.inverse,
}),
/Speist UV-02 – erst Einspeisung ändern\./
);
assert.equal(getRow(fixture.context, "row-1")?.circuitId, targetCircuit.id);
assert.ok(
fixture.context.db.select().from(circuits)
.where(eq(circuits.id, targetCircuit.id)).get()
);
} finally {
fixture.context.close();
}
});
it("rejects deletion when the created target changed outside history", () => {
const fixture = createTestDatabase();
try {

View file

@ -47,7 +47,10 @@ function makeCircuit(
sortOrder,
isReserve: deviceRows.length === 0,
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
circuitApparentPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
circuitCosPhi: 1,
deviceRows,
downstreamBoardLoads: [],
};
}
@ -176,6 +179,44 @@ describe("circuit grid projection", () => {
);
});
it("renders linked downstream boards as separate read-only rows", () => {
const feeder = {
...reserveCircuit,
isReserve: false,
circuitTotalPower: 4,
circuitApparentPower: 5,
circuitCosPhi: 0.8,
downstreamBoardLoads: [{
distributionBoardId: "board-child",
name: "Unterverteilung",
boardNumber: "UV-02",
activePowerKw: 4,
apparentPowerKva: 5,
cosPhi: 0.8,
}],
};
const rows = buildVisibleGridRowsWithStructure(
[{ ...sections[0], circuits: [feeder] }],
{ headerComponents: [], footerComponents: [] }
);
assert.deepEqual(rows.map((row) => row.rowType), [
"section",
"circuitSummary",
"downstreamBoardLoad",
"placeholder",
]);
const loadRow = rows[2];
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value,
"Unterverteilung UV-02"
);
assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
4
);
assert.equal(loadRow.cells.every((cell) => !cell.editable), true);
});
it("shows circuit protection once on each visible circuit row", () => {
const protectedSections = sections.map((section) => ({
...section,

View file

@ -16,6 +16,7 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js";
@ -228,6 +229,45 @@ describe("circuit-group subtree project command", () => {
}
});
it("blocks deleting a group containing a feeding circuit", () => {
const context = createTestDatabase();
try {
const list = context.db.select().from(circuitLists).get()!;
const downstream = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler");
context.db
.update(distributionBoards)
.set({
feedingDistributionBoardId: list.distributionBoardId,
feedingCircuitId: "subtree-circuit",
})
.where(eq(distributionBoards.id, downstream.id))
.run();
const repository =
new CircuitGroupSubtreeProjectCommandRepository(context.db);
const groupId = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "subtree-circuit"))
.get()!.sectionId;
const snapshot = repository.capture("project-1", groupId);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupDeleteSubtreeProjectCommand(snapshot),
}),
/Speist Unterverteiler – erst Einspeisung ändern\./
);
assert.deepEqual(repository.capture("project-1", groupId), snapshot);
} finally {
context.close();
}
});
it("rolls back a cascaded deletion when history persistence fails", () => {
const context = createTestDatabase();
try {

View file

@ -14,6 +14,7 @@ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
@ -442,6 +443,48 @@ describe("circuit structure project-command repository", () => {
}
});
it("blocks deleting a circuit that feeds a distribution board", () => {
const fixture = createTestDatabase();
try {
const downstream = new DistributionBoardFixtureRepository(
fixture.context.db
).createWithCircuitListAndDefaultSections("project-1", "UV Unterverteiler");
fixture.context.db
.update(distributionBoards)
.set({
boardNumber: "UV-02",
feedingDistributionBoardId: fixture.circuitListId,
feedingCircuitId: "circuit-existing",
})
.where(eq(distributionBoards.id, downstream.id))
.run();
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/Speist UV-02 – erst Einspeisung ändern\./
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back insert and delete for late history failures", () => {
const fixture = createTestDatabase();
try {

View file

@ -38,13 +38,15 @@ describe("circuit tree controller", () => {
it("returns header, grouped and footer components with both protection layers", async () => {
const originals = {
findList: circuitListRepository.findById,
listLists: circuitListRepository.listByProject,
findProject: projectRepository.findById,
findBoard: distributionBoardRepository.findById,
listSections: circuitSectionRepository.listByCircuitList,
listCircuits: circuitRepository.listByCircuitList,
listBoards: distributionBoardRepository.listByProject,
listSections: circuitSectionRepository.listByCircuitLists,
listCircuits: circuitRepository.listByCircuitLists,
listRows: circuitDeviceRowRepository.listByCircuitList,
listComponents:
distributionBoardComponentRepository.listByCircuitList,
distributionBoardComponentRepository.listByCircuitLists,
listComponentProtection:
distributionBoardComponentRepository.listProtectionByComponentIds,
listCircuitProtection:
@ -57,6 +59,13 @@ describe("circuit tree controller", () => {
distributionBoardId: "board-1",
name: "UV-01",
}) as never;
circuitListRepository.listByProject = async () =>
[{
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV-01",
}] as never;
projectRepository.findById = async () =>
({
id: "project-1",
@ -66,8 +75,19 @@ describe("circuit tree controller", () => {
threePhaseVoltageV: 400,
}) as never;
distributionBoardRepository.findById = async () =>
({ id: "board-1", simultaneityFactor: 0.8 }) as never;
circuitSectionRepository.listByCircuitList = async () =>
({ id: "board-1", name: "UV-01", simultaneityFactor: 0.8 }) as never;
distributionBoardRepository.listByProject = async () =>
[{
id: "board-1",
projectId: "project-1",
name: "UV-01",
boardNumber: "UV-01",
supplyType: "AV",
simultaneityFactor: 0.8,
feedingDistributionBoardId: null,
feedingCircuitId: null,
}] as never;
circuitSectionRepository.listByCircuitLists = async () =>
[
{
id: "group-1",
@ -80,7 +100,7 @@ describe("circuit tree controller", () => {
groupNumber: 1,
},
] as never;
circuitRepository.listByCircuitList = async () =>
circuitRepository.listByCircuitLists = async () =>
[
{
id: "circuit-1",
@ -90,10 +110,19 @@ describe("circuit tree controller", () => {
displayName: "Licht",
sortOrder: 10,
isReserve: 0,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
remark: null,
},
] as never;
circuitDeviceRowRepository.listByCircuitList = async () => [];
distributionBoardComponentRepository.listByCircuitList =
distributionBoardComponentRepository.listByCircuitLists =
async () =>
[
{
@ -170,13 +199,15 @@ describe("circuit tree controller", () => {
);
} finally {
circuitListRepository.findById = originals.findList;
circuitListRepository.listByProject = originals.listLists;
projectRepository.findById = originals.findProject;
distributionBoardRepository.findById = originals.findBoard;
circuitSectionRepository.listByCircuitList =
distributionBoardRepository.listByProject = originals.listBoards;
circuitSectionRepository.listByCircuitLists =
originals.listSections;
circuitRepository.listByCircuitList = originals.listCircuits;
circuitRepository.listByCircuitLists = originals.listCircuits;
circuitDeviceRowRepository.listByCircuitList = originals.listRows;
distributionBoardComponentRepository.listByCircuitList =
distributionBoardComponentRepository.listByCircuitLists =
originals.listComponents;
distributionBoardComponentRepository.listProtectionByComponentIds =
originals.listComponentProtection;

View file

@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { calculateDistributionBoardLoads } from "../src/domain/calculations/distribution-board-load-calculation.js";
const board = (
id: string,
simultaneityFactor: number,
feedingDistributionBoardId: string | null = null,
feedingCircuitId: string | null = null
) => ({
id,
name: id,
boardNumber: id.toUpperCase(),
simultaneityFactor,
feedingDistributionBoardId,
feedingCircuitId,
});
describe("distribution-board load calculation", () => {
it("rolls recursive demand into the selected feeder circuit exactly once", () => {
const result = calculateDistributionBoardLoads({
boards: [
board("nshv", 0.8),
board("uv1", 0.5, "nshv", "nshv-out"),
board("uv2", 0.25, "uv1", "uv1-out"),
],
circuits: [
{ id: "nshv-out", distributionBoardId: "nshv", rows: [{ activePowerKw: 2, cosPhi: 1 }] },
{ id: "uv1-out", distributionBoardId: "uv1", rows: [{ activePowerKw: 4, cosPhi: 0.8 }] },
{ id: "uv2-load", distributionBoardId: "uv2", rows: [{ activePowerKw: 8, cosPhi: 0.8 }] },
],
});
assert.deepEqual(result.boards.get("uv2"), {
distributionBoardId: "uv2",
activePowerBeforeSimultaneityKw: 8,
apparentPowerBeforeSimultaneityKva: 10,
demandActivePowerKw: 2,
demandApparentPowerKva: 2.5,
cosPhi: 0.8,
});
assert.equal(result.circuits.get("uv1-out")?.activePowerKw, 6);
assert.equal(result.boards.get("uv1")?.demandActivePowerKw, 3);
assert.equal(result.circuits.get("nshv-out")?.activePowerKw, 5);
assert.equal(result.boards.get("nshv")?.demandActivePowerKw, 4);
assert.equal(result.circuits.get("nshv-out")?.downstreamBoards.length, 1);
});
it("combines apparent power for multiple child boards on one circuit", () => {
const result = calculateDistributionBoardLoads({
boards: [board("main", 1), board("a", 1, "main", "out"), board("b", 1, "main", "out")],
circuits: [
{ id: "out", distributionBoardId: "main", rows: [] },
{ id: "a-load", distributionBoardId: "a", rows: [{ activePowerKw: 2, cosPhi: 0.5 }] },
{ id: "b-load", distributionBoardId: "b", rows: [{ activePowerKw: 2, cosPhi: 1 }] },
],
});
const feeder = result.circuits.get("out")!;
assert.equal(feeder.activePowerKw, 4);
assert.equal(feeder.apparentPowerKva, 6);
assert.equal(feeder.cosPhi, 2 / 3);
assert.deepEqual(feeder.downstreamBoards.map((entry) => entry.distributionBoardId), ["a", "b"]);
});
it("keeps a board-only hierarchy relation out of load rollup", () => {
const result = calculateDistributionBoardLoads({
boards: [board("main", 1), board("child", 1, "main", null)],
circuits: [
{ id: "main-load", distributionBoardId: "main", rows: [{ activePowerKw: 1, cosPhi: 1 }] },
{ id: "child-load", distributionBoardId: "child", rows: [{ activePowerKw: 5, cosPhi: 1 }] },
],
});
assert.equal(result.boards.get("main")?.demandActivePowerKw, 1);
assert.equal(result.boards.get("child")?.demandActivePowerKw, 5);
});
it("rejects cycles defensively", () => {
assert.throws(
() => calculateDistributionBoardLoads({
boards: [board("a", 1, "b", "b-out"), board("b", 1, "a", "a-out")],
circuits: [
{ id: "a-out", distributionBoardId: "a", rows: [] },
{ id: "b-out", distributionBoardId: "b", rows: [] },
],
}),
/cycle/
);
});
});

View file

@ -219,6 +219,54 @@ describe("distribution-board structure project command", () => {
}
});
it("blocks undo from removing an empty board used as a feeder", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(context.db);
const upstream = createDistributionBoardStructureSnapshot(
"project-1",
"NSHV"
);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(upstream),
});
const downstream = createDistributionBoardStructureSnapshot(
"project-1",
"UV-01"
);
context.db.insert(distributionBoards).values({
...downstream.distributionBoard,
boardNumber: "UV-01",
feedingDistributionBoardId: upstream.distributionBoard.id,
}).run();
context.db.insert(circuitLists).values(downstream.circuitList).run();
context.db.insert(circuitSections).values(downstream.sections).run();
context.db.insert(distributionBoardComponents)
.values(downstream.components).run();
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
}),
/Speist UV-01 – erst Einspeisung ändern\./
);
assert.ok(
context.db.select().from(distributionBoards)
.where(eq(distributionBoards.id, upstream.distributionBoard.id)).get()
);
} finally {
context.close();
}
});
it("allows only supply types enabled in the project", () => {
const context = createTestDatabase();
try {
@ -491,6 +539,159 @@ describe("distribution-board structure project command", () => {
}
});
it("links a feeding board and circuit while preserving version-1 updates", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(context.db);
const upstream = createDistributionBoardStructureSnapshot(
"project-1",
"NSHV",
{ supplyType: "AV" }
);
const downstream = createDistributionBoardStructureSnapshot(
"project-1",
"UV EG",
{ supplyType: "AV" }
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(upstream),
});
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createDistributionBoardInsertProjectCommand(downstream),
});
context.db.insert(circuits).values([
{
id: "feed-circuit",
circuitListId: upstream.circuitList.id,
sectionId: upstream.sections[2]!.id,
equipmentIdentifier: "-3F1.1",
sortOrder: 10,
},
{
id: "downstream-circuit",
circuitListId: downstream.circuitList.id,
sectionId: downstream.sections[2]!.id,
equipmentIdentifier: "-3F1.1",
sortOrder: 10,
},
]).run();
const linked = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
downstream.distributionBoard.id,
{
feedingDistributionBoardId: upstream.distributionBoard.id,
feedingCircuitId: "feed-circuit",
}
),
});
assert.deepEqual(
context.db
.select({
feedingDistributionBoardId:
distributionBoards.feedingDistributionBoardId,
feedingCircuitId: distributionBoards.feedingCircuitId,
})
.from(distributionBoards)
.where(eq(distributionBoards.id, downstream.distributionBoard.id))
.get(),
{
feedingDistributionBoardId: upstream.distributionBoard.id,
feedingCircuitId: "feed-circuit",
}
);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: linked.inverse,
});
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 4,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
downstream.distributionBoard.id,
{
feedingDistributionBoardId: upstream.distributionBoard.id,
feedingCircuitId: null,
}
),
});
assert.throws(
() => repository.executeUpdate({
projectId: "project-1",
expectedRevision: 5,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
upstream.distributionBoard.id,
{
feedingDistributionBoardId: downstream.distributionBoard.id,
feedingCircuitId: null,
}
),
}),
/Zyklus/
);
assert.throws(
() => repository.executeUpdate({
projectId: "project-1",
expectedRevision: 5,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
downstream.distributionBoard.id,
{
feedingDistributionBoardId: upstream.distributionBoard.id,
feedingCircuitId: "downstream-circuit",
}
),
}),
/gehört nicht zur gewählten Verteilung/
);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 5,
source: "user",
command: {
schemaVersion: 1,
type: "distribution-board.update",
payload: {
distributionBoardId: downstream.distributionBoard.id,
changes: [{ field: "boardNumber", value: "UV-01" }],
},
},
});
const afterLegacyUpdate = context.db
.select()
.from(distributionBoards)
.where(eq(distributionBoards.id, downstream.distributionBoard.id))
.get();
assert.equal(afterLegacyUpdate?.boardNumber, "UV-01");
assert.equal(
afterLegacyUpdate?.feedingDistributionBoardId,
upstream.distributionBoard.id
);
} finally {
context.close();
}
});
it("sends distribution-board writes to the revision-safe API", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;

View file

@ -291,6 +291,43 @@ describe("distribution-board subtree project command", () => {
}
});
it("blocks deleting a board subtree that feeds another board", () => {
const context = createTestDatabase();
try {
const upstream = createPopulatedBoard(context);
const downstream = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "Unterverteiler");
context.db
.update(distributionBoards)
.set({
boardNumber: "UV-03",
feedingDistributionBoardId: upstream.id,
})
.where(eq(distributionBoards.id, downstream.id))
.run();
const repository =
new DistributionBoardSubtreeProjectCommandRepository(context.db);
const snapshot = repository.capture("project-1", upstream.id);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardDeleteSubtreeProjectCommand(snapshot),
}),
/Speist UV-03 – erst Einspeisung ändern\./
);
assert.deepEqual(repository.capture("project-1", upstream.id), snapshot);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
it("rolls back a cascaded deletion when history persistence fails", () => {
const context = createTestDatabase();
try {

View file

@ -19,6 +19,8 @@ function createSnapshot(): DistributionBoardSubtreeSnapshot {
floorId: "floor-1",
supplyType: "AV",
simultaneityFactor: 0.8,
feedingDistributionBoardId: "board-upstream",
feedingCircuitId: "circuit-upstream",
},
circuitList: {
id: "list-1",
@ -157,6 +159,11 @@ describe("distribution-board subtree snapshot", () => {
assert.equal(clone.distributionBoard.id, "new-1");
assert.equal(clone.circuitList.id, "new-1");
assert.equal(clone.distributionBoard.name, "UV 1 Kopie");
assert.equal(
clone.distributionBoard.feedingDistributionBoardId,
"board-upstream"
);
assert.equal(clone.distributionBoard.feedingCircuitId, "circuit-upstream");
assert.equal(clone.circuitList.name, "UV 1 Kopie Stromkreisliste");
assert.equal(clone.sections[0].id, "new-2");
assert.equal(clone.circuits[0].id, "new-3");

View file

@ -533,6 +533,61 @@ describe("external object new-circuit project command", () => {
}
});
it("blocks history removal when the generated circuit became a feeder", () => {
const context = createFixture(null);
try {
const object = objectSnapshot(null);
const circuit = newExternalCircuit(context.db);
const command = createExternalObjectNewCircuitProjectCommand(
externalObjectAssignToNewCircuitCommandType,
{
circuit,
objects: [{
expected: object,
target: { ...object, circuitDeviceRowId: "row-new" },
}],
confirmedConflictObjectIds: [],
}
);
const repository = new ExternalObjectNewCircuitProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
context.db.insert(distributionBoards).values({
id: "board-2",
projectId: "project-1",
name: "Unterverteiler",
boardNumber: "UV-02",
feedingDistributionBoardId: "board-1",
feedingCircuitId: circuit.id,
}).run();
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
}),
/Speist UV-02 – erst Einspeisung ändern\./
);
assert.ok(
context.db.select().from(circuits)
.where(eq(circuits.id, circuit.id)).get()
);
assert.equal(
context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId,
"row-new"
);
} finally {
context.close();
}
});
it("rolls back the complete circuit subtree when history persistence fails", () => {
const context = createFixture(null);
try {

View file

@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy,
buildBoardHierarchy,
formatFeedingReference,
formatCircuitCable,
formatProtectionDevice,
summarizePrintBoard,
@ -27,6 +28,8 @@ function board(
location: null,
fedFrom: null,
remark: null,
feedingDistributionBoardId: null,
feedingCircuitId: null,
...info,
};
}
@ -52,6 +55,8 @@ function tree(
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
return {
circuitListId: "list",
distributionBoardId: "board",
distributionBoardName: "Board",
currentRevision: 1,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
@ -79,7 +84,13 @@ function tree(
sortOrder: 1,
isReserve: false,
circuitTotalPower: total,
circuitApparentPower: deviceRows.reduce(
(sum, row) => sum + row.rowTotalPower / (row.cosPhi || 1),
0
),
circuitCosPhi: 1,
deviceRows,
downstreamBoardLoads: [],
},
],
},
@ -117,16 +128,15 @@ describe("print document model", () => {
assert.equal(formatCircuitCable(circuit), null);
});
it("does not guess a parent when feed text matches more than one board", () => {
it("does not derive hierarchy from the external free text", () => {
const summaries = [
summary("a", { boardNumber: "HV" }, 2),
summary("b", { name: "HV" }, 3),
summary("uv", { fedFrom: " hv " }, 1),
];
for (const ordered of [summaries, [...summaries].reverse()]) {
const entries = buildPreliminaryBoardHierarchy(ordered);
const entries = buildBoardHierarchy(ordered);
assert.ok(entries.every((entry) => entry.parentBoardId === null));
assert.equal(entries.reduce((sum, entry) => sum + entry.cumulatedDemandPowerKw, 0), 6);
}
});
@ -145,17 +155,39 @@ describe("print document model", () => {
});
assert.equal(result.circuitCount, 1);
assert.equal(result.installedPowerKw, 14);
assert.equal(result.devicePowerKw, 10);
assert.equal(result.powerBeforeBoardSimultaneityKw, 10);
assert.equal(result.demandPowerKw, 5);
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
});
it("nests boards by matching free-text feed and sums downstream demand", () => {
const entries = buildPreliminaryBoardHierarchy([
it("uses the selected one-phase feeder circuit for the design current", () => {
const inputTree = tree([
{ quantity: 1, powerPerUnit: 2.3, simultaneityFactor: 1, cosPhi: 1 },
]);
inputTree.feedingReference = {
distributionBoardId: "hv",
circuitListId: "hv-list",
distributionBoardName: "Hauptverteilung",
distributionBoardNumber: "NSHV",
circuitId: "out",
circuitEquipmentIdentifier: "-2F1.1",
circuitCategory: "single_phase",
};
const result = summarizePrintBoard({
board: board("uv"),
circuitListId: "uv",
floorName: null,
tree: inputTree,
});
assert.ok(Math.abs(result.designCurrentA - 10) < 1e-9);
});
it("nests boards by their logical feeding references", () => {
const entries = buildBoardHierarchy([
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
summary("uv-a", { boardNumber: "UV 1.1", feedingDistributionBoardId: "hv" }, 4),
summary("uv-b", { boardNumber: "UV 1.2", feedingDistributionBoardId: "uv-a" }, 2),
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
]);
assert.deepEqual(
@ -163,28 +195,37 @@ describe("print document model", () => {
entry.summary.board.id,
entry.depth,
entry.parentBoardId,
entry.cumulatedDemandPowerKw,
]),
[
["hv", 0, null, 16],
["uv-a", 1, "hv", 6],
["uv-b", 2, "uv-a", 2],
["uv-c", 0, null, 1],
["hv", 0, null],
["uv-a", 1, "hv"],
["uv-b", 2, "uv-a"],
["uv-c", 0, null],
]
);
});
it("keeps boards with cyclic feed texts at the top level", () => {
const entries = buildPreliminaryBoardHierarchy([
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
it("keeps malformed cyclic logical links visible at the top level", () => {
const entries = buildBoardHierarchy([
summary("a", { boardNumber: "A", feedingDistributionBoardId: "b" }, 1),
summary("b", { boardNumber: "B", feedingDistributionBoardId: "a" }, 1),
]);
assert.equal(entries.length, 2);
assert.ok(entries.some((entry) => entry.parentBoardId === null));
assert.equal(
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
2
);
});
it("formats the resolved feeding board and circuit instead of free text", () => {
const value = summary("uv", { fedFrom: "Alttext" }, 1);
value.tree.feedingReference = {
distributionBoardId: "hv",
circuitListId: "hv-list",
distributionBoardName: "Hauptverteilung",
distributionBoardNumber: "NSHV",
circuitId: "out",
circuitEquipmentIdentifier: "-3F1.1",
circuitCategory: "three_phase",
};
assert.equal(formatFeedingReference(value), "NSHV-3F1.1");
});
it("formats protection devices for the plan table", () => {

View file

@ -50,6 +50,8 @@ it("upgrades a populated pre-print database through the current schema without c
for (const [key, value] of Object.entries(oldRoom)) assert.deepEqual(room[key], value);
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
assert.equal(board.feeding_distribution_board_id, null);
assert.equal(board.feeding_circuit_id, null);
assert.equal(floor.building_id, null);
assert.equal(room.area_m2, null);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);

View file

@ -476,7 +476,14 @@ describe("project snapshot repository", () => {
delete payload.project[field];
}
for (const board of payload.distributionBoards) {
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
for (const field of [
"boardNumber",
"location",
"fedFrom",
"remark",
"feedingDistributionBoardId",
"feedingCircuitId",
]) {
delete board[field];
}
}

View file

@ -6,12 +6,25 @@ import {
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js";
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
/** Removes the fields introduced by snapshot schema version 9. */
function withoutVersionNineFields<
T extends { distributionBoards: object[] },
>(snapshot: T): T {
for (const board of snapshot.distributionBoards) {
delete (board as Record<string, unknown>).feedingDistributionBoardId;
delete (board as Record<string, unknown>).feedingCircuitId;
}
return snapshot;
}
/** Removes the fields introduced by snapshot schema version 7. */
function withoutVersionSevenFields<
T extends { floors: object[] },
T extends { floors: object[]; distributionBoards: object[] },
>(snapshot: T): T {
withoutVersionNineFields(snapshot);
delete (snapshot as Record<string, unknown>).buildings;
for (const floor of snapshot.floors) {
delete (floor as Record<string, unknown>).buildingId;
@ -117,6 +130,8 @@ function protectedSnapshot() {
location: "Raum 003 Technik" as string | null,
fedFrom: "HV 0.1" as string | null,
remark: null as string | null,
feedingDistributionBoardId: null as string | null,
feedingCircuitId: null as string | null,
},
],
circuitLists: [
@ -206,6 +221,96 @@ describe("project state snapshot model", () => {
);
});
it("upgrades snapshot version 8 with empty feeding references", () => {
const previous = withoutVersionNineFields(protectedSnapshot());
previous.schemaVersion = 8 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(
upgraded.distributionBoards[0]?.feedingDistributionBoardId,
null
);
assert.equal(upgraded.distributionBoards[0]?.feedingCircuitId, null);
});
it("validates logical feeding references and cycles", () => {
const linked = protectedSnapshot();
linked.distributionBoards.push({
id: "board-2",
projectId: "project-1",
name: "UV 2",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
boardNumber: "UV 2.1",
location: null,
fedFrom: null,
remark: null,
feedingDistributionBoardId: "board-1",
feedingCircuitId: "circuit-1",
});
linked.circuitLists.push({
id: "list-2",
projectId: "project-1",
distributionBoardId: "board-2",
name: "UV 2 Stromkreisliste",
});
linked.circuitSections.push({
id: "section-2",
circuitListId: "list-2",
key: "three_phase",
displayName: "Dreiphasig 1",
prefix: "-3F1.",
sortOrder: 10,
category: "three_phase",
groupNumber: 1,
});
const parsed = parseProjectStateSnapshot(linked);
assert.equal(parsed.distributionBoards[1]?.feedingCircuitId, "circuit-1");
let nextId = 0;
const remapped = remapProjectState(
parsed,
"project-copy",
() => `copy-${++nextId}`
);
const remappedUpstream = remapped.distributionBoards.find(
(board) => board.name === "UV 1"
);
const remappedDownstream = remapped.distributionBoards.find(
(board) => board.name === "UV 2"
);
assert.ok(remappedUpstream);
assert.ok(remappedDownstream);
assert.equal(
remappedDownstream.feedingDistributionBoardId,
remappedUpstream.id
);
assert.notEqual(remappedDownstream.feedingCircuitId, "circuit-1");
assert.ok(
remapped.circuits.some(
(circuit) => circuit.id === remappedDownstream.feedingCircuitId
)
);
const missingBoard = structuredClone(linked);
missingBoard.distributionBoards[1]!.feedingDistributionBoardId = null;
assert.throws(
() => parseProjectStateSnapshot(missingBoard),
/requires a feeding distribution board/
);
const cycle = structuredClone(linked);
cycle.distributionBoards[0]!.feedingDistributionBoardId = "board-2";
cycle.distributionBoards[0]!.feedingCircuitId = null;
cycle.distributionBoards[1]!.feedingCircuitId = null;
assert.throws(
() => parseProjectStateSnapshot(cycle),
/contains a cycle/
);
});
it("upgrades the supported baseline snapshot with a safe building default", () => {
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;