rework vehicle handling
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import vehicleService from '../services/vehicle.service';
|
||||
import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model';
|
||||
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
// ── UUID validation ───────────────────────────────────────────────────────────
|
||||
|
||||
function isValidUUID(s: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||
}
|
||||
|
||||
// ── Zod Validation Schemas ────────────────────────────────────────────────────
|
||||
|
||||
const FahrzeugStatusEnum = z.enum([
|
||||
@@ -13,19 +19,9 @@ const FahrzeugStatusEnum = z.enum([
|
||||
FahrzeugStatus.InLehrgang,
|
||||
]);
|
||||
|
||||
const PruefungArtEnum = z.enum([
|
||||
PruefungArt.HU,
|
||||
PruefungArt.AU,
|
||||
PruefungArt.UVV,
|
||||
PruefungArt.Leiter,
|
||||
PruefungArt.Kran,
|
||||
PruefungArt.Seilwinde,
|
||||
PruefungArt.Sonstiges,
|
||||
]);
|
||||
|
||||
const isoDate = z.string().regex(
|
||||
/^\d{4}-\d{2}-\d{2}$/,
|
||||
'Expected ISO date format YYYY-MM-DD'
|
||||
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||
'Erwartet ISO-Datum im Format YYYY-MM-DD'
|
||||
);
|
||||
|
||||
const CreateFahrzeugSchema = z.object({
|
||||
@@ -39,30 +35,40 @@ const CreateFahrzeugSchema = z.object({
|
||||
besatzung_soll: z.string().max(10).optional(),
|
||||
status: FahrzeugStatusEnum.optional(),
|
||||
status_bemerkung: z.string().max(500).optional(),
|
||||
standort: z.string().max(100).optional(),
|
||||
bild_url: z.string().url().max(500).optional(),
|
||||
standort: z.string().min(1).max(100).optional(),
|
||||
bild_url: z.string().url().max(500).refine(
|
||||
(url) => /^https?:\/\//i.test(url),
|
||||
'Nur http/https URLs erlaubt'
|
||||
).optional(),
|
||||
paragraph57a_faellig_am: isoDate.optional(),
|
||||
naechste_wartung_am: isoDate.optional(),
|
||||
});
|
||||
|
||||
const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial();
|
||||
const UpdateFahrzeugSchema = z.object({
|
||||
bezeichnung: z.string().min(1).max(100).optional(),
|
||||
kurzname: z.string().max(20).nullable().optional(),
|
||||
amtliches_kennzeichen: z.string().max(20).nullable().optional(),
|
||||
fahrgestellnummer: z.string().max(50).nullable().optional(),
|
||||
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
|
||||
hersteller: z.string().max(100).nullable().optional(),
|
||||
typ_schluessel: z.string().max(30).nullable().optional(),
|
||||
besatzung_soll: z.string().max(10).nullable().optional(),
|
||||
status: FahrzeugStatusEnum.optional(),
|
||||
status_bemerkung: z.string().max(500).nullable().optional(),
|
||||
standort: z.string().min(1).max(100).optional(),
|
||||
bild_url: z.string().url().max(500).refine(
|
||||
(url) => /^https?:\/\//i.test(url),
|
||||
'Nur http/https URLs erlaubt'
|
||||
).nullable().optional(),
|
||||
paragraph57a_faellig_am: isoDate.nullable().optional(),
|
||||
naechste_wartung_am: isoDate.nullable().optional(),
|
||||
});
|
||||
|
||||
const UpdateStatusSchema = z.object({
|
||||
status: FahrzeugStatusEnum,
|
||||
bemerkung: z.string().max(500).optional().default(''),
|
||||
});
|
||||
|
||||
const CreatePruefungSchema = z.object({
|
||||
pruefung_art: PruefungArtEnum,
|
||||
faellig_am: isoDate,
|
||||
durchgefuehrt_am: isoDate.optional(),
|
||||
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(),
|
||||
pruefende_stelle: z.string().max(150).optional(),
|
||||
kosten: z.number().min(0).optional(),
|
||||
dokument_url: z.string().url().max(500).optional(),
|
||||
bemerkung: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const CreateWartungslogSchema = z.object({
|
||||
datum: isoDate,
|
||||
art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(),
|
||||
@@ -76,17 +82,12 @@ const CreateWartungslogSchema = z.object({
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getUserId(req: Request): string {
|
||||
// req.user is guaranteed by the authenticate middleware
|
||||
return req.user!.id;
|
||||
}
|
||||
|
||||
// ── Controller ────────────────────────────────────────────────────────────────
|
||||
|
||||
class VehicleController {
|
||||
/**
|
||||
* GET /api/vehicles
|
||||
* Fleet overview list with per-vehicle inspection badge data.
|
||||
*/
|
||||
async listVehicles(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const vehicles = await vehicleService.getAllVehicles();
|
||||
@@ -97,10 +98,6 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/stats
|
||||
* Aggregated KPI counts for the dashboard strip.
|
||||
*/
|
||||
async getStats(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stats = await vehicleService.getVehicleStats();
|
||||
@@ -111,23 +108,14 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/alerts?daysAhead=30
|
||||
* Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel.
|
||||
* Returns alerts sorted by urgency (most overdue / soonest due first).
|
||||
*/
|
||||
async getAlerts(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const daysAhead = Math.min(
|
||||
parseInt((req.query.daysAhead as string) || '30', 10),
|
||||
365 // hard cap — never expose more than 1 year of lookahead
|
||||
);
|
||||
|
||||
if (isNaN(daysAhead) || daysAhead < 0) {
|
||||
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
|
||||
if (isNaN(raw) || raw < 0) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
|
||||
return;
|
||||
}
|
||||
|
||||
const daysAhead = Math.min(raw, 365);
|
||||
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
|
||||
res.status(200).json({ success: true, data: alerts });
|
||||
} catch (error) {
|
||||
@@ -136,20 +124,18 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/:id
|
||||
* Full vehicle detail with pruefstatus, inspection history, and wartungslog.
|
||||
*/
|
||||
async getVehicle(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||
return;
|
||||
}
|
||||
const vehicle = await vehicleService.getVehicleById(id);
|
||||
|
||||
if (!vehicle) {
|
||||
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: vehicle });
|
||||
} catch (error) {
|
||||
logger.error('getVehicle error', { error, id: req.params.id });
|
||||
@@ -157,10 +143,6 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/vehicles
|
||||
* Create a new vehicle. Requires vehicles:write permission.
|
||||
*/
|
||||
async createVehicle(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const parsed = CreateFahrzeugSchema.safeParse(req.body);
|
||||
@@ -172,7 +154,6 @@ class VehicleController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
|
||||
res.status(201).json({ success: true, data: vehicle });
|
||||
} catch (error) {
|
||||
@@ -181,13 +162,13 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/vehicles/:id
|
||||
* Update vehicle fields. Requires vehicles:write permission.
|
||||
*/
|
||||
async updateVehicle(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
@@ -197,7 +178,10 @@ class VehicleController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(parsed.data).length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||
return;
|
||||
}
|
||||
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
|
||||
res.status(200).json({ success: true, data: vehicle });
|
||||
} catch (error: any) {
|
||||
@@ -210,20 +194,14 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/vehicles/:id/status
|
||||
* Live status change — the Socket.IO hook point for Tier 3.
|
||||
* Requires vehicles:write permission.
|
||||
*
|
||||
* The `io` instance is attached to req.app in server.ts (Tier 3):
|
||||
* app.set('io', io);
|
||||
* and retrieved here via req.app.get('io').
|
||||
*/
|
||||
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateStatusSchema.safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -232,19 +210,10 @@ class VehicleController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up.
|
||||
// Passing undefined here is safe — the service handles it gracefully.
|
||||
const io = req.app.get('io') ?? undefined;
|
||||
|
||||
await vehicleService.updateVehicleStatus(
|
||||
id,
|
||||
parsed.data.status,
|
||||
parsed.data.bemerkung,
|
||||
getUserId(req),
|
||||
io
|
||||
id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io
|
||||
);
|
||||
|
||||
res.status(200).json({ success: true, message: 'Status aktualisiert' });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Vehicle not found') {
|
||||
@@ -256,60 +225,14 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inspections ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/vehicles/:id/pruefungen
|
||||
* Record an inspection (scheduled or completed). Requires vehicles:write.
|
||||
*/
|
||||
async addPruefung(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
const parsed = CreatePruefungSchema.safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pruefung = await vehicleService.addPruefung(id, parsed.data, getUserId(req));
|
||||
res.status(201).json({ success: true, data: pruefung });
|
||||
} catch (error) {
|
||||
logger.error('addPruefung error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Prüfung konnte nicht eingetragen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/:id/pruefungen
|
||||
* Full inspection history for a vehicle.
|
||||
*/
|
||||
async getPruefungen(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
const pruefungen = await vehicleService.getPruefungenForVehicle(id);
|
||||
res.status(200).json({ success: true, data: pruefungen });
|
||||
} catch (error) {
|
||||
logger.error('getPruefungen error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Maintenance Log ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/vehicles/:id/wartung
|
||||
* Add a maintenance log entry. Requires vehicles:write.
|
||||
*/
|
||||
async addWartung(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||
return;
|
||||
}
|
||||
const parsed = CreateWartungslogSchema.safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -318,24 +241,27 @@ class VehicleController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
|
||||
res.status(201).json({ success: true, data: entry });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Vehicle not found') {
|
||||
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
logger.error('addWartung error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/vehicles/:id
|
||||
* Delete a vehicle. Requires dashboard_admin group.
|
||||
*/
|
||||
async deleteVehicle(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||
return;
|
||||
}
|
||||
await vehicleService.deleteVehicle(id, getUserId(req));
|
||||
res.status(200).json({ success: true, message: 'Fahrzeug gelöscht' });
|
||||
res.status(204).send();
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Vehicle not found') {
|
||||
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||
@@ -346,13 +272,13 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/:id/wartung
|
||||
* Maintenance log for a vehicle.
|
||||
*/
|
||||
async getWartung(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||
return;
|
||||
}
|
||||
const entries = await vehicleService.getWartungslogForVehicle(id);
|
||||
res.status(200).json({ success: true, data: entries });
|
||||
} catch (error) {
|
||||
|
||||
53
backend/src/database/migrations/008_simplify_inspections.sql
Normal file
53
backend/src/database/migrations/008_simplify_inspections.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Migration 008: Simplify inspection model
|
||||
-- Remove fahrzeug_pruefungen table and related structures.
|
||||
-- Only §57a (paragraph57a_faellig_am) and Wartung (naechste_wartung_am)
|
||||
-- remain as the two tracked inspection deadlines, stored on fahrzeuge.
|
||||
|
||||
-- Drop the pruefungen table (cascades to indexes)
|
||||
DROP TABLE IF EXISTS fahrzeug_pruefungen CASCADE;
|
||||
|
||||
-- Drop and recreate the fleet overview view (simplified — no CTE)
|
||||
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
|
||||
|
||||
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||
SELECT
|
||||
f.id,
|
||||
f.bezeichnung,
|
||||
f.kurzname,
|
||||
f.amtliches_kennzeichen,
|
||||
f.fahrgestellnummer,
|
||||
f.baujahr,
|
||||
f.hersteller,
|
||||
f.typ_schluessel,
|
||||
f.besatzung_soll,
|
||||
f.status,
|
||||
f.status_bemerkung,
|
||||
f.standort,
|
||||
f.bild_url,
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
f.paragraph57a_faellig_am,
|
||||
CASE
|
||||
WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS paragraph57a_tage_bis_faelligkeit,
|
||||
f.naechste_wartung_am,
|
||||
CASE
|
||||
WHEN f.naechste_wartung_am IS NOT NULL
|
||||
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS wartung_tage_bis_faelligkeit,
|
||||
LEAST(
|
||||
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||
ELSE NULL END,
|
||||
CASE WHEN f.naechste_wartung_am IS NOT NULL
|
||||
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||
ELSE NULL END
|
||||
) AS naechste_pruefung_tage
|
||||
FROM fahrzeuge f;
|
||||
|
||||
-- Index support for alert queries
|
||||
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_paragraph57a ON fahrzeuge(paragraph57a_faellig_am) WHERE paragraph57a_faellig_am IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_wartung ON fahrzeuge(naechste_wartung_am) WHERE naechste_wartung_am IS NOT NULL;
|
||||
57
backend/src/database/migrations/009_vehicle_soft_delete.sql
Normal file
57
backend/src/database/migrations/009_vehicle_soft_delete.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- Migration 009: Soft delete for vehicles
|
||||
-- Adds deleted_at to fahrzeuge and refreshes the view to exclude soft-deleted rows.
|
||||
-- Hard DELETE is replaced by UPDATE SET deleted_at = NOW() in the service layer.
|
||||
|
||||
ALTER TABLE fahrzeuge
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN fahrzeuge.deleted_at IS
|
||||
'NULL = active vehicle. Set to timestamp when soft-deleted. Records are never physically removed.';
|
||||
|
||||
-- Partial index: only index active (non-deleted) vehicles for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_active
|
||||
ON fahrzeuge(id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Refresh the view to exclude soft-deleted vehicles
|
||||
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
|
||||
|
||||
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||
SELECT
|
||||
f.id,
|
||||
f.bezeichnung,
|
||||
f.kurzname,
|
||||
f.amtliches_kennzeichen,
|
||||
f.fahrgestellnummer,
|
||||
f.baujahr,
|
||||
f.hersteller,
|
||||
f.typ_schluessel,
|
||||
f.besatzung_soll,
|
||||
f.status,
|
||||
f.status_bemerkung,
|
||||
f.standort,
|
||||
f.bild_url,
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
f.paragraph57a_faellig_am,
|
||||
CASE
|
||||
WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS paragraph57a_tage_bis_faelligkeit,
|
||||
f.naechste_wartung_am,
|
||||
CASE
|
||||
WHEN f.naechste_wartung_am IS NOT NULL
|
||||
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS wartung_tage_bis_faelligkeit,
|
||||
LEAST(
|
||||
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||
ELSE NULL END,
|
||||
CASE WHEN f.naechste_wartung_am IS NOT NULL
|
||||
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||
ELSE NULL END
|
||||
) AS naechste_pruefung_tage
|
||||
FROM fahrzeuge f
|
||||
WHERE f.deleted_at IS NULL;
|
||||
@@ -4,10 +4,6 @@
|
||||
|
||||
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Operational status of a vehicle.
|
||||
* These values are the CHECK constraint values in the database.
|
||||
*/
|
||||
export enum FahrzeugStatus {
|
||||
Einsatzbereit = 'einsatzbereit',
|
||||
AusserDienstWartung = 'ausser_dienst_wartung',
|
||||
@@ -15,7 +11,6 @@ export enum FahrzeugStatus {
|
||||
InLehrgang = 'in_lehrgang',
|
||||
}
|
||||
|
||||
/** Human-readable German labels for each status value */
|
||||
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
||||
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
|
||||
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
|
||||
@@ -23,53 +18,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
||||
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
|
||||
};
|
||||
|
||||
/**
|
||||
* Types of vehicle inspections (Prüfungsarten).
|
||||
* These values are the CHECK constraint values in the database.
|
||||
*/
|
||||
export enum PruefungArt {
|
||||
HU = 'HU', // Hauptuntersuchung (TÜV) — 24-month interval
|
||||
AU = 'AU', // Abgasuntersuchung — 12-month interval
|
||||
UVV = 'UVV', // Unfallverhütungsvorschrift BGV D29 — 12-month
|
||||
Leiter = 'Leiter', // Leiternprüfung (DLK only) — 12-month
|
||||
Kran = 'Kran', // Kranprüfung — 12-month
|
||||
Seilwinde = 'Seilwinde', // Seilwindenprüfung — 12-month
|
||||
Sonstiges = 'Sonstiges',
|
||||
}
|
||||
|
||||
/** Human-readable German labels for each PruefungArt */
|
||||
export const PruefungArtLabel: Record<PruefungArt, string> = {
|
||||
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
|
||||
[PruefungArt.AU]: 'Abgasuntersuchung',
|
||||
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
|
||||
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
|
||||
[PruefungArt.Kran]: 'Kranprüfung',
|
||||
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
|
||||
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard inspection intervals in months, keyed by PruefungArt.
|
||||
* Used by vehicle.service.ts to auto-calculate naechste_faelligkeit.
|
||||
*/
|
||||
export const PruefungIntervalMonths: Partial<Record<PruefungArt, number>> = {
|
||||
[PruefungArt.HU]: 24,
|
||||
[PruefungArt.AU]: 12,
|
||||
[PruefungArt.UVV]: 12,
|
||||
[PruefungArt.Leiter]: 12,
|
||||
[PruefungArt.Kran]: 12,
|
||||
[PruefungArt.Seilwinde]: 12,
|
||||
// Sonstiges: no standard interval — must be set manually
|
||||
};
|
||||
|
||||
/** Inspection result values */
|
||||
export type PruefungErgebnis =
|
||||
| 'bestanden'
|
||||
| 'bestanden_mit_maengeln'
|
||||
| 'nicht_bestanden'
|
||||
| 'ausstehend';
|
||||
|
||||
/** Maintenance log entry types */
|
||||
export type WartungslogArt =
|
||||
| 'Inspektion'
|
||||
| 'Reparatur'
|
||||
@@ -81,50 +29,29 @@ export type WartungslogArt =
|
||||
|
||||
// ── Core Entities ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Raw database row from the `fahrzeuge` table */
|
||||
export interface Fahrzeug {
|
||||
id: string; // UUID
|
||||
bezeichnung: string; // e.g. "LF 20/16"
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
amtliches_kennzeichen: string | null;
|
||||
fahrgestellnummer: string | null;
|
||||
baujahr: number | null;
|
||||
hersteller: string | null;
|
||||
typ_schluessel: string | null;
|
||||
besatzung_soll: string | null; // e.g. "1/8"
|
||||
besatzung_soll: string | null;
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: string | null;
|
||||
standort: string;
|
||||
bild_url: string | null;
|
||||
/** §57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV) */
|
||||
paragraph57a_faellig_am: Date | null;
|
||||
/** Next scheduled service / maintenance due date */
|
||||
naechste_wartung_am: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** Raw database row from `fahrzeug_pruefungen` */
|
||||
export interface FahrzeugPruefung {
|
||||
id: string; // UUID
|
||||
fahrzeug_id: string; // UUID FK
|
||||
pruefung_art: PruefungArt;
|
||||
faellig_am: Date; // The hard legal deadline
|
||||
durchgefuehrt_am: Date | null;
|
||||
ergebnis: PruefungErgebnis | null;
|
||||
naechste_faelligkeit: Date | null;
|
||||
pruefende_stelle: string | null;
|
||||
kosten: number | null;
|
||||
dokument_url: string | null;
|
||||
bemerkung: string | null;
|
||||
erfasst_von: string | null; // UUID FK users
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
/** Raw database row from `fahrzeug_wartungslog` */
|
||||
export interface FahrzeugWartungslog {
|
||||
id: string; // UUID
|
||||
fahrzeug_id: string; // UUID FK
|
||||
id: string;
|
||||
fahrzeug_id: string;
|
||||
datum: Date;
|
||||
art: WartungslogArt | null;
|
||||
beschreibung: string;
|
||||
@@ -132,49 +59,12 @@ export interface FahrzeugWartungslog {
|
||||
kraftstoff_liter: number | null;
|
||||
kosten: number | null;
|
||||
externe_werkstatt: string | null;
|
||||
erfasst_von: string | null; // UUID FK users
|
||||
erfasst_von: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// ── Inspection Status per Type ────────────────────────────────────────────────
|
||||
|
||||
/** Status of a single inspection type for a vehicle */
|
||||
export interface PruefungStatus {
|
||||
pruefung_id: string | null;
|
||||
faellig_am: Date | null;
|
||||
tage_bis_faelligkeit: number | null; // negative = overdue
|
||||
ergebnis: PruefungErgebnis | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vehicle with its per-type inspection status.
|
||||
* Comes from the `fahrzeuge_mit_pruefstatus` view.
|
||||
*/
|
||||
export interface FahrzeugWithPruefstatus extends Fahrzeug {
|
||||
pruefstatus: {
|
||||
hu: PruefungStatus;
|
||||
au: PruefungStatus;
|
||||
uvv: PruefungStatus;
|
||||
leiter: PruefungStatus;
|
||||
};
|
||||
/** Days until §57a inspection (negative = overdue) */
|
||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||
/** Days until next service (negative = overdue) */
|
||||
wartung_tage_bis_faelligkeit: number | null;
|
||||
/** Minimum tage_bis_faelligkeit across all inspections (negative = any overdue) */
|
||||
naechste_pruefung_tage: number | null;
|
||||
/** Full inspection history, ordered by faellig_am DESC */
|
||||
pruefungen: FahrzeugPruefung[];
|
||||
/** Maintenance log entries, ordered by datum DESC */
|
||||
wartungslog: FahrzeugWartungslog[];
|
||||
}
|
||||
|
||||
// ── List Item (Grid / Card view) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lightweight type used in the vehicle fleet overview grid.
|
||||
* Includes only the fields needed to render a card plus inspection badges.
|
||||
*/
|
||||
export interface FahrzeugListItem {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
@@ -186,47 +76,44 @@ export interface FahrzeugListItem {
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: string | null;
|
||||
bild_url: string | null;
|
||||
/** §57a due date (primary inspection badge) */
|
||||
paragraph57a_faellig_am: Date | null;
|
||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||
/** Next service due date */
|
||||
naechste_wartung_am: Date | null;
|
||||
wartung_tage_bis_faelligkeit: number | null;
|
||||
// Legacy pruefungen kept for backwards compat
|
||||
hu_faellig_am: Date | null;
|
||||
hu_tage_bis_faelligkeit: number | null;
|
||||
au_faellig_am: Date | null;
|
||||
au_tage_bis_faelligkeit: number | null;
|
||||
uvv_faellig_am: Date | null;
|
||||
uvv_tage_bis_faelligkeit: number | null;
|
||||
leiter_faellig_am: Date | null;
|
||||
leiter_tage_bis_faelligkeit: number | null;
|
||||
naechste_pruefung_tage: number | null;
|
||||
}
|
||||
|
||||
// ── Detail View ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FahrzeugDetail extends Fahrzeug {
|
||||
paragraph57a_tage_bis_faelligkeit: number | null;
|
||||
wartung_tage_bis_faelligkeit: number | null;
|
||||
naechste_pruefung_tage: number | null;
|
||||
wartungslog: FahrzeugWartungslog[];
|
||||
}
|
||||
|
||||
// ── Dashboard KPI ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Aggregated vehicle stats for the dashboard KPI strip */
|
||||
export interface VehicleStats {
|
||||
total: number;
|
||||
einsatzbereit: number;
|
||||
ausserDienst: number; // wartung + schaden combined
|
||||
inLehrgang: number;
|
||||
inspectionsDue: number; // vehicles with any inspection due within 30 days
|
||||
inspectionsOverdue: number; // vehicles with any inspection already overdue
|
||||
total: number;
|
||||
einsatzbereit: number;
|
||||
ausserDienst: number;
|
||||
inLehrgang: number;
|
||||
inspectionsDue: number;
|
||||
inspectionsOverdue: number;
|
||||
}
|
||||
|
||||
// ── Inspection Alert ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Single alert item for the dashboard InspectionAlerts component */
|
||||
export type InspectionAlertType = '57a' | 'wartung';
|
||||
|
||||
export interface InspectionAlert {
|
||||
fahrzeugId: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
pruefungId: string;
|
||||
pruefungArt: PruefungArt;
|
||||
type: InspectionAlertType;
|
||||
faelligAm: Date;
|
||||
tage: number; // negative = already overdue
|
||||
tage: number;
|
||||
}
|
||||
|
||||
// ── Create / Update DTOs ──────────────────────────────────────────────────────
|
||||
@@ -244,8 +131,8 @@ export interface CreateFahrzeugData {
|
||||
status_bemerkung?: string;
|
||||
standort?: string;
|
||||
bild_url?: string;
|
||||
paragraph57a_faellig_am?: string; // ISO date 'YYYY-MM-DD'
|
||||
naechste_wartung_am?: string; // ISO date 'YYYY-MM-DD'
|
||||
paragraph57a_faellig_am?: string;
|
||||
naechste_wartung_am?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFahrzeugData {
|
||||
@@ -261,24 +148,12 @@ export interface UpdateFahrzeugData {
|
||||
status_bemerkung?: string | null;
|
||||
standort?: string;
|
||||
bild_url?: string | null;
|
||||
paragraph57a_faellig_am?: string | null; // ISO date 'YYYY-MM-DD'
|
||||
naechste_wartung_am?: string | null; // ISO date 'YYYY-MM-DD'
|
||||
}
|
||||
|
||||
export interface CreatePruefungData {
|
||||
pruefung_art: PruefungArt;
|
||||
faellig_am: string; // ISO date string 'YYYY-MM-DD'
|
||||
durchgefuehrt_am?: string; // ISO date string, optional
|
||||
ergebnis?: PruefungErgebnis;
|
||||
pruefende_stelle?: string;
|
||||
kosten?: number;
|
||||
dokument_url?: string;
|
||||
bemerkung?: string;
|
||||
// naechste_faelligkeit is auto-calculated by the service — not accepted from client
|
||||
paragraph57a_faellig_am?: string | null;
|
||||
naechste_wartung_am?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateWartungslogData {
|
||||
datum: string; // ISO date string 'YYYY-MM-DD'
|
||||
datum: string;
|
||||
art?: WartungslogArt;
|
||||
beschreibung: string;
|
||||
km_stand?: number;
|
||||
|
||||
@@ -3,118 +3,28 @@ import vehicleController from '../controllers/vehicle.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requireGroups } from '../middleware/rbac.middleware';
|
||||
|
||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||
const STATUS_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Read-only endpoints (any authenticated user) ──────────────────────────────
|
||||
// ── Read-only (any authenticated user) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/vehicles
|
||||
* Fleet overview list — inspection badges included.
|
||||
*/
|
||||
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/stats
|
||||
* Dashboard KPI aggregates.
|
||||
* NOTE: /stats and /alerts must be declared BEFORE /:id to avoid route conflicts.
|
||||
*/
|
||||
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/alerts?daysAhead=30
|
||||
* Upcoming and overdue inspections for the dashboard alert panel.
|
||||
*/
|
||||
router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController));
|
||||
router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController));
|
||||
router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController));
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/:id
|
||||
* Full vehicle detail with inspection history and maintenance log.
|
||||
*/
|
||||
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/:id/pruefungen
|
||||
* Inspection history for a single vehicle.
|
||||
*/
|
||||
router.get('/:id/pruefungen', authenticate, vehicleController.getPruefungen.bind(vehicleController));
|
||||
|
||||
/**
|
||||
* GET /api/vehicles/:id/wartung
|
||||
* Maintenance log for a single vehicle.
|
||||
*/
|
||||
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||
|
||||
// ── Write endpoints (dashboard_admin group required) ────────────────────────
|
||||
// ── Write — admin only ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/vehicles
|
||||
* Create a new vehicle.
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
requireGroups(ADMIN_GROUPS),
|
||||
vehicleController.createVehicle.bind(vehicleController)
|
||||
);
|
||||
router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController));
|
||||
router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController));
|
||||
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController));
|
||||
|
||||
/**
|
||||
* PATCH /api/vehicles/:id
|
||||
* Update vehicle fields.
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requireGroups(ADMIN_GROUPS),
|
||||
vehicleController.updateVehicle.bind(vehicleController)
|
||||
);
|
||||
// ── Status + maintenance log — admin + fahrmeister ────────────────────────────
|
||||
|
||||
/**
|
||||
* PATCH /api/vehicles/:id/status
|
||||
* Live status change — dashboard_admin or dashboard_fahrmeister required.
|
||||
* The `io` instance is retrieved inside the controller via req.app.get('io').
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/status',
|
||||
authenticate,
|
||||
requireGroups(STATUS_GROUPS),
|
||||
vehicleController.updateVehicleStatus.bind(vehicleController)
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/vehicles/:id/pruefungen
|
||||
* Record an inspection (scheduled or completed).
|
||||
*/
|
||||
router.post(
|
||||
'/:id/pruefungen',
|
||||
authenticate,
|
||||
requireGroups(ADMIN_GROUPS),
|
||||
vehicleController.addPruefung.bind(vehicleController)
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/vehicles/:id/wartung
|
||||
* Add a maintenance log entry.
|
||||
*/
|
||||
router.post(
|
||||
'/:id/wartung',
|
||||
authenticate,
|
||||
requireGroups(ADMIN_GROUPS),
|
||||
vehicleController.addWartung.bind(vehicleController)
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/vehicles/:id
|
||||
* Delete a vehicle — dashboard_admin only.
|
||||
* NOTE: vehicleController.deleteVehicle needs to be implemented.
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requireGroups(ADMIN_GROUPS),
|
||||
vehicleController.deleteVehicle.bind(vehicleController)
|
||||
);
|
||||
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,79 +3,29 @@ import logger from '../utils/logger';
|
||||
import {
|
||||
Fahrzeug,
|
||||
FahrzeugListItem,
|
||||
FahrzeugWithPruefstatus,
|
||||
FahrzeugPruefung,
|
||||
FahrzeugDetail,
|
||||
FahrzeugWartungslog,
|
||||
CreateFahrzeugData,
|
||||
UpdateFahrzeugData,
|
||||
CreatePruefungData,
|
||||
CreateWartungslogData,
|
||||
FahrzeugStatus,
|
||||
PruefungArt,
|
||||
PruefungIntervalMonths,
|
||||
VehicleStats,
|
||||
InspectionAlert,
|
||||
} from '../models/vehicle.model';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: add N months to a Date (handles month-end edge cases)
|
||||
// ---------------------------------------------------------------------------
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setMonth(result.getMonth() + months);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: map a flat view row to PruefungStatus sub-object
|
||||
// ---------------------------------------------------------------------------
|
||||
function mapPruefungStatus(row: any, prefix: string) {
|
||||
return {
|
||||
pruefung_id: row[`${prefix}_pruefung_id`] ?? null,
|
||||
faellig_am: row[`${prefix}_faellig_am`] ?? null,
|
||||
tage_bis_faelligkeit: row[`${prefix}_tage_bis_faelligkeit`] != null
|
||||
? parseInt(row[`${prefix}_tage_bis_faelligkeit`], 10)
|
||||
: null,
|
||||
ergebnis: row[`${prefix}_ergebnis`] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
class VehicleService {
|
||||
// =========================================================================
|
||||
// FLEET OVERVIEW
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Returns all vehicles with their next-due inspection dates per type.
|
||||
* Used by the fleet overview grid (FahrzeugListItem[]).
|
||||
*/
|
||||
async getAllVehicles(): Promise<FahrzeugListItem[]> {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
bezeichnung,
|
||||
kurzname,
|
||||
amtliches_kennzeichen,
|
||||
baujahr,
|
||||
hersteller,
|
||||
besatzung_soll,
|
||||
status,
|
||||
status_bemerkung,
|
||||
bild_url,
|
||||
paragraph57a_faellig_am,
|
||||
paragraph57a_tage_bis_faelligkeit,
|
||||
naechste_wartung_am,
|
||||
wartung_tage_bis_faelligkeit,
|
||||
hu_faellig_am,
|
||||
hu_tage_bis_faelligkeit,
|
||||
au_faellig_am,
|
||||
au_tage_bis_faelligkeit,
|
||||
uvv_faellig_am,
|
||||
uvv_tage_bis_faelligkeit,
|
||||
leiter_faellig_am,
|
||||
leiter_tage_bis_faelligkeit,
|
||||
naechste_pruefung_tage
|
||||
id, bezeichnung, kurzname, amtliches_kennzeichen,
|
||||
baujahr, hersteller, besatzung_soll, status, status_bemerkung,
|
||||
bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit,
|
||||
naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage
|
||||
FROM fahrzeuge_mit_pruefstatus
|
||||
ORDER BY bezeichnung ASC
|
||||
`);
|
||||
@@ -84,17 +34,9 @@ class VehicleService {
|
||||
...row,
|
||||
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
||||
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||
hu_tage_bis_faelligkeit: row.hu_tage_bis_faelligkeit != null
|
||||
? parseInt(row.hu_tage_bis_faelligkeit, 10) : null,
|
||||
au_tage_bis_faelligkeit: row.au_tage_bis_faelligkeit != null
|
||||
? parseInt(row.au_tage_bis_faelligkeit, 10) : null,
|
||||
uvv_tage_bis_faelligkeit: row.uvv_tage_bis_faelligkeit != null
|
||||
? parseInt(row.uvv_tage_bis_faelligkeit, 10) : null,
|
||||
leiter_tage_bis_faelligkeit: row.leiter_tage_bis_faelligkeit != null
|
||||
? parseInt(row.leiter_tage_bis_faelligkeit, 10) : null,
|
||||
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
||||
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
||||
? parseInt(row.naechste_pruefung_tage, 10) : null,
|
||||
})) as FahrzeugListItem[];
|
||||
} catch (error) {
|
||||
@@ -107,13 +49,8 @@ class VehicleService {
|
||||
// VEHICLE DETAIL
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Returns a single vehicle with full pruefstatus, inspection history,
|
||||
* and maintenance log.
|
||||
*/
|
||||
async getVehicleById(id: string): Promise<FahrzeugWithPruefstatus | null> {
|
||||
async getVehicleById(id: string): Promise<FahrzeugDetail | null> {
|
||||
try {
|
||||
// 1) Main record + inspection status from view
|
||||
const vehicleResult = await pool.query(
|
||||
`SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`,
|
||||
[id]
|
||||
@@ -123,15 +60,6 @@ class VehicleService {
|
||||
|
||||
const row = vehicleResult.rows[0];
|
||||
|
||||
// 2) Full inspection history
|
||||
const pruefungenResult = await pool.query(
|
||||
`SELECT * FROM fahrzeug_pruefungen
|
||||
WHERE fahrzeug_id = $1
|
||||
ORDER BY faellig_am DESC, created_at DESC`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// 3) Maintenance log
|
||||
const wartungslogResult = await pool.query(
|
||||
`SELECT * FROM fahrzeug_wartungslog
|
||||
WHERE fahrzeug_id = $1
|
||||
@@ -139,7 +67,7 @@ class VehicleService {
|
||||
[id]
|
||||
);
|
||||
|
||||
const vehicle: FahrzeugWithPruefstatus = {
|
||||
const vehicle: FahrzeugDetail = {
|
||||
id: row.id,
|
||||
bezeichnung: row.bezeichnung,
|
||||
kurzname: row.kurzname,
|
||||
@@ -157,20 +85,16 @@ class VehicleService {
|
||||
naechste_wartung_am: row.naechste_wartung_am ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
pruefstatus: {
|
||||
hu: mapPruefungStatus(row, 'hu'),
|
||||
au: mapPruefungStatus(row, 'au'),
|
||||
uvv: mapPruefungStatus(row, 'uvv'),
|
||||
leiter: mapPruefungStatus(row, 'leiter'),
|
||||
},
|
||||
paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null
|
||||
? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null,
|
||||
wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null
|
||||
? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null,
|
||||
naechste_pruefung_tage: row.naechste_pruefung_tage != null
|
||||
? parseInt(row.naechste_pruefung_tage, 10) : null,
|
||||
pruefungen: pruefungenResult.rows as FahrzeugPruefung[],
|
||||
wartungslog: wartungslogResult.rows as FahrzeugWartungslog[],
|
||||
wartungslog: wartungslogResult.rows.map(r => ({
|
||||
...r,
|
||||
kosten: r.kosten != null ? Number(r.kosten) : null,
|
||||
})) as FahrzeugWartungslog[],
|
||||
};
|
||||
|
||||
return vehicle;
|
||||
@@ -184,10 +108,7 @@ class VehicleService {
|
||||
// CRUD
|
||||
// =========================================================================
|
||||
|
||||
async createVehicle(
|
||||
data: CreateFahrzeugData,
|
||||
createdBy: string
|
||||
): Promise<Fahrzeug> {
|
||||
async createVehicle(data: CreateFahrzeugData, createdBy: string): Promise<Fahrzeug> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO fahrzeuge (
|
||||
@@ -224,11 +145,7 @@ class VehicleService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateVehicle(
|
||||
id: string,
|
||||
data: UpdateFahrzeugData,
|
||||
updatedBy: string
|
||||
): Promise<Fahrzeug> {
|
||||
async updateVehicle(id: string, data: UpdateFahrzeugData, updatedBy: string): Promise<Fahrzeug> {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
@@ -258,9 +175,9 @@ class VehicleService {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(id); // for WHERE clause
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`,
|
||||
`UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
@@ -280,7 +197,10 @@ class VehicleService {
|
||||
async deleteVehicle(id: string, deletedBy: string): Promise<void> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM fahrzeuge WHERE id = $1 RETURNING id`,
|
||||
`UPDATE fahrzeuge
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
@@ -288,7 +208,7 @@ class VehicleService {
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
logger.info('Vehicle deleted', { id, by: deletedBy });
|
||||
logger.info('Vehicle soft-deleted', { id, by: deletedBy });
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.deleteVehicle failed', { error, id });
|
||||
throw error;
|
||||
@@ -297,22 +217,8 @@ class VehicleService {
|
||||
|
||||
// =========================================================================
|
||||
// STATUS MANAGEMENT
|
||||
// Socket.io-ready: accepts optional `io` parameter.
|
||||
// In Tier 3, pass the real Socket.IO server instance here.
|
||||
// The endpoint contract is: PATCH /api/vehicles/:id/status
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Updates vehicle status and optionally broadcasts a Socket.IO event.
|
||||
*
|
||||
* Socket.IO integration (Tier 3):
|
||||
* Pass the live `io` instance from server.ts. When provided, emits:
|
||||
* event: 'vehicle:statusChanged'
|
||||
* payload: { vehicleId, bezeichnung, oldStatus, newStatus, bemerkung, updatedBy, timestamp }
|
||||
* All connected clients on the default namespace receive the update immediately.
|
||||
*
|
||||
* @param io - Optional Socket.IO server instance (injected from app layer in Tier 3)
|
||||
*/
|
||||
async updateVehicleStatus(
|
||||
id: string,
|
||||
status: FahrzeugStatus,
|
||||
@@ -320,38 +226,33 @@ class VehicleService {
|
||||
updatedBy: string,
|
||||
io?: any
|
||||
): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Fetch old status for Socket.IO payload and logging
|
||||
const oldResult = await pool.query(
|
||||
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`,
|
||||
await client.query('BEGIN');
|
||||
|
||||
const oldResult = await client.query(
|
||||
`SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (oldResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
const { bezeichnung, status: oldStatus } = oldResult.rows[0];
|
||||
|
||||
await pool.query(
|
||||
`UPDATE fahrzeuge
|
||||
SET status = $1, status_bemerkung = $2
|
||||
WHERE id = $3`,
|
||||
await client.query(
|
||||
`UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`,
|
||||
[status, bemerkung || null, id]
|
||||
);
|
||||
|
||||
logger.info('Vehicle status updated', {
|
||||
id,
|
||||
from: oldStatus,
|
||||
to: status,
|
||||
by: updatedBy,
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy });
|
||||
|
||||
// ── Socket.IO broadcast (Tier 3 integration point) ──────────────────
|
||||
// When `io` is provided (Tier 3), broadcast the status change to all
|
||||
// connected dashboard clients so the live status board updates in real time.
|
||||
if (io) {
|
||||
const payload = {
|
||||
io.emit('vehicle:statusChanged', {
|
||||
vehicleId: id,
|
||||
bezeichnung,
|
||||
oldStatus,
|
||||
@@ -359,143 +260,14 @@ class VehicleService {
|
||||
bemerkung: bemerkung || null,
|
||||
updatedBy,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
io.emit('vehicle:statusChanged', payload);
|
||||
logger.debug('Emitted vehicle:statusChanged via Socket.IO', { vehicleId: id });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
logger.error('VehicleService.updateVehicleStatus failed', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INSPECTIONS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Records a new inspection entry.
|
||||
* Automatically calculates naechste_faelligkeit based on standard intervals
|
||||
* when durchgefuehrt_am is provided and the art has a known interval.
|
||||
*/
|
||||
async addPruefung(
|
||||
fahrzeugId: string,
|
||||
data: CreatePruefungData,
|
||||
createdBy: string
|
||||
): Promise<FahrzeugPruefung> {
|
||||
try {
|
||||
// Auto-calculate naechste_faelligkeit
|
||||
let naechsteFaelligkeit: string | null = null;
|
||||
|
||||
if (data.durchgefuehrt_am) {
|
||||
const intervalMonths = PruefungIntervalMonths[data.pruefung_art];
|
||||
if (intervalMonths !== undefined) {
|
||||
const durchgefuehrt = new Date(data.durchgefuehrt_am);
|
||||
naechsteFaelligkeit = addMonths(durchgefuehrt, intervalMonths)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO fahrzeug_pruefungen (
|
||||
fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am,
|
||||
ergebnis, naechste_faelligkeit, pruefende_stelle,
|
||||
kosten, dokument_url, bemerkung, erfasst_von
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING *`,
|
||||
[
|
||||
fahrzeugId,
|
||||
data.pruefung_art,
|
||||
data.faellig_am,
|
||||
data.durchgefuehrt_am ?? null,
|
||||
data.ergebnis ?? 'ausstehend',
|
||||
naechsteFaelligkeit,
|
||||
data.pruefende_stelle ?? null,
|
||||
data.kosten ?? null,
|
||||
data.dokument_url ?? null,
|
||||
data.bemerkung ?? null,
|
||||
createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
const pruefung = result.rows[0] as FahrzeugPruefung;
|
||||
logger.info('Pruefung added', {
|
||||
pruefungId: pruefung.id,
|
||||
fahrzeugId,
|
||||
art: data.pruefung_art,
|
||||
by: createdBy,
|
||||
});
|
||||
return pruefung;
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.addPruefung failed', { error, fahrzeugId });
|
||||
throw new Error('Failed to add inspection record');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full inspection history for a specific vehicle,
|
||||
* ordered newest-first.
|
||||
*/
|
||||
async getPruefungenForVehicle(fahrzeugId: string): Promise<FahrzeugPruefung[]> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM fahrzeug_pruefungen
|
||||
WHERE fahrzeug_id = $1
|
||||
ORDER BY faellig_am DESC, created_at DESC`,
|
||||
[fahrzeugId]
|
||||
);
|
||||
return result.rows.map(r => ({
|
||||
...r,
|
||||
kosten: r.kosten != null ? Number(r.kosten) : null,
|
||||
})) as FahrzeugPruefung[];
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.getPruefungenForVehicle failed', { error, fahrzeugId });
|
||||
throw new Error('Failed to fetch inspection history');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all upcoming or overdue inspections within the given lookahead window.
|
||||
* Used by the dashboard InspectionAlerts panel.
|
||||
*
|
||||
* @param daysAhead - How many days into the future to look (e.g. 30).
|
||||
* Pass a very large number (e.g. 9999) to include all overdue too.
|
||||
*/
|
||||
async getUpcomingInspections(daysAhead: number): Promise<InspectionAlert[]> {
|
||||
try {
|
||||
// We include already-overdue inspections (tage < 0) AND upcoming within window.
|
||||
// Only open (not yet completed) inspections are relevant.
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
p.id AS pruefung_id,
|
||||
p.fahrzeug_id,
|
||||
p.pruefung_art,
|
||||
p.faellig_am,
|
||||
(p.faellig_am::date - CURRENT_DATE) AS tage,
|
||||
f.bezeichnung,
|
||||
f.kurzname
|
||||
FROM fahrzeug_pruefungen p
|
||||
JOIN fahrzeuge f ON f.id = p.fahrzeug_id
|
||||
WHERE
|
||||
p.durchgefuehrt_am IS NULL
|
||||
AND (p.faellig_am::date - CURRENT_DATE) <= $1
|
||||
ORDER BY p.faellig_am ASC`,
|
||||
[daysAhead]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
fahrzeugId: row.fahrzeug_id,
|
||||
bezeichnung: row.bezeichnung,
|
||||
kurzname: row.kurzname,
|
||||
pruefungId: row.pruefung_id,
|
||||
pruefungArt: row.pruefung_art as PruefungArt,
|
||||
faelligAm: row.faellig_am,
|
||||
tage: parseInt(row.tage, 10),
|
||||
})) as InspectionAlert[];
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead });
|
||||
throw new Error('Failed to fetch inspection alerts');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +281,14 @@ class VehicleService {
|
||||
createdBy: string
|
||||
): Promise<FahrzeugWartungslog> {
|
||||
try {
|
||||
const check = await pool.query(
|
||||
`SELECT 1 FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[fahrzeugId]
|
||||
);
|
||||
if (check.rows.length === 0) {
|
||||
throw new Error('Vehicle not found');
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO fahrzeug_wartungslog (
|
||||
fahrzeug_id, datum, art, beschreibung,
|
||||
@@ -529,15 +309,11 @@ class VehicleService {
|
||||
);
|
||||
|
||||
const entry = result.rows[0] as FahrzeugWartungslog;
|
||||
logger.info('Wartungslog entry added', {
|
||||
entryId: entry.id,
|
||||
fahrzeugId,
|
||||
by: createdBy,
|
||||
});
|
||||
logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy });
|
||||
return entry;
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId });
|
||||
throw new Error('Failed to add maintenance log entry');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,14 +339,9 @@ class VehicleService {
|
||||
// DASHBOARD KPI
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Returns aggregate counts for the dashboard stats strip.
|
||||
* inspectionsDue = vehicles with at least one inspection due within 30 days
|
||||
* inspectionsOverdue = vehicles with at least one inspection already overdue
|
||||
*/
|
||||
async getVehicleStats(): Promise<VehicleStats> {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
const totalsResult = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
|
||||
@@ -579,22 +350,31 @@ class VehicleService {
|
||||
) AS ausser_dienst,
|
||||
COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang
|
||||
FROM fahrzeuge
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
const alertResult = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT fahrzeug_id) FILTER (
|
||||
WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30
|
||||
COUNT(*) FILTER (
|
||||
WHERE (
|
||||
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE BETWEEN 0 AND 30)
|
||||
OR
|
||||
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE BETWEEN 0 AND 30)
|
||||
)
|
||||
) AS inspections_due,
|
||||
COUNT(DISTINCT fahrzeug_id) FILTER (
|
||||
WHERE faellig_am::date < CURRENT_DATE
|
||||
COUNT(*) FILTER (
|
||||
WHERE (
|
||||
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date < CURRENT_DATE)
|
||||
OR
|
||||
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date < CURRENT_DATE)
|
||||
)
|
||||
) AS inspections_overdue
|
||||
FROM fahrzeug_pruefungen
|
||||
WHERE durchgefuehrt_am IS NULL
|
||||
FROM fahrzeuge
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
const totals = result.rows[0];
|
||||
const alerts = alertResult.rows[0];
|
||||
const totals = totalsResult.rows[0];
|
||||
const alerts = alertResult.rows[0];
|
||||
|
||||
return {
|
||||
total: parseInt(totals.total, 10),
|
||||
@@ -609,6 +389,73 @@ class VehicleService {
|
||||
throw new Error('Failed to fetch vehicle stats');
|
||||
}
|
||||
}
|
||||
|
||||
async getUpcomingInspections(daysAhead: number): Promise<InspectionAlert[]> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id AS fahrzeug_id,
|
||||
bezeichnung,
|
||||
kurzname,
|
||||
paragraph57a_faellig_am,
|
||||
paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage,
|
||||
naechste_wartung_am,
|
||||
naechste_wartung_am::date - CURRENT_DATE AS wartung_tage
|
||||
FROM fahrzeuge
|
||||
WHERE
|
||||
deleted_at IS NULL
|
||||
AND (
|
||||
(paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE <= $1)
|
||||
OR
|
||||
(naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE <= $1)
|
||||
)
|
||||
ORDER BY LEAST(
|
||||
CASE WHEN paragraph57a_faellig_am IS NOT NULL
|
||||
THEN paragraph57a_faellig_am::date - CURRENT_DATE END,
|
||||
CASE WHEN naechste_wartung_am IS NOT NULL
|
||||
THEN naechste_wartung_am::date - CURRENT_DATE END
|
||||
) ASC NULLS LAST`,
|
||||
[daysAhead]
|
||||
);
|
||||
|
||||
const alerts: InspectionAlert[] = [];
|
||||
|
||||
for (const row of result.rows) {
|
||||
if (row.paragraph57a_faellig_am !== null && row.paragraph57a_tage !== null) {
|
||||
const tage = parseInt(row.paragraph57a_tage, 10);
|
||||
if (tage <= daysAhead) {
|
||||
alerts.push({
|
||||
fahrzeugId: row.fahrzeug_id,
|
||||
bezeichnung: row.bezeichnung,
|
||||
kurzname: row.kurzname,
|
||||
type: '57a',
|
||||
faelligAm: row.paragraph57a_faellig_am,
|
||||
tage,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (row.naechste_wartung_am !== null && row.wartung_tage !== null) {
|
||||
const tage = parseInt(row.wartung_tage, 10);
|
||||
if (tage <= daysAhead) {
|
||||
alerts.push({
|
||||
fahrzeugId: row.fahrzeug_id,
|
||||
bezeichnung: row.bezeichnung,
|
||||
kurzname: row.kurzname,
|
||||
type: 'wartung',
|
||||
faelligAm: row.naechste_wartung_am,
|
||||
tage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alerts.sort((a, b) => a.tage - b.tage);
|
||||
return alerts;
|
||||
} catch (error) {
|
||||
logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead });
|
||||
throw new Error('Failed to fetch inspection alerts');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VehicleService();
|
||||
|
||||
Reference in New Issue
Block a user