import pool from '../config/database'; import logger from '../utils/logger'; import { Fahrzeug, FahrzeugListItem, FahrzeugDetail, FahrzeugWartungslog, CreateFahrzeugData, UpdateFahrzeugData, CreateWartungslogData, FahrzeugStatus, VehicleStats, InspectionAlert, } from '../models/vehicle.model'; class VehicleService { // ========================================================================= // FLEET OVERVIEW // ========================================================================= 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, 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, 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 // ========================================================================= async getVehicleById(id: string): Promise { try { 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]; const wartungslogResult = await pool.query( `SELECT * FROM fahrzeug_wartungslog WHERE fahrzeug_id = $1 ORDER BY datum DESC, created_at DESC`, [id] ); const vehicle: FahrzeugDetail = { 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, 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, wartungslog: wartungslogResult.rows.map(r => ({ ...r, kosten: r.kosten != null ? Number(r.kosten) : null, })) 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); const result = await pool.query( `UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL 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( `UPDATE fahrzeuge SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL RETURNING id`, [id] ); if (result.rows.length === 0) { throw new Error('Vehicle not found'); } logger.info('Vehicle soft-deleted', { id, by: deletedBy }); } catch (error) { logger.error('VehicleService.deleteVehicle failed', { error, id }); throw error; } } // ========================================================================= // STATUS MANAGEMENT // ========================================================================= async updateVehicleStatus( id: string, status: FahrzeugStatus, bemerkung: string, updatedBy: string, io?: any ): Promise { const client = await pool.connect(); try { 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 client.query( `UPDATE fahrzeuge SET status = $1, status_bemerkung = $2 WHERE id = $3`, [status, bemerkung || null, id] ); await client.query('COMMIT'); logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy }); if (io) { io.emit('vehicle:statusChanged', { vehicleId: id, bezeichnung, oldStatus, newStatus: status, bemerkung: bemerkung || null, updatedBy, timestamp: new Date().toISOString(), }); } } catch (error) { await client.query('ROLLBACK').catch(() => {}); logger.error('VehicleService.updateVehicleStatus failed', { error, id }); throw error; } finally { client.release(); } } // ========================================================================= // MAINTENANCE LOG // ========================================================================= async addWartungslog( fahrzeugId: string, data: CreateWartungslogData, createdBy: string ): Promise { 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, 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 error; } } 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 // ========================================================================= async getVehicleStats(): Promise { try { const totalsResult = 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 WHERE deleted_at IS NULL `); const alertResult = await pool.query(` SELECT 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(*) 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 fahrzeuge WHERE deleted_at IS NULL `); const totals = totalsResult.rows[0] ?? { total: '0', einsatzbereit: '0', ausser_dienst: '0', in_lehrgang: '0' }; const alerts = alertResult.rows[0] ?? { inspections_due: '0', inspections_overdue: '0' }; return { total: parseInt(totals.total ?? '0', 10), einsatzbereit: parseInt(totals.einsatzbereit ?? '0', 10), ausserDienst: parseInt(totals.ausser_dienst ?? '0', 10), inLehrgang: parseInt(totals.in_lehrgang ?? '0', 10), inspectionsDue: parseInt(alerts.inspections_due ?? '0', 10), inspectionsOverdue: parseInt(alerts.inspections_overdue ?? '0', 10), }; } catch (error) { logger.error('VehicleService.getVehicleStats failed', { error }); throw new Error('Failed to fetch vehicle stats'); } } async getUpcomingInspections(daysAhead: number): Promise { 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();