700 lines
23 KiB
TypeScript
700 lines
23 KiB
TypeScript
import pool from '../config/database';
|
|
import logger from '../utils/logger';
|
|
import {
|
|
Einsatz,
|
|
EinsatzWithDetails,
|
|
EinsatzListItem,
|
|
EinsatzStats,
|
|
EinsatzFahrzeug,
|
|
EinsatzPersonal,
|
|
MonthlyStatRow,
|
|
EinsatzArtStatRow,
|
|
EinsatzArt,
|
|
CreateEinsatzData,
|
|
UpdateEinsatzData,
|
|
AssignPersonnelData,
|
|
AssignVehicleData,
|
|
IncidentFilters,
|
|
} from '../models/incident.model';
|
|
|
|
class IncidentService {
|
|
// -------------------------------------------------------------------------
|
|
// LIST
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get a paginated list of incidents with optional filters.
|
|
* Returns lightweight EinsatzListItem rows (no bericht_text, no sub-arrays).
|
|
*/
|
|
async getAllIncidents(
|
|
filters: IncidentFilters = { limit: 50, offset: 0 }
|
|
): Promise<{ items: EinsatzListItem[]; total: number }> {
|
|
try {
|
|
const conditions: string[] = ["e.status != 'archiviert'"];
|
|
const params: unknown[] = [];
|
|
let p = 1;
|
|
|
|
if (filters.dateFrom) {
|
|
conditions.push(`e.alarm_time >= $${p++}`);
|
|
params.push(filters.dateFrom);
|
|
}
|
|
if (filters.dateTo) {
|
|
conditions.push(`e.alarm_time <= $${p++}`);
|
|
params.push(filters.dateTo);
|
|
}
|
|
if (filters.einsatzArt) {
|
|
conditions.push(`e.einsatz_art = $${p++}`);
|
|
params.push(filters.einsatzArt);
|
|
}
|
|
if (filters.status) {
|
|
conditions.push(`e.status = $${p++}`);
|
|
params.push(filters.status);
|
|
}
|
|
|
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
// Count query
|
|
const countResult = await pool.query(
|
|
`SELECT COUNT(*)::INTEGER AS total FROM einsaetze e ${where}`,
|
|
params
|
|
);
|
|
const total: number = countResult.rows[0].total;
|
|
|
|
// Data query — joined with users for einsatzleiter name and personal count
|
|
const dataQuery = `
|
|
SELECT
|
|
e.id,
|
|
e.einsatz_nr,
|
|
e.alarm_time,
|
|
e.einsatz_art,
|
|
e.einsatz_stichwort,
|
|
e.ort,
|
|
e.strasse,
|
|
e.status,
|
|
COALESCE(
|
|
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
|
|
u.name,
|
|
u.preferred_username
|
|
) AS einsatzleiter_name,
|
|
ROUND(
|
|
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
|
|
)::INTEGER AS hilfsfrist_min,
|
|
ROUND(
|
|
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
|
|
)::INTEGER AS dauer_min,
|
|
COALESCE(ep.personal_count, 0)::INTEGER AS personal_count
|
|
FROM einsaetze e
|
|
LEFT JOIN users u ON u.id = e.einsatzleiter_id
|
|
LEFT JOIN (
|
|
SELECT einsatz_id, COUNT(*) AS personal_count
|
|
FROM einsatz_personal
|
|
GROUP BY einsatz_id
|
|
) ep ON ep.einsatz_id = e.id
|
|
${where}
|
|
ORDER BY e.alarm_time DESC
|
|
LIMIT $${p++} OFFSET $${p++}
|
|
`;
|
|
|
|
params.push(filters.limit ?? 50, filters.offset ?? 0);
|
|
const dataResult = await pool.query(dataQuery, params);
|
|
|
|
const items: EinsatzListItem[] = dataResult.rows.map((row) => ({
|
|
id: row.id,
|
|
einsatz_nr: row.einsatz_nr,
|
|
alarm_time: row.alarm_time,
|
|
einsatz_art: row.einsatz_art,
|
|
einsatz_stichwort: row.einsatz_stichwort,
|
|
ort: row.ort,
|
|
strasse: row.strasse,
|
|
status: row.status,
|
|
einsatzleiter_name: row.einsatzleiter_name ?? null,
|
|
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
|
|
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
|
|
personal_count: Number(row.personal_count),
|
|
}));
|
|
|
|
return { items, total };
|
|
} catch (error) {
|
|
logger.error('Error fetching incident list', { error, filters });
|
|
throw new Error('Failed to fetch incidents');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// DETAIL
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get a single incident with full details: personnel, vehicles, computed times.
|
|
* NOTE: bericht_text is included here; role-based redaction is applied in
|
|
* the controller based on req.user.role.
|
|
*/
|
|
async getIncidentById(id: string): Promise<EinsatzWithDetails | null> {
|
|
try {
|
|
const einsatzResult = await pool.query(
|
|
`
|
|
SELECT
|
|
e.*,
|
|
COALESCE(
|
|
TRIM(CONCAT(u.given_name, ' ', u.family_name)),
|
|
u.name,
|
|
u.preferred_username
|
|
) AS einsatzleiter_name,
|
|
ROUND(
|
|
EXTRACT(EPOCH FROM (e.ankunft_time - e.alarm_time)) / 60.0
|
|
)::INTEGER AS hilfsfrist_min,
|
|
ROUND(
|
|
EXTRACT(EPOCH FROM (e.einrueck_time - e.alarm_time)) / 60.0
|
|
)::INTEGER AS dauer_min
|
|
FROM einsaetze e
|
|
LEFT JOIN users u ON u.id = e.einsatzleiter_id
|
|
WHERE e.id = $1
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (einsatzResult.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const row = einsatzResult.rows[0];
|
|
|
|
// Fetch assigned personnel
|
|
const personalResult = await pool.query(
|
|
`
|
|
SELECT
|
|
ep.einsatz_id,
|
|
ep.user_id,
|
|
ep.funktion,
|
|
ep.alarm_time,
|
|
ep.ankunft_time,
|
|
ep.assigned_at,
|
|
u.name,
|
|
u.email,
|
|
u.given_name,
|
|
u.family_name
|
|
FROM einsatz_personal ep
|
|
JOIN users u ON u.id = ep.user_id
|
|
WHERE ep.einsatz_id = $1
|
|
ORDER BY ep.funktion, u.family_name
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
// Fetch assigned vehicles
|
|
const fahrzeugeResult = await pool.query(
|
|
`
|
|
SELECT
|
|
ef.einsatz_id,
|
|
ef.fahrzeug_id,
|
|
ef.ausrueck_time,
|
|
ef.einrueck_time,
|
|
ef.assigned_at,
|
|
f.amtliches_kennzeichen AS kennzeichen,
|
|
f.bezeichnung,
|
|
f.typ_schluessel AS fahrzeug_typ
|
|
FROM einsatz_fahrzeuge ef
|
|
JOIN fahrzeuge f ON f.id = ef.fahrzeug_id
|
|
WHERE ef.einsatz_id = $1
|
|
ORDER BY f.bezeichnung
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
const einsatz: EinsatzWithDetails = {
|
|
id: row.id,
|
|
einsatz_nr: row.einsatz_nr,
|
|
alarm_time: row.alarm_time,
|
|
ausrueck_time: row.ausrueck_time,
|
|
ankunft_time: row.ankunft_time,
|
|
einrueck_time: row.einrueck_time,
|
|
einsatz_art: row.einsatz_art,
|
|
einsatz_stichwort: row.einsatz_stichwort,
|
|
strasse: row.strasse,
|
|
hausnummer: row.hausnummer,
|
|
ort: row.ort,
|
|
koordinaten: row.koordinaten,
|
|
bericht_kurz: row.bericht_kurz,
|
|
bericht_text: row.bericht_text,
|
|
einsatzleiter_id: row.einsatzleiter_id,
|
|
alarmierung_art: row.alarmierung_art,
|
|
status: row.status,
|
|
created_by: row.created_by,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
einsatzleiter_name: row.einsatzleiter_name ?? null,
|
|
hilfsfrist_min: row.hilfsfrist_min !== null ? Number(row.hilfsfrist_min) : null,
|
|
dauer_min: row.dauer_min !== null ? Number(row.dauer_min) : null,
|
|
fahrzeuge: fahrzeugeResult.rows as EinsatzFahrzeug[],
|
|
personal: personalResult.rows as EinsatzPersonal[],
|
|
};
|
|
|
|
return einsatz;
|
|
} catch (error) {
|
|
logger.error('Error fetching incident by ID', { error, id });
|
|
throw new Error('Failed to fetch incident');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// CREATE
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a new incident.
|
|
* Einsatz-Nr is generated atomically by the PostgreSQL function
|
|
* generate_einsatz_nr() using a per-year sequence table with UPDATE ... RETURNING.
|
|
* This is safe under concurrent inserts.
|
|
*/
|
|
async createIncident(data: CreateEinsatzData, createdBy: string): Promise<Einsatz> {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Generate the Einsatz-Nr atomically inside the transaction
|
|
const nrResult = await client.query(
|
|
`SELECT generate_einsatz_nr($1::TIMESTAMPTZ) AS einsatz_nr`,
|
|
[data.alarm_time]
|
|
);
|
|
const einsatz_nr: string = nrResult.rows[0].einsatz_nr;
|
|
|
|
const result = await client.query(
|
|
`
|
|
INSERT INTO einsaetze (
|
|
einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time,
|
|
einsatz_art, einsatz_stichwort,
|
|
strasse, hausnummer, ort,
|
|
bericht_kurz, bericht_text,
|
|
einsatzleiter_id, alarmierung_art, status,
|
|
created_by
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7,
|
|
$8, $9, $10,
|
|
$11, $12,
|
|
$13, $14, $15,
|
|
$16
|
|
)
|
|
RETURNING *
|
|
`,
|
|
[
|
|
einsatz_nr,
|
|
data.alarm_time,
|
|
data.ausrueck_time ?? null,
|
|
data.ankunft_time ?? null,
|
|
data.einrueck_time ?? null,
|
|
data.einsatz_art,
|
|
data.einsatz_stichwort ?? null,
|
|
data.strasse ?? null,
|
|
data.hausnummer ?? null,
|
|
data.ort ?? null,
|
|
data.bericht_kurz ?? null,
|
|
data.bericht_text ?? null,
|
|
data.einsatzleiter_id ?? null,
|
|
data.alarmierung_art ?? 'ILS',
|
|
data.status ?? 'aktiv',
|
|
createdBy,
|
|
]
|
|
);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
const einsatz = result.rows[0] as Einsatz;
|
|
logger.info('Incident created', {
|
|
einsatzId: einsatz.id,
|
|
einsatz_nr: einsatz.einsatz_nr,
|
|
createdBy,
|
|
});
|
|
|
|
return einsatz;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
logger.error('Error creating incident', { error, createdBy });
|
|
throw new Error('Failed to create incident');
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// UPDATE
|
|
// -------------------------------------------------------------------------
|
|
|
|
async updateIncident(
|
|
id: string,
|
|
data: UpdateEinsatzData,
|
|
updatedBy: string
|
|
): Promise<Einsatz> {
|
|
try {
|
|
const fields: string[] = [];
|
|
const values: unknown[] = [];
|
|
let p = 1;
|
|
|
|
const fieldMap: Array<[keyof UpdateEinsatzData, string]> = [
|
|
['alarm_time', 'alarm_time'],
|
|
['ausrueck_time', 'ausrueck_time'],
|
|
['ankunft_time', 'ankunft_time'],
|
|
['einrueck_time', 'einrueck_time'],
|
|
['einsatz_art', 'einsatz_art'],
|
|
['einsatz_stichwort', 'einsatz_stichwort'],
|
|
['strasse', 'strasse'],
|
|
['hausnummer', 'hausnummer'],
|
|
['ort', 'ort'],
|
|
['bericht_kurz', 'bericht_kurz'],
|
|
['bericht_text', 'bericht_text'],
|
|
['einsatzleiter_id', 'einsatzleiter_id'],
|
|
['alarmierung_art', 'alarmierung_art'],
|
|
['status', 'status'],
|
|
];
|
|
|
|
for (const [key, col] of fieldMap) {
|
|
if (key in data) {
|
|
fields.push(`${col} = $${p++}`);
|
|
values.push((data as Record<string, unknown>)[key] ?? null);
|
|
}
|
|
}
|
|
|
|
if (fields.length === 0) {
|
|
// Nothing to update — return current state
|
|
const current = await this.getIncidentById(id);
|
|
if (!current) throw new Error('Incident not found');
|
|
return current as Einsatz;
|
|
}
|
|
|
|
// updated_at is handled by the trigger, but we also set it explicitly
|
|
// to ensure immediate consistency within the same request cycle
|
|
fields.push(`updated_at = NOW()`);
|
|
values.push(id);
|
|
|
|
const result = await pool.query(
|
|
`
|
|
UPDATE einsaetze
|
|
SET ${fields.join(', ')}
|
|
WHERE id = $${p}
|
|
RETURNING *
|
|
`,
|
|
values
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new Error('Incident not found');
|
|
}
|
|
|
|
const einsatz = result.rows[0] as Einsatz;
|
|
logger.info('Incident updated', {
|
|
einsatzId: einsatz.id,
|
|
einsatz_nr: einsatz.einsatz_nr,
|
|
updatedBy,
|
|
});
|
|
|
|
return einsatz;
|
|
} catch (error) {
|
|
logger.error('Error updating incident', { error, id, updatedBy });
|
|
if (error instanceof Error && error.message === 'Incident not found') throw error;
|
|
throw new Error('Failed to update incident');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SOFT DELETE
|
|
// -------------------------------------------------------------------------
|
|
|
|
/** Soft delete: sets status = 'archiviert'. Hard delete is not exposed. */
|
|
async deleteIncident(id: string, deletedBy: string): Promise<void> {
|
|
try {
|
|
const result = await pool.query(
|
|
`
|
|
UPDATE einsaetze
|
|
SET status = 'archiviert', updated_at = NOW()
|
|
WHERE id = $1 AND status != 'archiviert'
|
|
RETURNING id
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new Error('Incident not found or already archived');
|
|
}
|
|
|
|
logger.info('Incident archived (soft-deleted)', { einsatzId: id, deletedBy });
|
|
} catch (error) {
|
|
logger.error('Error archiving incident', { error, id });
|
|
if (error instanceof Error && error.message.includes('not found')) throw error;
|
|
throw new Error('Failed to archive incident');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// PERSONNEL
|
|
// -------------------------------------------------------------------------
|
|
|
|
async assignPersonnel(einsatzId: string, data: AssignPersonnelData): Promise<void> {
|
|
try {
|
|
await pool.query(
|
|
`
|
|
INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (einsatz_id, user_id) DO UPDATE SET
|
|
funktion = EXCLUDED.funktion,
|
|
alarm_time = EXCLUDED.alarm_time,
|
|
ankunft_time = EXCLUDED.ankunft_time
|
|
`,
|
|
[
|
|
einsatzId,
|
|
data.user_id,
|
|
data.funktion ?? 'Mannschaft',
|
|
data.alarm_time ?? null,
|
|
data.ankunft_time ?? null,
|
|
]
|
|
);
|
|
|
|
logger.info('Personnel assigned to incident', {
|
|
einsatzId,
|
|
userId: data.user_id,
|
|
funktion: data.funktion,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error assigning personnel', { error, einsatzId, data });
|
|
throw new Error('Failed to assign personnel');
|
|
}
|
|
}
|
|
|
|
async removePersonnel(einsatzId: string, userId: string): Promise<void> {
|
|
try {
|
|
const result = await pool.query(
|
|
`DELETE FROM einsatz_personal WHERE einsatz_id = $1 AND user_id = $2 RETURNING user_id`,
|
|
[einsatzId, userId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new Error('Personnel assignment not found');
|
|
}
|
|
|
|
logger.info('Personnel removed from incident', { einsatzId, userId });
|
|
} catch (error) {
|
|
logger.error('Error removing personnel', { error, einsatzId, userId });
|
|
if (error instanceof Error && error.message.includes('not found')) throw error;
|
|
throw new Error('Failed to remove personnel');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// VEHICLES
|
|
// -------------------------------------------------------------------------
|
|
|
|
async assignVehicle(einsatzId: string, data: AssignVehicleData): Promise<void> {
|
|
try {
|
|
await pool.query(
|
|
`
|
|
INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (einsatz_id, fahrzeug_id) DO UPDATE SET
|
|
ausrueck_time = EXCLUDED.ausrueck_time,
|
|
einrueck_time = EXCLUDED.einrueck_time
|
|
`,
|
|
[
|
|
einsatzId,
|
|
data.fahrzeug_id,
|
|
data.ausrueck_time ?? null,
|
|
data.einrueck_time ?? null,
|
|
]
|
|
);
|
|
|
|
logger.info('Vehicle assigned to incident', { einsatzId, fahrzeugId: data.fahrzeug_id });
|
|
} catch (error) {
|
|
logger.error('Error assigning vehicle', { error, einsatzId, data });
|
|
throw new Error('Failed to assign vehicle');
|
|
}
|
|
}
|
|
|
|
async removeVehicle(einsatzId: string, fahrzeugId: string): Promise<void> {
|
|
try {
|
|
const result = await pool.query(
|
|
`DELETE FROM einsatz_fahrzeuge WHERE einsatz_id = $1 AND fahrzeug_id = $2 RETURNING fahrzeug_id`,
|
|
[einsatzId, fahrzeugId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new Error('Vehicle assignment not found');
|
|
}
|
|
|
|
logger.info('Vehicle removed from incident', { einsatzId, fahrzeugId });
|
|
} catch (error) {
|
|
logger.error('Error removing vehicle', { error, einsatzId, fahrzeugId });
|
|
if (error instanceof Error && error.message.includes('not found')) throw error;
|
|
throw new Error('Failed to remove vehicle');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// STATISTICS
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns aggregated statistics for a given year (defaults to current year).
|
|
* Queries live data directly rather than the materialized view so the stats
|
|
* page always reflects uncommitted-or-just-committed incidents.
|
|
* The materialized view is used for dashboard KPI cards via a separate endpoint.
|
|
*/
|
|
async getIncidentStats(year?: number): Promise<EinsatzStats> {
|
|
try {
|
|
const targetYear = year ?? new Date().getFullYear();
|
|
const prevYear = targetYear - 1;
|
|
|
|
// Overall totals for target year
|
|
const totalsResult = await pool.query(
|
|
`
|
|
SELECT
|
|
COUNT(*)::INTEGER AS gesamt,
|
|
COUNT(*) FILTER (WHERE status = 'abgeschlossen')::INTEGER AS abgeschlossen,
|
|
COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv,
|
|
ROUND(
|
|
AVG(
|
|
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
|
|
) FILTER (WHERE ankunft_time IS NOT NULL)
|
|
)::INTEGER AS avg_hilfsfrist_min
|
|
FROM einsaetze
|
|
WHERE EXTRACT(YEAR FROM alarm_time) = $1
|
|
AND status != 'archiviert'
|
|
`,
|
|
[targetYear]
|
|
);
|
|
|
|
const totals = totalsResult.rows[0] ?? { gesamt: 0, abgeschlossen: 0, aktiv: 0, avg_hilfsfrist_min: null };
|
|
|
|
// Monthly breakdown — target year
|
|
const monthlyResult = await pool.query(
|
|
`
|
|
SELECT
|
|
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
|
|
COUNT(*)::INTEGER AS anzahl,
|
|
ROUND(
|
|
AVG(
|
|
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
|
|
) FILTER (WHERE ankunft_time IS NOT NULL)
|
|
)::INTEGER AS avg_hilfsfrist_min,
|
|
ROUND(
|
|
AVG(
|
|
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
|
|
) FILTER (WHERE einrueck_time IS NOT NULL)
|
|
)::INTEGER AS avg_dauer_min
|
|
FROM einsaetze
|
|
WHERE EXTRACT(YEAR FROM alarm_time) = $1
|
|
AND status != 'archiviert'
|
|
GROUP BY EXTRACT(MONTH FROM alarm_time)
|
|
ORDER BY monat
|
|
`,
|
|
[targetYear]
|
|
);
|
|
|
|
// Monthly breakdown — previous year (for chart overlay)
|
|
const prevMonthlyResult = await pool.query(
|
|
`
|
|
SELECT
|
|
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
|
|
COUNT(*)::INTEGER AS anzahl,
|
|
ROUND(
|
|
AVG(
|
|
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
|
|
) FILTER (WHERE ankunft_time IS NOT NULL)
|
|
)::INTEGER AS avg_hilfsfrist_min,
|
|
ROUND(
|
|
AVG(
|
|
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
|
|
) FILTER (WHERE einrueck_time IS NOT NULL)
|
|
)::INTEGER AS avg_dauer_min
|
|
FROM einsaetze
|
|
WHERE EXTRACT(YEAR FROM alarm_time) = $1
|
|
AND status != 'archiviert'
|
|
GROUP BY EXTRACT(MONTH FROM alarm_time)
|
|
ORDER BY monat
|
|
`,
|
|
[prevYear]
|
|
);
|
|
|
|
// By Einsatzart — target year
|
|
const byArtResult = await pool.query(
|
|
`
|
|
SELECT
|
|
einsatz_art,
|
|
COUNT(*)::INTEGER AS anzahl,
|
|
ROUND(
|
|
AVG(
|
|
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
|
|
) FILTER (WHERE ankunft_time IS NOT NULL)
|
|
)::INTEGER AS avg_hilfsfrist_min
|
|
FROM einsaetze
|
|
WHERE EXTRACT(YEAR FROM alarm_time) = $1
|
|
AND status != 'archiviert'
|
|
GROUP BY einsatz_art
|
|
ORDER BY anzahl DESC
|
|
`,
|
|
[targetYear]
|
|
);
|
|
|
|
// Determine most common Einsatzart
|
|
const haeufigste_art: EinsatzArt | null =
|
|
byArtResult.rows.length > 0 ? (byArtResult.rows[0].einsatz_art as EinsatzArt) : null;
|
|
|
|
const monthly: MonthlyStatRow[] = (monthlyResult.rows ?? []).map((r) => ({
|
|
monat: r.monat,
|
|
anzahl: r.anzahl,
|
|
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
|
|
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
|
|
}));
|
|
|
|
const prev_year_monthly: MonthlyStatRow[] = (prevMonthlyResult.rows ?? []).map((r) => ({
|
|
monat: r.monat,
|
|
anzahl: r.anzahl,
|
|
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
|
|
avg_dauer_min: r.avg_dauer_min !== null ? Number(r.avg_dauer_min) : null,
|
|
}));
|
|
|
|
const by_art: EinsatzArtStatRow[] = (byArtResult.rows ?? []).map((r) => ({
|
|
einsatz_art: r.einsatz_art as EinsatzArt,
|
|
anzahl: r.anzahl,
|
|
avg_hilfsfrist_min: r.avg_hilfsfrist_min !== null ? Number(r.avg_hilfsfrist_min) : null,
|
|
}));
|
|
|
|
return {
|
|
jahr: targetYear,
|
|
gesamt: totals.gesamt ?? 0,
|
|
abgeschlossen: totals.abgeschlossen ?? 0,
|
|
aktiv: totals.aktiv ?? 0,
|
|
avg_hilfsfrist_min:
|
|
totals.avg_hilfsfrist_min !== null ? Number(totals.avg_hilfsfrist_min) : null,
|
|
haeufigste_art,
|
|
monthly,
|
|
by_art,
|
|
prev_year_monthly,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error fetching incident statistics', { error, year });
|
|
throw new Error('Failed to fetch incident statistics');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// MATERIALIZED VIEW REFRESH
|
|
// -------------------------------------------------------------------------
|
|
|
|
/** Refresh the einsatz_statistik materialized view. Call after bulk operations. */
|
|
async refreshStatistikView(): Promise<void> {
|
|
try {
|
|
await pool.query('REFRESH MATERIALIZED VIEW CONCURRENTLY einsatz_statistik');
|
|
logger.info('einsatz_statistik materialized view refreshed');
|
|
} catch (error) {
|
|
// CONCURRENTLY requires a unique index — fall back to non-concurrent refresh
|
|
try {
|
|
await pool.query('REFRESH MATERIALIZED VIEW einsatz_statistik');
|
|
logger.info('einsatz_statistik materialized view refreshed (non-concurrent)');
|
|
} catch (fallbackError) {
|
|
logger.error('Error refreshing einsatz_statistik view', { fallbackError });
|
|
throw new Error('Failed to refresh statistics view');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export default new IncidentService();
|