Add proposed cable-sizing module

Isolated module (mirrors src/external-model/ dependency direction) that
adds an on-demand, DIN VDE 0298-4 referenced cable cross-section
calculator: a click-on-a-circuit input mask for laying method,
insulation, ambient temperature, grouping and voltage-drop limit that
suggests a cross-section for the circuit cableCrossSection/cableLength
fields. Applying a suggestion goes through the existing circuit.update
command (expectedRevision, undo/redo) unchanged - nothing here writes
to circuits directly or touches the revision/command system. See
docs/cable-sizing-module.md for the full rationale, API contract,
verification status and maintenance plan. Proposal, not yet reviewed
by the project owner - see the docs file.

423 existing + 11 new tests pass, build:api/build:web/typecheck:scripts
clean.
This commit is contained in:
Grovy311 2026-08-07 17:13:59 +02:00
parent a17e2e3f4b
commit 1d030971dd
17 changed files with 4179 additions and 2 deletions

View file

@ -13,6 +13,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { CableSizingCalculationRepository } from "../../db/repositories/cable-sizing-calculation.repository.js";
export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db);
@ -33,3 +34,4 @@ export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const cableSizingCalculationRepository = new CableSizingCalculationRepository(db);

View file

@ -0,0 +1,80 @@
import { randomUUID } from "node:crypto";
import type { Request, Response } from "express";
import { cableSizingRequestSchema } from "../../cable-sizing/domain/cable-sizing-contracts.js";
import {
calculateCableSizing,
buildCableSizingAlerts,
LAYING_METHODS,
LAYING_METHOD_LABELS,
LAYING_METHOD_VERIFIED,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
} from "../../cable-sizing/domain/cable-sizing-calculation.js";
import { cableSizingCalculationRepository } from "../composition/application-repositories.js";
// Stateless calculation, not a project command: no expectedRevision, no
// circuit mutation here. The caller applies the result via the existing
// circuit.update command (POST /api/projects/:projectId/commands) if the
// user confirms it. See docs/cable-sizing-module.md.
export async function calculateCableSizingHandler(req: Request, res: Response) {
const parsed = cableSizingRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { context, ...input } = parsed.data;
const result = calculateCableSizing(input);
const alerts = buildCableSizingAlerts(input, result);
const entry = await cableSizingCalculationRepository.create({
id: randomUUID(),
projectId: context?.projectId ?? null,
circuitId: context?.circuitId ?? null,
equipmentIdentifier: context?.equipmentIdentifier ?? null,
input,
result,
appliedToCircuit: 0,
});
return res.status(201).json({ calculationId: entry.id, result, alerts });
}
export async function listLayingMethodsHandler(_req: Request, res: Response) {
return res.json(
LAYING_METHODS.map((method) => ({
method,
label: LAYING_METHOD_LABELS[method],
dataVerified: LAYING_METHOD_VERIFIED[method],
}))
);
}
export async function listInsulationMaterialsHandler(_req: Request, res: Response) {
return res.json(
INSULATION_MATERIALS.map((insulation) => ({
insulation,
label: INSULATION_MATERIAL_LABELS[insulation],
}))
);
}
export async function markCalculationAppliedHandler(req: Request, res: Response) {
const { calculationId } = req.params;
if (typeof calculationId !== "string") {
return res.status(400).json({ error: "Invalid calculationId" });
}
const updated = await cableSizingCalculationRepository.markApplied(calculationId);
if (!updated) {
return res.status(404).json({ error: "Calculation not found" });
}
return res.json(updated);
}
export async function listCalculationsForCircuitHandler(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
const rows = await cableSizingCalculationRepository.listByCircuit(circuitId);
return res.json(rows);
}

View file

@ -1,6 +1,7 @@
import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
@ -49,6 +50,7 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use("/api/cable-sizing", cableSizingRouter);
app.use(errorMiddleware);

View file

@ -0,0 +1,19 @@
import express from "express";
import * as cableSizingController from "../controllers/cable-sizing.controller.js";
export const cableSizingRouter = express.Router();
cableSizingRouter.post("/calculate", cableSizingController.calculateCableSizingHandler);
cableSizingRouter.get("/laying-methods", cableSizingController.listLayingMethodsHandler);
cableSizingRouter.get(
"/insulation-materials",
cableSizingController.listInsulationMaterialsHandler
);
cableSizingRouter.post(
"/calculations/:calculationId/applied",
cableSizingController.markCalculationAppliedHandler
);
cableSizingRouter.get(
"/circuits/:circuitId/calculations",
cableSizingController.listCalculationsForCircuitHandler
);