Files
dashboard/backend/src/services/vehicle.service.ts
Matthias Hochmeister 690f260b71 new features
2026-03-23 16:47:36 +01:00

693 lines
25 KiB
TypeScript

import pool from '../config/database';
import logger from '../utils/logger';
import {
Fahrzeug,
FahrzeugListItem,
FahrzeugDetail,
FahrzeugWartungslog,
CreateFahrzeugData,
UpdateFahrzeugData,
CreateWartungslogData,
UpdateWartungslogData,
FahrzeugStatus,
VehicleStats,
InspectionAlert,
OverlappingBooking,
} from '../models/vehicle.model';
class VehicleService {
// =========================================================================
// FLEET OVERVIEW
// =========================================================================
async getAllVehicles(): Promise<FahrzeugListItem[]> {
try {
const result = await pool.query(`
SELECT
id, bezeichnung, kurzname, amtliches_kennzeichen,
baujahr, hersteller, besatzung_soll, status, status_bemerkung,
ausser_dienst_von, ausser_dienst_bis,
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
`);
// Fetch active lehrgang bookings for all vehicles in one query
const lehrgangs = await pool.query(`
SELECT DISTINCT ON (b.fahrzeug_id)
b.fahrzeug_id, b.titel, b.beginn, b.ende
FROM fahrzeug_buchungen b
WHERE b.buchungs_art = 'lehrgang'
AND b.abgesagt = FALSE
AND b.beginn <= NOW()
AND b.ende >= NOW()
ORDER BY b.fahrzeug_id, b.beginn ASC
`);
const lehrgangsMap = new Map<string, { titel: string; beginn: Date; ende: Date }>();
for (const r of lehrgangs.rows) {
lehrgangsMap.set(r.fahrzeug_id, { titel: r.titel, beginn: new Date(r.beginn), ende: new Date(r.ende) });
}
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,
aktiver_lehrgang: lehrgangsMap.get(row.id) ?? 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<FahrzeugDetail | null> {
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,
ausser_dienst_von: row.ausser_dienst_von ?? null,
ausser_dienst_bis: row.ausser_dienst_bis ?? null,
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[],
};
// Fetch active lehrgang booking if any
const lehrgang = await pool.query(`
SELECT titel, beginn, ende
FROM fahrzeug_buchungen
WHERE fahrzeug_id = $1
AND buchungs_art = 'lehrgang'
AND abgesagt = FALSE
AND beginn <= NOW()
AND ende >= NOW()
ORDER BY beginn ASC
LIMIT 1
`, [id]);
vehicle.aktiver_lehrgang = lehrgang.rows.length > 0
? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende }
: null;
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<Fahrzeug> {
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<Fahrzeug> {
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<void> {
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,
ausserDienstVon?: Date | null,
ausserDienstBis?: Date | null,
): Promise<{ overlappingBookings: OverlappingBooking[] }> {
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];
const isAusserDienst = status === FahrzeugStatus.AusserDienstWartung || status === FahrzeugStatus.AusserDienstSchaden;
await client.query(
`UPDATE fahrzeuge
SET status = $1, status_bemerkung = $2,
ausser_dienst_von = $3, ausser_dienst_bis = $4
WHERE id = $5`,
[
status,
bemerkung || null,
isAusserDienst ? (ausserDienstVon ?? null) : null,
isAusserDienst ? (ausserDienstBis ?? null) : null,
id,
]
);
// Record status change history
if (oldStatus !== status) {
await client.query(
`INSERT INTO fahrzeug_status_historie (fahrzeug_id, alter_status, neuer_status, bemerkung, geaendert_von)
VALUES ($1, $2, $3, $4, $5)`,
[id, oldStatus, status, bemerkung || null, updatedBy]
);
}
await client.query('COMMIT');
logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy });
// Find bookings that overlap with the out-of-service period
let overlappingBookings: OverlappingBooking[] = [];
if (isAusserDienst && ausserDienstVon && ausserDienstBis) {
const overlap = await pool.query(
`SELECT b.id, b.titel, b.beginn, b.ende, u.name AS gebucht_von_name
FROM fahrzeug_buchungen b
JOIN users u ON u.id = b.gebucht_von
WHERE b.fahrzeug_id = $1
AND b.abgesagt = FALSE
AND ($2::timestamptz, $3::timestamptz) OVERLAPS (b.beginn, b.ende)`,
[id, ausserDienstVon, ausserDienstBis]
);
overlappingBookings = overlap.rows;
}
if (io) {
io.emit('vehicle:statusChanged', {
vehicleId: id,
bezeichnung,
oldStatus,
newStatus: status,
bemerkung: bemerkung || null,
ausserDienstVon: isAusserDienst ? ausserDienstVon ?? null : null,
ausserDienstBis: isAusserDienst ? ausserDienstBis ?? null : null,
updatedBy,
timestamp: new Date().toISOString(),
});
}
return { overlappingBookings };
} 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<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,
km_stand, kraftstoff_liter, kosten, externe_werkstatt,
ergebnis, naechste_faelligkeit, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
fahrzeugId,
data.datum,
data.art ?? null,
data.beschreibung,
data.km_stand ?? null,
data.kraftstoff_liter ?? null,
data.kosten ?? null,
data.externe_werkstatt ?? null,
data.ergebnis ?? null,
data.naechste_faelligkeit ?? null,
createdBy,
]
);
const entry = result.rows[0] as FahrzeugWartungslog;
// Auto-update next service date on the vehicle when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
const column = data.art === '§57a Prüfung' ? 'paragraph57a_faellig_am' : 'naechste_wartung_am';
await pool.query(
`UPDATE fahrzeuge SET ${column} = $1 WHERE id = $2`,
[data.naechste_faelligkeit, fahrzeugId]
);
}
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 updateWartungslog(
wartungId: string,
fahrzeugId: string,
data: UpdateWartungslogData,
updatedBy: string
): Promise<FahrzeugWartungslog> {
try {
const result = await pool.query(
`UPDATE fahrzeug_wartungslog
SET datum = $1, art = $2, beschreibung = $3, km_stand = $4,
externe_werkstatt = $5, ergebnis = $6, naechste_faelligkeit = $7
WHERE id = $8 AND fahrzeug_id = $9
RETURNING *`,
[
data.datum,
data.art ?? null,
data.beschreibung,
data.km_stand ?? null,
data.externe_werkstatt ?? null,
data.ergebnis ?? null,
data.naechste_faelligkeit ?? null,
wartungId,
fahrzeugId,
]
);
if (result.rows.length === 0) {
throw new Error('Wartungseintrag nicht gefunden');
}
const entry = result.rows[0] as FahrzeugWartungslog;
// Auto-update next service date on the vehicle when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
const column = data.art === '§57a Prüfung' ? 'paragraph57a_faellig_am' : 'naechste_wartung_am';
await pool.query(
`UPDATE fahrzeuge SET ${column} = $1 WHERE id = $2`,
[data.naechste_faelligkeit, fahrzeugId]
);
}
logger.info('Wartungslog entry updated', { wartungId, fahrzeugId, by: updatedBy });
return entry;
} catch (error) {
logger.error('VehicleService.updateWartungslog failed', { error, wartungId, fahrzeugId });
throw error;
}
}
async getWartungslogForVehicle(fahrzeugId: string): Promise<FahrzeugWartungslog[]> {
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');
}
}
// =========================================================================
// MAINTENANCE WINDOWS (for booking calendar overlay)
// =========================================================================
async getMaintenanceWindows(
from: Date,
to: Date
): Promise<
{
id: string;
bezeichnung: string;
kurzname: string | null;
status: string;
status_bemerkung: string | null;
ausser_dienst_von: string;
ausser_dienst_bis: string;
}[]
> {
try {
const result = await pool.query(
`SELECT id, bezeichnung, kurzname, status, status_bemerkung,
ausser_dienst_von, ausser_dienst_bis
FROM fahrzeuge
WHERE deleted_at IS NULL
AND status IN ('ausser_dienst_wartung', 'ausser_dienst_schaden')
AND ausser_dienst_von IS NOT NULL
AND ausser_dienst_bis IS NOT NULL
AND (ausser_dienst_von, ausser_dienst_bis) OVERLAPS ($1, $2)`,
[from, to]
);
return result.rows;
} catch (error) {
logger.error('VehicleService.getMaintenanceWindows failed', { error });
throw new Error('Failed to fetch maintenance windows');
}
}
// =========================================================================
// DASHBOARD KPI
// =========================================================================
async getVehicleStats(): Promise<VehicleStats> {
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
FROM fahrzeuge
WHERE deleted_at IS NULL
`);
// Count vehicles with an active lehrgang booking
const lehrgangsResult = await pool.query(`
SELECT COUNT(DISTINCT b.fahrzeug_id) AS in_lehrgang
FROM fahrzeug_buchungen b
WHERE b.buchungs_art = 'lehrgang'
AND b.abgesagt = FALSE
AND b.beginn <= NOW()
AND b.ende >= NOW()
`);
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' };
const lehrgangs = lehrgangsResult.rows[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(lehrgangs.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<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');
}
}
// =========================================================================
// STATUS HISTORY
// =========================================================================
async getStatusHistory(fahrzeugId: string) {
try {
const result = await pool.query(
`SELECT h.*, u.display_name AS geaendert_von_name
FROM fahrzeug_status_historie h
LEFT JOIN users u ON u.id = h.geaendert_von
WHERE h.fahrzeug_id = $1
ORDER BY h.erstellt_am DESC
LIMIT 50`,
[fahrzeugId]
);
return result.rows;
} catch (error) {
logger.error('VehicleService.getStatusHistory failed', { error, fahrzeugId });
throw new Error('Status-Historie konnte nicht geladen werden');
}
}
// =========================================================================
// WARTUNGSLOG FILE UPLOAD
// =========================================================================
async updateWartungslogFile(wartungId: number, filePath: string) {
try {
const result = await pool.query(
`UPDATE fahrzeug_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`,
[filePath, wartungId]
);
if (result.rows.length === 0) {
throw new Error('Wartungseintrag nicht gefunden');
}
return result.rows[0];
} catch (error) {
logger.error('VehicleService.updateWartungslogFile failed', { error, wartungId });
throw error;
}
}
}
export default new VehicleService();