Add logical distribution board feed links

This commit is contained in:
Julian Appel 2026-09-27 22:21:58 +02:00
parent b50c00c0ac
commit b893c72534
23 changed files with 3732 additions and 45 deletions

View file

@ -491,6 +491,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

@ -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

@ -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;