Add print view, board info card and document logo upload
- Seitenansicht (/projects/:projectId/print): tabular cover page, board overview with supply structure and power sums, one plan per board with a repeating title block; PDF via the browser print dialog with named @page rules (A4 portrait cover, A4/A3 landscape plans, page x of y). - Distribution-board info card (board number, location, fed-from, remark) in the board edit dialog, persisted through the additively extended distribution-board.update command (migration 0008, snapshot schema 6, legacy subtree commands normalized). - Global document logo upload (PNG/JPEG/SVG, max 2 MB) outside project revisions and snapshots. - fed-from stays free text; the overview hierarchy and cumulated sums are preliminary until the feed link is modeled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2e3e85a8e5
commit
4779e4a1a7
40 changed files with 5297 additions and 33 deletions
|
|
@ -387,6 +387,110 @@ describe("distribution-board structure project command", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("updates the info card fields with persisted undo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository =
|
||||
new DistributionBoardStructureProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const structure = createDistributionBoardStructureSnapshot(
|
||||
"project-1",
|
||||
"UV-EG",
|
||||
{ floorId: null, supplyType: "AV" }
|
||||
);
|
||||
const insert = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createDistributionBoardInsertProjectCommand(structure),
|
||||
});
|
||||
const readInfo = () =>
|
||||
context.db
|
||||
.select({
|
||||
boardNumber: distributionBoards.boardNumber,
|
||||
location: distributionBoards.location,
|
||||
fedFrom: distributionBoards.fedFrom,
|
||||
remark: distributionBoards.remark,
|
||||
})
|
||||
.from(distributionBoards)
|
||||
.get();
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
});
|
||||
|
||||
const forward = repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{
|
||||
boardNumber: "UV 0.1",
|
||||
location: "Raum 003 Technik",
|
||||
fedFrom: "HAK",
|
||||
}
|
||||
),
|
||||
});
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: "UV 0.1",
|
||||
location: "Raum 003 Technik",
|
||||
fedFrom: "HAK",
|
||||
remark: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
forward.inverse.payload.changes.map((change) => change.field),
|
||||
["boardNumber", "location", "fedFrom"]
|
||||
);
|
||||
|
||||
// A board with info values is no longer the unchanged empty setup.
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "user",
|
||||
command: insert.inverse,
|
||||
}),
|
||||
/changed before deletion/
|
||||
);
|
||||
|
||||
const undoStep = new ProjectHistoryRepository(
|
||||
context.db
|
||||
).getNextCommand("project-1", "undo");
|
||||
assert.ok(undoStep);
|
||||
repository.executeUpdate({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: undoStep.changeSetId,
|
||||
command: forward.inverse,
|
||||
});
|
||||
assert.deepEqual(readInfo(), {
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
});
|
||||
|
||||
for (const value of ["", " UV 1", "x".repeat(51)]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
createDistributionBoardUpdateProjectCommand(
|
||||
structure.distributionBoard.id,
|
||||
{ boardNumber: value }
|
||||
),
|
||||
/boardNumber/
|
||||
);
|
||||
}
|
||||
} 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;
|
||||
|
|
|
|||
|
|
@ -192,6 +192,67 @@ describe("distribution-board subtree project command", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("copies board info values and accepts snapshots stored before they existed", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const board = createPopulatedBoard(context);
|
||||
context.db
|
||||
.update(distributionBoards)
|
||||
.set({ boardNumber: "UV 1.1", location: "Technik" })
|
||||
.where(eq(distributionBoards.id, board.id))
|
||||
.run();
|
||||
const repository =
|
||||
new DistributionBoardSubtreeProjectCommandRepository(context.db);
|
||||
const withInfo = repository.capture("project-1", board.id);
|
||||
const clone = cloneDistributionBoardSubtree(withInfo, "UV 1 Kopie");
|
||||
assert.equal(clone.distributionBoard.boardNumber, "UV 1.1");
|
||||
assert.equal(clone.distributionBoard.location, "Technik");
|
||||
|
||||
context.db
|
||||
.update(distributionBoards)
|
||||
.set({ boardNumber: null, location: null })
|
||||
.where(eq(distributionBoards.id, board.id))
|
||||
.run();
|
||||
const legacySnapshot = structuredClone(
|
||||
repository.capture("project-1", board.id)
|
||||
);
|
||||
const legacyBoard = legacySnapshot.distributionBoard as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
delete legacyBoard.boardNumber;
|
||||
delete legacyBoard.location;
|
||||
delete legacyBoard.fedFrom;
|
||||
delete legacyBoard.remark;
|
||||
|
||||
const deleted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
description: "Verteilung löschen",
|
||||
command: createDistributionBoardDeleteSubtreeProjectCommand(
|
||||
legacySnapshot
|
||||
),
|
||||
});
|
||||
assert.throws(
|
||||
() => repository.capture("project-1", board.id),
|
||||
/not found/
|
||||
);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: deleted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
repository.capture("project-1", board.id).distributionBoard.fedFrom,
|
||||
null
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects changed deletion snapshots without partial writes", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
|
|
|||
176
tests/print-document-model.test.ts
Normal file
176
tests/print-document-model.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildPreliminaryBoardHierarchy,
|
||||
formatProtectionDevice,
|
||||
summarizePrintBoard,
|
||||
} from "../src/frontend/print/print-document-model.js";
|
||||
import type {
|
||||
CircuitTreeResponseDto,
|
||||
DistributionBoardDto,
|
||||
} from "../src/frontend/types.js";
|
||||
|
||||
function board(
|
||||
id: string,
|
||||
info: Partial<DistributionBoardDto> = {}
|
||||
): DistributionBoardDto {
|
||||
return {
|
||||
id,
|
||||
projectId: "project-1",
|
||||
name: id,
|
||||
floorId: null,
|
||||
supplyType: "AV",
|
||||
simultaneityFactor: 1,
|
||||
boardNumber: null,
|
||||
location: null,
|
||||
fedFrom: null,
|
||||
remark: null,
|
||||
...info,
|
||||
};
|
||||
}
|
||||
|
||||
function tree(
|
||||
rows: Array<{
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi?: number;
|
||||
}>,
|
||||
boardSimultaneityFactor = 1
|
||||
): CircuitTreeResponseDto {
|
||||
const deviceRows = rows.map((row, index) => ({
|
||||
id: `row-${index}`,
|
||||
sortOrder: index,
|
||||
name: "Gerät",
|
||||
displayName: "Gerät",
|
||||
manualQuantity: row.quantity,
|
||||
rowTotalPower: row.quantity * row.powerPerUnit * row.simultaneityFactor,
|
||||
...row,
|
||||
}));
|
||||
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
|
||||
return {
|
||||
circuitListId: "list",
|
||||
currentRevision: 1,
|
||||
singlePhaseVoltageV: 230,
|
||||
threePhaseVoltageV: 400,
|
||||
distributionBoardSimultaneityFactor: boardSimultaneityFactor,
|
||||
distributionBoardTotalPower: total,
|
||||
distributionBoardTotalPowerWithSimultaneityFactor:
|
||||
total * boardSimultaneityFactor,
|
||||
headerComponents: [],
|
||||
footerComponents: [],
|
||||
sections: [
|
||||
{
|
||||
id: "section",
|
||||
key: "single_phase",
|
||||
displayName: "1-phasig 1",
|
||||
prefix: "-2F1.",
|
||||
sortOrder: 1,
|
||||
sectionTotalPower: total,
|
||||
components: [],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit",
|
||||
circuitListId: "list",
|
||||
sectionId: "section",
|
||||
equipmentIdentifier: "-2F1.1",
|
||||
sortOrder: 1,
|
||||
isReserve: false,
|
||||
circuitTotalPower: total,
|
||||
deviceRows,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
info: Partial<DistributionBoardDto>,
|
||||
demandKw: number
|
||||
) {
|
||||
return summarizePrintBoard({
|
||||
board: board(id, info),
|
||||
circuitListId: id,
|
||||
floorName: null,
|
||||
tree: tree([{ quantity: 1, powerPerUnit: demandKw, simultaneityFactor: 1 }]),
|
||||
});
|
||||
}
|
||||
|
||||
describe("print document model", () => {
|
||||
it("derives installed, device and demand power and the design current", () => {
|
||||
const result = summarizePrintBoard({
|
||||
board: board("uv"),
|
||||
circuitListId: "uv",
|
||||
floorName: "EG",
|
||||
tree: tree(
|
||||
[
|
||||
{ quantity: 4, powerPerUnit: 2, simultaneityFactor: 0.5, cosPhi: 0.8 },
|
||||
{ quantity: 1, powerPerUnit: 6, simultaneityFactor: 1 },
|
||||
],
|
||||
0.5
|
||||
),
|
||||
});
|
||||
assert.equal(result.circuitCount, 1);
|
||||
assert.equal(result.installedPowerKw, 14);
|
||||
assert.equal(result.devicePowerKw, 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([
|
||||
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-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => [
|
||||
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],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
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),
|
||||
]);
|
||||
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 protection devices for the plan table", () => {
|
||||
assert.equal(
|
||||
formatProtectionDevice({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }),
|
||||
"LS B 16 A"
|
||||
);
|
||||
assert.equal(
|
||||
formatProtectionDevice({
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
}),
|
||||
"FI/LS B 16 A, 30 mA Typ A"
|
||||
);
|
||||
assert.equal(formatProtectionDevice(undefined), null);
|
||||
});
|
||||
});
|
||||
|
|
@ -377,6 +377,67 @@ describe("project snapshot repository", () => {
|
|||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("restores snapshots stored with an older supported schema version", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new ProjectSnapshotRepository(context.db);
|
||||
const snapshot = repository.createNamed({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
name: "Vor dem Update",
|
||||
});
|
||||
assert.ok(snapshot);
|
||||
// Rewrite the stored payload into the previous (version 5) shape, as it
|
||||
// exists for snapshots created before the current version.
|
||||
const payload = JSON.parse(
|
||||
context.db
|
||||
.select({ payloadJson: projectSnapshots.payloadJson })
|
||||
.from(projectSnapshots)
|
||||
.where(eq(projectSnapshots.id, snapshot.id))
|
||||
.get()!.payloadJson
|
||||
) as {
|
||||
schemaVersion: number;
|
||||
project: Record<string, unknown>;
|
||||
distributionBoards: Array<Record<string, unknown>>;
|
||||
};
|
||||
payload.schemaVersion = 5;
|
||||
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
|
||||
delete payload.project[field];
|
||||
}
|
||||
for (const board of payload.distributionBoards) {
|
||||
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
|
||||
delete board[field];
|
||||
}
|
||||
}
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
context.db
|
||||
.update(projectSnapshots)
|
||||
.set({
|
||||
schemaVersion: 5,
|
||||
payloadJson,
|
||||
payloadSha256: crypto
|
||||
.createHash("sha256")
|
||||
.update(payloadJson)
|
||||
.digest("hex"),
|
||||
})
|
||||
.where(eq(projectSnapshots.id, snapshot.id))
|
||||
.run();
|
||||
|
||||
const prepared = repository.prepareRestore("project-1", snapshot.id);
|
||||
assert.ok(prepared);
|
||||
const targetState = prepared.command.payload.targetState;
|
||||
assert.equal(targetState.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.ok(targetState.distributionBoards.length > 0);
|
||||
assert.ok(
|
||||
targetState.distributionBoards.every(
|
||||
(board) => board.boardNumber === null && board.fedFrom === null
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("portable project transfer", () => {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ function protectedSnapshot() {
|
|||
floorId: null,
|
||||
supplyType: null,
|
||||
simultaneityFactor: 1,
|
||||
boardNumber: "UV 1.1" as string | null,
|
||||
location: "Raum 003 Technik" as string | null,
|
||||
fedFrom: "HV 0.1" as string | null,
|
||||
remark: null as string | null,
|
||||
},
|
||||
],
|
||||
circuitLists: [
|
||||
|
|
@ -199,9 +203,52 @@ describe("project state snapshot model", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 5 with empty distribution-board info", () => {
|
||||
const previous = protectedSnapshot();
|
||||
previous.schemaVersion = 5 as typeof previous.schemaVersion;
|
||||
for (const board of previous.distributionBoards) {
|
||||
const record = board as Record<string, unknown>;
|
||||
delete record.boardNumber;
|
||||
delete record.location;
|
||||
delete record.fedFrom;
|
||||
delete record.remark;
|
||||
}
|
||||
|
||||
const upgraded = parseProjectStateSnapshot(previous);
|
||||
|
||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.deepEqual(
|
||||
{
|
||||
boardNumber: upgraded.distributionBoards[0]?.boardNumber,
|
||||
location: upgraded.distributionBoards[0]?.location,
|
||||
fedFrom: upgraded.distributionBoards[0]?.fedFrom,
|
||||
remark: upgraded.distributionBoards[0]?.remark,
|
||||
},
|
||||
{ boardNumber: null, location: null, fedFrom: null, remark: null }
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps distribution-board info values and rejects them in older versions", () => {
|
||||
const parsed = parseProjectStateSnapshot(protectedSnapshot());
|
||||
assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1");
|
||||
assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik");
|
||||
assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1");
|
||||
|
||||
const mislabeled = protectedSnapshot();
|
||||
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion;
|
||||
assert.throws(() => parseProjectStateSnapshot(mislabeled));
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
|
||||
const previous = protectedSnapshot();
|
||||
previous.schemaVersion = 4 as typeof previous.schemaVersion;
|
||||
for (const board of previous.distributionBoards) {
|
||||
const record = board as Record<string, unknown>;
|
||||
delete record.boardNumber;
|
||||
delete record.location;
|
||||
delete record.fedFrom;
|
||||
delete record.remark;
|
||||
}
|
||||
previous.circuits[0]!.isReserve = false;
|
||||
previous.circuits[0]!.deviceRows = [{
|
||||
id: "row-1",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue