import pool from '../config/database'; import logger from '../utils/logger'; import { Fahrzeug, FahrzeugListItem, FahrzeugWithPruefstatus, FahrzeugPruefung, 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 { 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 FROM fahrzeuge_mit_pruefstatus ORDER BY bezeichnung ASC `); return result.rows.map((row) => ({ ...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 ? 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 ? parseInt(row.naechste_pruefung_tage, 10) : null, })) as FahrzeugListItem[]; } catch (error) { logger.error('VehicleService.getAllVehicles failed', { error }); throw new Error('Failed to fetch vehicles'); } } // ========================================================================= // VEHICLE DETAIL // ========================================================================= /** * Returns a single vehicle with full pruefstatus, inspection history, * and maintenance log. */ async getVehicleById(id: string): Promise { try { // 1) Main record + inspection status from view const vehicleResult = await pool.query( `SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`, [id] ); if (vehicleResult.rows.length === 0) return null; 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 ORDER BY datum DESC, created_at DESC`, [id] ); const vehicle: FahrzeugWithPruefstatus = { id: row.id, bezeichnung: row.bezeichnung, kurzname: row.kurzname, amtliches_kennzeichen: row.amtliches_kennzeichen, fahrgestellnummer: row.fahrgestellnummer, baujahr: row.baujahr, hersteller: row.hersteller, typ_schluessel: row.typ_schluessel, besatzung_soll: row.besatzung_soll, status: row.status as FahrzeugStatus, status_bemerkung: row.status_bemerkung, standort: row.standort, bild_url: row.bild_url, paragraph57a_faellig_am: row.paragraph57a_faellig_am ?? null, 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[], }; return vehicle; } catch (error) { logger.error('VehicleService.getVehicleById failed', { error, id }); throw new Error('Failed to fetch vehicle'); } } // ========================================================================= // CRUD // ========================================================================= async createVehicle( data: CreateFahrzeugData, createdBy: string ): Promise { try { const result = await pool.query( `INSERT INTO fahrzeuge ( bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer, baujahr, hersteller, typ_schluessel, besatzung_soll, status, status_bemerkung, standort, bild_url, paragraph57a_faellig_am, naechste_wartung_am ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [ data.bezeichnung, data.kurzname ?? null, data.amtliches_kennzeichen ?? null, data.fahrgestellnummer ?? null, data.baujahr ?? null, data.hersteller ?? null, data.typ_schluessel ?? null, data.besatzung_soll ?? null, data.status ?? FahrzeugStatus.Einsatzbereit, data.status_bemerkung ?? null, data.standort ?? 'Feuerwehrhaus', data.bild_url ?? null, data.paragraph57a_faellig_am ?? null, data.naechste_wartung_am ?? null, ] ); const vehicle = result.rows[0] as Fahrzeug; logger.info('Vehicle created', { id: vehicle.id, by: createdBy }); return vehicle; } catch (error) { logger.error('VehicleService.createVehicle failed', { error, createdBy }); throw new Error('Failed to create vehicle'); } } async updateVehicle( id: string, data: UpdateFahrzeugData, updatedBy: string ): Promise { 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.kurzname !== undefined) addField('kurzname', data.kurzname); if (data.amtliches_kennzeichen !== undefined) addField('amtliches_kennzeichen', data.amtliches_kennzeichen); if (data.fahrgestellnummer !== undefined) addField('fahrgestellnummer', data.fahrgestellnummer); if (data.baujahr !== undefined) addField('baujahr', data.baujahr); if (data.hersteller !== undefined) addField('hersteller', data.hersteller); if (data.typ_schluessel !== undefined) addField('typ_schluessel', data.typ_schluessel); if (data.besatzung_soll !== undefined) addField('besatzung_soll', data.besatzung_soll); if (data.status !== undefined) addField('status', data.status); if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung); if (data.standort !== undefined) addField('standort', data.standort); if (data.bild_url !== undefined) addField('bild_url', data.bild_url); if (data.paragraph57a_faellig_am !== undefined) addField('paragraph57a_faellig_am', data.paragraph57a_faellig_am); if (data.naechste_wartung_am !== undefined) addField('naechste_wartung_am', data.naechste_wartung_am); if (fields.length === 0) { throw new Error('No fields to update'); } values.push(id); // for WHERE clause const result = await pool.query( `UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`, values ); if (result.rows.length === 0) { throw new Error('Vehicle not found'); } const vehicle = result.rows[0] as Fahrzeug; logger.info('Vehicle updated', { id, by: updatedBy }); return vehicle; } catch (error) { logger.error('VehicleService.updateVehicle failed', { error, id, updatedBy }); throw error; } } async deleteVehicle(id: string, deletedBy: string): Promise { try { const result = await pool.query( `DELETE FROM fahrzeuge WHERE id = $1 RETURNING id`, [id] ); if (result.rows.length === 0) { throw new Error('Vehicle not found'); } logger.info('Vehicle deleted', { id, by: deletedBy }); } catch (error) { logger.error('VehicleService.deleteVehicle failed', { error, id }); throw error; } } // ========================================================================= // 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, bemerkung: string, updatedBy: string, io?: any ): Promise { try { // Fetch old status for Socket.IO payload and logging const oldResult = await pool.query( `SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1`, [id] ); if (oldResult.rows.length === 0) { 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`, [status, bemerkung || null, id] ); 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 = { vehicleId: id, bezeichnung, oldStatus, newStatus: status, 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) { 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 { 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 { 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 { 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'); } } // ========================================================================= // MAINTENANCE LOG // ========================================================================= async addWartungslog( fahrzeugId: string, data: CreateWartungslogData, createdBy: string ): Promise { try { const result = await pool.query( `INSERT INTO fahrzeug_wartungslog ( fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten, externe_werkstatt, erfasst_von ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [ fahrzeugId, data.datum, data.art ?? null, data.beschreibung, data.km_stand ?? null, data.kraftstoff_liter ?? null, data.kosten ?? null, data.externe_werkstatt ?? null, createdBy, ] ); const entry = result.rows[0] as FahrzeugWartungslog; 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'); } } async getWartungslogForVehicle(fahrzeugId: string): Promise { try { const result = await pool.query( `SELECT * FROM fahrzeug_wartungslog WHERE fahrzeug_id = $1 ORDER BY datum DESC, created_at DESC`, [fahrzeugId] ); return result.rows.map(r => ({ ...r, kosten: r.kosten != null ? Number(r.kosten) : null, })) as FahrzeugWartungslog[]; } catch (error) { logger.error('VehicleService.getWartungslogForVehicle failed', { error, fahrzeugId }); throw new Error('Failed to fetch maintenance log'); } } // ========================================================================= // 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 { try { const result = await pool.query(` SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit, COUNT(*) FILTER ( WHERE status IN ('ausser_dienst_wartung','ausser_dienst_schaden') ) AS ausser_dienst, COUNT(*) FILTER (WHERE status = 'in_lehrgang') AS in_lehrgang FROM fahrzeuge `); const alertResult = await pool.query(` SELECT COUNT(DISTINCT fahrzeug_id) FILTER ( WHERE (faellig_am::date - CURRENT_DATE) BETWEEN 0 AND 30 ) AS inspections_due, COUNT(DISTINCT fahrzeug_id) FILTER ( WHERE faellig_am::date < CURRENT_DATE ) AS inspections_overdue FROM fahrzeug_pruefungen WHERE durchgefuehrt_am IS NULL `); const totals = result.rows[0]; const alerts = alertResult.rows[0]; return { total: parseInt(totals.total, 10), einsatzbereit: parseInt(totals.einsatzbereit, 10), ausserDienst: parseInt(totals.ausser_dienst, 10), inLehrgang: parseInt(totals.in_lehrgang, 10), inspectionsDue: parseInt(alerts.inspections_due, 10), inspectionsOverdue: parseInt(alerts.inspections_overdue, 10), }; } catch (error) { logger.error('VehicleService.getVehicleStats failed', { error }); throw new Error('Failed to fetch vehicle stats'); } } } export default new VehicleService();