This commit is contained in:
Matthias Hochmeister
2026-03-13 19:23:39 +01:00
parent 02d9d808b2
commit bc6d09200a
15 changed files with 610 additions and 74 deletions

View File

@@ -141,6 +141,35 @@ class BookingService {
return rowToBuchung(rows[0]);
}
/**
* Checks whether a vehicle is out of service during the given interval.
* Returns the out-of-service period if there IS a conflict, null otherwise.
*/
async checkOutOfServiceConflict(
fahrzeugId: string,
beginn: Date,
ende: Date
): Promise<{ ausser_dienst_von: Date; ausser_dienst_bis: Date } | null> {
const query = `
SELECT ausser_dienst_von, ausser_dienst_bis
FROM fahrzeuge
WHERE id = $1
AND 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 ($2::timestamptz, $3::timestamptz) OVERLAPS (ausser_dienst_von, ausser_dienst_bis)
LIMIT 1
`;
const { rows } = await pool.query(query, [fahrzeugId, beginn, ende]);
if (rows.length === 0) return null;
return {
ausser_dienst_von: new Date(rows[0].ausser_dienst_von),
ausser_dienst_bis: new Date(rows[0].ausser_dienst_bis),
};
}
/**
* Checks whether a vehicle is already booked for the given interval.
* Returns true if there IS a conflict.
@@ -171,8 +200,13 @@ class BookingService {
return rows.length > 0;
}
/** Creates a new booking. Throws if the vehicle has a conflicting booking. */
/** Creates a new booking. Throws if the vehicle has a conflicting booking or is out of service. */
async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> {
const outOfService = await this.checkOutOfServiceConflict(data.fahrzeugId, data.beginn, data.ende);
if (outOfService) {
throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst');
}
const hasConflict = await this.checkConflict(
data.fahrzeugId,
data.beginn,
@@ -225,6 +259,15 @@ class BookingService {
data.fahrzeugId != null || data.beginn != null || data.ende != null;
if (timingChanged) {
const outOfService = await this.checkOutOfServiceConflict(
effectiveFahrzeugId,
effectiveBeginn,
effectiveEnde
);
if (outOfService) {
throw new Error('Fahrzeug ist im gewählten Zeitraum außer Dienst');
}
const hasConflict = await this.checkConflict(
effectiveFahrzeugId,
effectiveBeginn,

View File

@@ -11,6 +11,7 @@ import {
FahrzeugStatus,
VehicleStats,
InspectionAlert,
OverlappingBooking,
} from '../models/vehicle.model';
class VehicleService {
@@ -24,12 +25,30 @@ class VehicleService {
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
@@ -38,6 +57,7 @@ class VehicleService {
? 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 });
@@ -79,6 +99,8 @@ class VehicleService {
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,
@@ -97,6 +119,23 @@ class VehicleService {
})) 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 });
@@ -224,8 +263,10 @@ class VehicleService {
status: FahrzeugStatus,
bemerkung: string,
updatedBy: string,
io?: any
): Promise<void> {
io?: any,
ausserDienstVon?: Date | null,
ausserDienstBis?: Date | null,
): Promise<{ overlappingBookings: OverlappingBooking[] }> {
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -242,26 +283,56 @@ class VehicleService {
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 WHERE id = $3`,
[status, bemerkung || null, id]
`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,
]
);
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,
vehicleId: id,
bezeichnung,
oldStatus,
newStatus: status,
bemerkung: bemerkung || null,
newStatus: status,
bemerkung: bemerkung || null,
ausserDienstVon: isAusserDienst ? ausserDienstVon ?? null : null,
ausserDienstBis: isAusserDienst ? ausserDienstBis ?? null : null,
updatedBy,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
});
}
return { overlappingBookings };
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
logger.error('VehicleService.updateVehicleStatus failed', { error, id });
@@ -347,12 +418,21 @@ class VehicleService {
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
) 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 (
@@ -373,14 +453,15 @@ class VehicleService {
WHERE deleted_at IS NULL
`);
const totals = totalsResult.rows[0] ?? { total: '0', einsatzbereit: '0', ausser_dienst: '0', in_lehrgang: '0' };
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(totals.in_lehrgang ?? '0', 10),
inLehrgang: parseInt(lehrgangs.in_lehrgang ?? '0', 10),
inspectionsDue: parseInt(alerts.inspections_due ?? '0', 10),
inspectionsOverdue: parseInt(alerts.inspections_overdue ?? '0', 10),
};