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

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