refine vehicle freatures

This commit is contained in:
Matthias Hochmeister
2026-02-28 17:19:18 +01:00
parent 0e81eabda6
commit e2be29c712
17 changed files with 4071 additions and 117 deletions

View File

@@ -60,6 +60,7 @@ import adminRoutes from './routes/admin.routes';
import trainingRoutes from './routes/training.routes';
import vehicleRoutes from './routes/vehicle.routes';
import incidentRoutes from './routes/incident.routes';
import equipmentRoutes from './routes/equipment.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -68,6 +69,7 @@ app.use('/api/admin', adminRoutes);
app.use('/api/training', trainingRoutes);
app.use('/api/vehicles', vehicleRoutes);
app.use('/api/incidents', incidentRoutes);
app.use('/api/equipment', equipmentRoutes);
// 404 handler
app.use(notFoundHandler);

View File

@@ -0,0 +1,318 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import equipmentService from '../services/equipment.service';
import { AusruestungStatus } from '../models/equipment.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 AusruestungStatusEnum = z.enum([
AusruestungStatus.Einsatzbereit,
AusruestungStatus.Beschaedigt,
AusruestungStatus.InWartung,
AusruestungStatus.AusserDienst,
]);
const isoDate = z.string().regex(
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
'Erwartet ISO-Datum im Format YYYY-MM-DD'
);
const uuidString = z.string().regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'Ungültige UUID'
);
const CreateAusruestungSchema = z.object({
bezeichnung: z.string().min(1).max(200),
kategorie_id: uuidString,
seriennummer: z.string().max(100).optional(),
inventarnummer: z.string().max(50).optional(),
hersteller: z.string().max(150).optional(),
baujahr: z.number().int().min(1950).max(2100).optional(),
status: AusruestungStatusEnum.optional(),
status_bemerkung: z.string().max(2000).optional(),
ist_wichtig: z.boolean().optional(),
fahrzeug_id: uuidString.optional(),
standort: z.string().min(1).max(150).optional(),
pruef_intervall_monate: z.number().int().min(1).optional(),
letzte_pruefung_am: isoDate.optional(),
naechste_pruefung_am: isoDate.optional(),
bemerkung: z.string().max(2000).optional(),
});
const UpdateAusruestungSchema = z.object({
bezeichnung: z.string().min(1).max(200).optional(),
kategorie_id: uuidString.optional(),
seriennummer: z.string().max(100).nullable().optional(),
inventarnummer: z.string().max(50).nullable().optional(),
hersteller: z.string().max(150).nullable().optional(),
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
status: AusruestungStatusEnum.optional(),
status_bemerkung: z.string().max(2000).nullable().optional(),
ist_wichtig: z.boolean().optional(),
fahrzeug_id: uuidString.nullable().optional(),
standort: z.string().min(1).max(150).optional(),
pruef_intervall_monate: z.number().int().min(1).nullable().optional(),
letzte_pruefung_am: isoDate.nullable().optional(),
naechste_pruefung_am: isoDate.nullable().optional(),
bemerkung: z.string().max(2000).nullable().optional(),
});
const UpdateStatusSchema = z.object({
status: AusruestungStatusEnum,
bemerkung: z.string().max(2000).optional().default(''),
});
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']),
beschreibung: z.string().min(1).max(2000),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).optional(),
kosten: z.number().min(0).optional(),
pruefende_stelle: z.string().max(150).optional(),
dokument_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
return req.user!.id;
}
// ── Controller ────────────────────────────────────────────────────────────────
class EquipmentController {
async listEquipment(_req: Request, res: Response): Promise<void> {
try {
const equipment = await equipmentService.getAllEquipment();
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('listEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
}
}
async getEquipment(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 Ausrüstungs-ID' });
return;
}
const equipment = await equipmentService.getEquipmentById(id);
if (!equipment) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('getEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
}
}
async getByVehicle(req: Request, res: Response): Promise<void> {
try {
const { fahrzeugId } = req.params as Record<string, string>;
if (!isValidUUID(fahrzeugId)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const equipment = await equipmentService.getEquipmentByVehicle(fahrzeugId);
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('getByVehicle error', { error, fahrzeugId: req.params.fahrzeugId });
res.status(500).json({ success: false, message: 'Ausrüstung für Fahrzeug konnte nicht geladen werden' });
}
}
async getCategories(_req: Request, res: Response): Promise<void> {
try {
const categories = await equipmentService.getCategories();
res.status(200).json({ success: true, data: categories });
} catch (error) {
logger.error('getCategories error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
async getStats(_req: Request, res: Response): Promise<void> {
try {
const stats = await equipmentService.getEquipmentStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
}
}
async getAlerts(req: Request, res: Response): Promise<void> {
try {
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 equipmentService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts });
} catch (error) {
logger.error('getAlerts error', { error });
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
}
}
async getVehicleWarnings(_req: Request, res: Response): Promise<void> {
try {
const warnings = await equipmentService.getVehicleWarnings();
res.status(200).json({ success: true, data: warnings });
} catch (error) {
logger.error('getVehicleWarnings error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Warnungen konnten nicht geladen werden' });
}
}
async createEquipment(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateAusruestungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: equipment });
} catch (error) {
logger.error('createEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht erstellt werden' });
}
}
async updateEquipment(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 Ausrüstungs-ID' });
return;
}
const parsed = UpdateAusruestungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
if (!equipment) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: equipment });
} catch (error: any) {
if (error?.message === 'No fields to update') {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
logger.error('updateEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht aktualisiert werden' });
}
}
async updateStatus(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 Ausrüstungs-ID' });
return;
}
const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await equipmentService.updateStatus(
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
);
res.status(200).json({ success: true, message: 'Status aktualisiert' });
} catch (error: any) {
if (error?.message === 'Equipment not found') {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
logger.error('updateStatus error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
async deleteEquipment(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 Ausrüstungs-ID' });
return;
}
const deleted = await equipmentService.deleteEquipment(id, getUserId(req));
if (!deleted) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Ausrüstung gelöscht' });
} catch (error) {
logger.error('deleteEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht gelöscht werden' });
}
}
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 Ausrüstungs-ID' });
return;
}
const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Equipment not found') {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
}
}
}
export default new EquipmentController();

View File

@@ -0,0 +1,143 @@
-- Migration 011: Ausrüstungsverwaltung (Equipment Management)
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
-- 005_create_fahrzeuge.sql (fahrzeuge table)
-- 009_vehicle_soft_delete.sql (soft-delete pattern)
-- ============================================================
-- TABLE: ausruestung_kategorien (Equipment Categories — lookup)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung_kategorien (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE, -- e.g. 'Atemschutzgeräte'
kurzname VARCHAR(30) NOT NULL UNIQUE, -- e.g. 'PA'
sortierung INTEGER NOT NULL DEFAULT 0, -- display order
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- TABLE: ausruestung (Core Equipment Inventory)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bezeichnung VARCHAR(200) NOT NULL, -- e.g. 'Dräger PSS 5000'
kategorie_id UUID NOT NULL REFERENCES ausruestung_kategorien(id),
seriennummer VARCHAR(100), -- manufacturer serial
inventarnummer VARCHAR(50), -- internal inventory number
hersteller VARCHAR(150), -- manufacturer
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
status VARCHAR(30) NOT NULL DEFAULT 'einsatzbereit'
CHECK (status IN (
'einsatzbereit',
'beschaedigt',
'in_wartung',
'ausser_dienst'
)),
status_bemerkung TEXT, -- free-text status note
ist_wichtig BOOLEAN NOT NULL DEFAULT FALSE, -- drives vehicle card warnings
fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL, -- nullable
standort VARCHAR(150) NOT NULL DEFAULT 'Lager', -- used when no fahrzeug
pruef_intervall_monate INTEGER CHECK (pruef_intervall_monate > 0), -- nullable
letzte_pruefung_am DATE,
naechste_pruefung_am DATE,
bemerkung TEXT, -- general notes
deleted_at TIMESTAMPTZ, -- soft-delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ausruestung_status
ON ausruestung(status);
CREATE INDEX IF NOT EXISTS idx_ausruestung_kategorie
ON ausruestung(kategorie_id);
CREATE INDEX IF NOT EXISTS idx_ausruestung_fahrzeug
ON ausruestung(fahrzeug_id)
WHERE fahrzeug_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_active
ON ausruestung(id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_pruefung
ON ausruestung(naechste_pruefung_am)
WHERE naechste_pruefung_am IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_wichtig
ON ausruestung(fahrzeug_id, status)
WHERE ist_wichtig = TRUE AND deleted_at IS NULL;
-- Auto-update updated_at (reuses function from migration 001)
CREATE TRIGGER update_ausruestung_updated_at
BEFORE UPDATE ON ausruestung
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- TABLE: ausruestung_wartungslog (Service/Inspection History)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung_wartungslog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
datum DATE NOT NULL,
art VARCHAR(30) NOT NULL
CHECK (art IN (
'Prüfung',
'Reparatur',
'Sonstiges'
)),
beschreibung TEXT NOT NULL,
ergebnis VARCHAR(30)
CHECK (ergebnis IS NULL OR ergebnis IN (
'bestanden',
'bestanden_mit_maengeln',
'nicht_bestanden'
)),
kosten DECIMAL(8,2) CHECK (kosten >= 0),
pruefende_stelle VARCHAR(150), -- e.g. 'Atemschutzwerkstatt Bezirk'
dokument_url VARCHAR(500), -- link to scan/PDF
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_item
ON ausruestung_wartungslog(ausruestung_id);
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_datum
ON ausruestung_wartungslog(datum DESC);
-- ============================================================
-- VIEW: ausruestung_mit_pruefstatus
-- For each active equipment item, joins category and vehicle
-- and computes pruefung_tage_bis_faelligkeit (negative = overdue).
-- The dashboard equipment panel and fleet overview query this view.
-- ============================================================
CREATE OR REPLACE VIEW ausruestung_mit_pruefstatus AS
SELECT
a.*,
k.name AS kategorie_name,
k.kurzname AS kategorie_kurzname,
f.bezeichnung AS fahrzeug_bezeichnung,
f.kurzname AS fahrzeug_kurzname,
CASE
WHEN a.naechste_pruefung_am IS NOT NULL
THEN a.naechste_pruefung_am::date - CURRENT_DATE
ELSE NULL
END AS pruefung_tage_bis_faelligkeit
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL
WHERE a.deleted_at IS NULL;
-- ============================================================
-- SEED DATA: Equipment Categories
-- ============================================================
INSERT INTO ausruestung_kategorien (name, kurzname, sortierung) VALUES
('Atemschutzgeräte', 'PA', 1),
('Pumpen', 'Pumpe', 2),
('Schläuche', 'SL', 3),
('Leitern', 'Leiter', 4),
('Rettungsgeräte', 'RG', 5),
('Messgeräte', 'MG', 6),
('Persönliche Schutzausrüstung', 'PSA', 7),
('Kommunikation', 'Funk', 8),
('Beleuchtung', 'Licht', 9),
('Sonstige', 'Sonst.', 10)
ON CONFLICT (name) DO NOTHING;

View File

@@ -0,0 +1,175 @@
// =============================================================================
// Equipment Management — Domain Model
// =============================================================================
// ── Enums ─────────────────────────────────────────────────────────────────────
export enum AusruestungStatus {
Einsatzbereit = 'einsatzbereit',
Beschaedigt = 'beschaedigt',
InWartung = 'in_wartung',
AusserDienst = 'ausser_dienst',
}
export const AusruestungStatusLabel: Record<AusruestungStatus, string> = {
[AusruestungStatus.Einsatzbereit]: 'Einsatzbereit',
[AusruestungStatus.Beschaedigt]: 'Beschädigt',
[AusruestungStatus.InWartung]: 'In Wartung',
[AusruestungStatus.AusserDienst]: 'Außer Dienst',
};
export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges';
// ── Lookup Entity ─────────────────────────────────────────────────────────────
export interface AusruestungKategorie {
id: string;
name: string;
kurzname: string;
sortierung: number;
}
// ── Core Entity ───────────────────────────────────────────────────────────────
export interface Ausruestung {
id: string;
bezeichnung: string;
kategorie_id: string;
seriennummer: string | null;
inventarnummer: string | null;
hersteller: string | null;
baujahr: number | null;
status: AusruestungStatus;
status_bemerkung: string | null;
ist_wichtig: boolean;
fahrzeug_id: string | null;
standort: string;
pruef_intervall_monate: number | null;
letzte_pruefung_am: Date | null;
naechste_pruefung_am: Date | null;
bemerkung: string | null;
deleted_at: Date | null;
created_at: Date;
updated_at: Date;
}
// ── List Item (Grid / Card view) ──────────────────────────────────────────────
export interface AusruestungListItem {
id: string;
bezeichnung: string;
kategorie_id: string;
seriennummer: string | null;
inventarnummer: string | null;
hersteller: string | null;
baujahr: number | null;
status: AusruestungStatus;
status_bemerkung: string | null;
ist_wichtig: boolean;
fahrzeug_id: string | null;
standort: string;
pruef_intervall_monate: number | null;
letzte_pruefung_am: Date | null;
naechste_pruefung_am: Date | null;
bemerkung: string | null;
created_at: Date;
updated_at: Date;
kategorie_name: string;
kategorie_kurzname: string;
fahrzeug_bezeichnung: string | null;
fahrzeug_kurzname: string | null;
pruefung_tage_bis_faelligkeit: number | null;
}
// ── Detail View ───────────────────────────────────────────────────────────────
export interface AusruestungDetail extends AusruestungListItem {
wartungslog: AusruestungWartungslog[];
}
// ── Wartungslog Entity ────────────────────────────────────────────────────────
export interface AusruestungWartungslog {
id: string;
ausruestung_id: string;
datum: Date;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis: string | null;
kosten: number | null;
pruefende_stelle: string | null;
dokument_url: string | null;
erfasst_von: string | null;
created_at: Date;
}
// ── Dashboard KPI ─────────────────────────────────────────────────────────────
export interface EquipmentStats {
total: number;
einsatzbereit: number;
beschaedigt: number;
inWartung: number;
ausserDienst: number;
inspectionsDue: number;
inspectionsOverdue: number;
wichtigNichtBereit: number;
}
// ── Vehicle Equipment Warning ─────────────────────────────────────────────────
export interface VehicleEquipmentWarning {
fahrzeug_id: string;
ausruestung_id: string;
bezeichnung: string;
status: AusruestungStatus;
kategorie_name: string;
}
// ── Create / Update DTOs ──────────────────────────────────────────────────────
export interface CreateAusruestungData {
bezeichnung: string;
kategorie_id: string;
seriennummer?: string;
inventarnummer?: string;
hersteller?: string;
baujahr?: number;
status?: AusruestungStatus;
status_bemerkung?: string;
ist_wichtig?: boolean;
fahrzeug_id?: string;
standort?: string;
pruef_intervall_monate?: number;
letzte_pruefung_am?: string;
naechste_pruefung_am?: string;
bemerkung?: string;
}
export interface UpdateAusruestungData {
bezeichnung?: string;
kategorie_id?: string;
seriennummer?: string | null;
inventarnummer?: string | null;
hersteller?: string | null;
baujahr?: number | null;
status?: AusruestungStatus;
status_bemerkung?: string | null;
ist_wichtig?: boolean;
fahrzeug_id?: string | null;
standort?: string;
pruef_intervall_monate?: number | null;
letzte_pruefung_am?: string | null;
naechste_pruefung_am?: string | null;
bemerkung?: string | null;
}
export interface CreateAusruestungWartungslogData {
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis?: string;
kosten?: number;
pruefende_stelle?: string;
dokument_url?: string;
}

View File

@@ -0,0 +1,32 @@
import { Router } from 'express';
import equipmentController from '../controllers/equipment.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
const router = Router();
// ── Read-only (any authenticated user) ───────────────────────────────────────
router.get('/', authenticate, equipmentController.listEquipment.bind(equipmentController));
router.get('/stats', authenticate, equipmentController.getStats.bind(equipmentController));
router.get('/alerts', authenticate, equipmentController.getAlerts.bind(equipmentController));
router.get('/categories', authenticate, equipmentController.getCategories.bind(equipmentController));
router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleWarnings.bind(equipmentController));
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
// ── Write — admin + fahrmeister ──────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), equipmentController.createEquipment.bind(equipmentController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateStatus.bind(equipmentController));
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), equipmentController.addWartung.bind(equipmentController));
// ── Delete — admin only ──────────────────────────────────────────────────────
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), equipmentController.deleteEquipment.bind(equipmentController));
export default router;

View File

@@ -0,0 +1,414 @@
import pool from '../config/database';
import logger from '../utils/logger';
import {
Ausruestung,
AusruestungKategorie,
AusruestungListItem,
AusruestungDetail,
AusruestungWartungslog,
CreateAusruestungData,
UpdateAusruestungData,
CreateAusruestungWartungslogData,
AusruestungStatus,
EquipmentStats,
VehicleEquipmentWarning,
} from '../models/equipment.model';
class EquipmentService {
// =========================================================================
// EQUIPMENT OVERVIEW
// =========================================================================
async getAllEquipment(): Promise<AusruestungListItem[]> {
try {
const result = await pool.query(`
SELECT *
FROM ausruestung_mit_pruefstatus
ORDER BY kategorie_kurzname, bezeichnung
`);
return result.rows.map((row) => ({
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
})) as AusruestungListItem[];
} catch (error) {
logger.error('EquipmentService.getAllEquipment failed', { error });
throw new Error('Failed to fetch equipment');
}
}
// =========================================================================
// EQUIPMENT DETAIL
// =========================================================================
async getEquipmentById(id: string): Promise<AusruestungDetail | null> {
try {
const equipmentResult = await pool.query(
`SELECT * FROM ausruestung_mit_pruefstatus WHERE id = $1`,
[id]
);
if (equipmentResult.rows.length === 0) return null;
const row = equipmentResult.rows[0];
const wartungslogResult = await pool.query(
`SELECT * FROM ausruestung_wartungslog
WHERE ausruestung_id = $1
ORDER BY datum DESC, created_at DESC`,
[id]
);
const equipment: AusruestungDetail = {
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
wartungslog: wartungslogResult.rows.map(r => ({
...r,
kosten: r.kosten != null ? Number(r.kosten) : null,
})) as AusruestungWartungslog[],
};
return equipment;
} catch (error) {
logger.error('EquipmentService.getEquipmentById failed', { error, id });
throw new Error('Failed to fetch equipment');
}
}
// =========================================================================
// EQUIPMENT BY VEHICLE
// =========================================================================
async getEquipmentByVehicle(fahrzeugId: string): Promise<AusruestungListItem[]> {
try {
const result = await pool.query(
`SELECT *
FROM ausruestung_mit_pruefstatus
WHERE fahrzeug_id = $1
ORDER BY kategorie_kurzname, bezeichnung`,
[fahrzeugId]
);
return result.rows.map((row) => ({
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
})) as AusruestungListItem[];
} catch (error) {
logger.error('EquipmentService.getEquipmentByVehicle failed', { error, fahrzeugId });
throw new Error('Failed to fetch equipment for vehicle');
}
}
// =========================================================================
// CATEGORIES
// =========================================================================
async getCategories(): Promise<AusruestungKategorie[]> {
try {
const result = await pool.query(
`SELECT * FROM ausruestung_kategorien ORDER BY sortierung`
);
return result.rows as AusruestungKategorie[];
} catch (error) {
logger.error('EquipmentService.getCategories failed', { error });
throw new Error('Failed to fetch equipment categories');
}
}
// =========================================================================
// CRUD
// =========================================================================
async createEquipment(data: CreateAusruestungData, createdBy: string): Promise<Ausruestung> {
try {
const result = await pool.query(
`INSERT INTO ausruestung (
id, bezeichnung, kategorie_id, seriennummer, inventarnummer,
hersteller, baujahr, status, status_bemerkung, ist_wichtig,
fahrzeug_id, standort, pruef_intervall_monate,
letzte_pruefung_am, naechste_pruefung_am, bemerkung
) VALUES (uuid_generate_v4(),$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING *`,
[
data.bezeichnung,
data.kategorie_id,
data.seriennummer ?? null,
data.inventarnummer ?? null,
data.hersteller ?? null,
data.baujahr ?? null,
data.status ?? AusruestungStatus.Einsatzbereit,
data.status_bemerkung ?? null,
data.ist_wichtig ?? false,
data.fahrzeug_id ?? null,
data.standort ?? 'Lager',
data.pruef_intervall_monate ?? null,
data.letzte_pruefung_am ?? null,
data.naechste_pruefung_am ?? null,
data.bemerkung ?? null,
]
);
const equipment = result.rows[0] as Ausruestung;
logger.info('Equipment created', { id: equipment.id, by: createdBy });
return equipment;
} catch (error) {
logger.error('EquipmentService.createEquipment failed', { error, createdBy });
throw new Error('Failed to create equipment');
}
}
async updateEquipment(id: string, data: UpdateAusruestungData, updatedBy: string): Promise<Ausruestung | null> {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const addField = (col: string, value: unknown) => {
fields.push(`${col} = $${p++}`);
values.push(value);
};
if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung);
if (data.kategorie_id !== undefined) addField('kategorie_id', data.kategorie_id);
if (data.seriennummer !== undefined) addField('seriennummer', data.seriennummer);
if (data.inventarnummer !== undefined) addField('inventarnummer', data.inventarnummer);
if (data.hersteller !== undefined) addField('hersteller', data.hersteller);
if (data.baujahr !== undefined) addField('baujahr', data.baujahr);
if (data.status !== undefined) addField('status', data.status);
if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung);
if (data.ist_wichtig !== undefined) addField('ist_wichtig', data.ist_wichtig);
if (data.fahrzeug_id !== undefined) addField('fahrzeug_id', data.fahrzeug_id);
if (data.standort !== undefined) addField('standort', data.standort);
if (data.pruef_intervall_monate !== undefined) addField('pruef_intervall_monate', data.pruef_intervall_monate);
if (data.letzte_pruefung_am !== undefined) addField('letzte_pruefung_am', data.letzte_pruefung_am);
if (data.naechste_pruefung_am !== undefined) addField('naechste_pruefung_am', data.naechste_pruefung_am);
if (data.bemerkung !== undefined) addField('bemerkung', data.bemerkung);
if (fields.length === 0) {
throw new Error('No fields to update');
}
values.push(id);
const result = await pool.query(
`UPDATE ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`,
values
);
if (result.rows.length === 0) {
return null;
}
const equipment = result.rows[0] as Ausruestung;
logger.info('Equipment updated', { id, by: updatedBy });
return equipment;
} catch (error) {
logger.error('EquipmentService.updateEquipment failed', { error, id, updatedBy });
throw error;
}
}
async deleteEquipment(id: string, deletedBy: string): Promise<boolean> {
try {
const result = await pool.query(
`UPDATE ausruestung
SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL
RETURNING id`,
[id]
);
if (result.rows.length === 0) {
return false;
}
logger.info('Equipment soft-deleted', { id, by: deletedBy });
return true;
} catch (error) {
logger.error('EquipmentService.deleteEquipment failed', { error, id });
throw error;
}
}
// =========================================================================
// STATUS MANAGEMENT
// =========================================================================
async updateStatus(
id: string,
status: AusruestungStatus,
bemerkung: string,
updatedBy: string
): Promise<void> {
try {
const result = await pool.query(
`UPDATE ausruestung
SET status = $1, status_bemerkung = $2, updated_at = NOW()
WHERE id = $3 AND deleted_at IS NULL
RETURNING id`,
[status, bemerkung || null, id]
);
if (result.rows.length === 0) {
throw new Error('Equipment not found');
}
logger.info('Equipment status updated', { id, status, by: updatedBy });
} catch (error) {
logger.error('EquipmentService.updateStatus failed', { error, id });
throw error;
}
}
// =========================================================================
// MAINTENANCE LOG
// =========================================================================
async addWartungslog(
equipmentId: string,
data: CreateAusruestungWartungslogData,
createdBy: string
): Promise<AusruestungWartungslog> {
try {
const check = await pool.query(
`SELECT 1 FROM ausruestung WHERE id = $1 AND deleted_at IS NULL`,
[equipmentId]
);
if (check.rows.length === 0) {
throw new Error('Equipment not found');
}
const result = await pool.query(
`INSERT INTO ausruestung_wartungslog (
ausruestung_id, datum, art, beschreibung,
ergebnis, kosten, pruefende_stelle, dokument_url, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`,
[
equipmentId,
data.datum,
data.art,
data.beschreibung,
data.ergebnis ?? null,
data.kosten ?? null,
data.pruefende_stelle ?? null,
data.dokument_url ?? null,
createdBy,
]
);
const entry = result.rows[0] as AusruestungWartungslog;
logger.info('Equipment wartungslog entry added', { entryId: entry.id, equipmentId, by: createdBy });
return entry;
} catch (error) {
logger.error('EquipmentService.addWartungslog failed', { error, equipmentId });
throw error;
}
}
// =========================================================================
// DASHBOARD KPI
// =========================================================================
async getEquipmentStats(): Promise<EquipmentStats> {
try {
const result = await pool.query(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit,
COUNT(*) FILTER (WHERE status = 'beschaedigt') AS beschaedigt,
COUNT(*) FILTER (WHERE status = 'in_wartung') AS in_wartung,
COUNT(*) FILTER (WHERE status = 'ausser_dienst') AS ausser_dienst,
COUNT(*) FILTER (
WHERE naechste_pruefung_am IS NOT NULL
AND naechste_pruefung_am::date - CURRENT_DATE BETWEEN 0 AND 30
) AS inspections_due,
COUNT(*) FILTER (
WHERE naechste_pruefung_am IS NOT NULL
AND naechste_pruefung_am::date < CURRENT_DATE
) AS inspections_overdue,
COUNT(*) FILTER (
WHERE ist_wichtig = TRUE
AND status != 'einsatzbereit'
) AS wichtig_nicht_bereit
FROM ausruestung
WHERE deleted_at IS NULL
`);
const row = result.rows[0] ?? {};
return {
total: parseInt(row.total ?? '0', 10),
einsatzbereit: parseInt(row.einsatzbereit ?? '0', 10),
beschaedigt: parseInt(row.beschaedigt ?? '0', 10),
inWartung: parseInt(row.in_wartung ?? '0', 10),
ausserDienst: parseInt(row.ausser_dienst ?? '0', 10),
inspectionsDue: parseInt(row.inspections_due ?? '0', 10),
inspectionsOverdue: parseInt(row.inspections_overdue ?? '0', 10),
wichtigNichtBereit: parseInt(row.wichtig_nicht_bereit ?? '0', 10),
};
} catch (error) {
logger.error('EquipmentService.getEquipmentStats failed', { error });
throw new Error('Failed to fetch equipment stats');
}
}
// =========================================================================
// VEHICLE WARNINGS
// =========================================================================
async getVehicleWarnings(): Promise<VehicleEquipmentWarning[]> {
try {
const result = await pool.query(`
SELECT
a.fahrzeug_id,
a.id AS ausruestung_id,
a.bezeichnung,
a.status,
k.name AS kategorie_name
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
WHERE a.ist_wichtig = TRUE
AND a.fahrzeug_id IS NOT NULL
AND a.status != 'einsatzbereit'
AND a.deleted_at IS NULL
ORDER BY a.fahrzeug_id
`);
return result.rows as VehicleEquipmentWarning[];
} catch (error) {
logger.error('EquipmentService.getVehicleWarnings failed', { error });
throw new Error('Failed to fetch vehicle equipment warnings');
}
}
// =========================================================================
// UPCOMING INSPECTIONS
// =========================================================================
async getUpcomingInspections(daysAhead: number = 30): Promise<AusruestungListItem[]> {
try {
const result = await pool.query(
`SELECT *
FROM ausruestung_mit_pruefstatus
WHERE pruefung_tage_bis_faelligkeit IS NOT NULL
AND pruefung_tage_bis_faelligkeit <= $1
ORDER BY pruefung_tage_bis_faelligkeit ASC`,
[daysAhead]
);
return result.rows.map((row) => ({
...row,
pruefung_tage_bis_faelligkeit: row.pruefung_tage_bis_faelligkeit != null
? parseInt(row.pruefung_tage_bis_faelligkeit, 10) : null,
})) as AusruestungListItem[];
} catch (error) {
logger.error('EquipmentService.getUpcomingInspections failed', { error, daysAhead });
throw new Error('Failed to fetch upcoming inspections');
}
}
}
export default new EquipmentService();