rework vehicle handling

This commit is contained in:
Matthias Hochmeister
2026-02-28 13:34:16 +01:00
parent 84cf505511
commit 41fc41bee4
13 changed files with 931 additions and 1228 deletions

View File

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

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

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

View File

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

View File

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

View File

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