feat(sync): fix exam sync pagination, add AGL/AT20-Theorie lehrgang variants with yellow checkmark

This commit is contained in:
Matthias Hochmeister
2026-04-19 19:28:22 +02:00
parent ed3ee143dd
commit d796fae978
7 changed files with 72 additions and 21 deletions

View File

@@ -0,0 +1,8 @@
-- Migration: 097_add_lehrgang_theorie_only
-- Adds a flag to distinguish theory-only AT20 completion from full lehrgang.
-- AT20 "mit Erfolg Theorie" = theory passed but not equipment-eligible.
ALTER TABLE atemschutz_traeger
ADD COLUMN IF NOT EXISTS lehrgang_theorie_only BOOLEAN DEFAULT FALSE;
-- The atemschutz_uebersicht view uses at.* so it picks up the new column automatically.

View File

@@ -483,9 +483,15 @@ function Atemschutz() {
)},
{ key: 'atemschutz_lehrgang', label: 'Lehrgang', align: 'center', render: (item) => (
item.atemschutz_lehrgang ? (
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
<Check color="success" fontSize="small" />
</Tooltip>
item.lehrgang_theorie_only ? (
<Tooltip title={item.lehrgang_datum ? `Theorie am ${formatDate(item.lehrgang_datum)}` : 'Nur Theorie absolviert'}>
<Check color="warning" fontSize="small" />
</Tooltip>
) : (
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
<Check color="success" fontSize="small" />
</Tooltip>
)
) : (
<Close color="disabled" fontSize="small" />
)

View File

@@ -1037,7 +1037,7 @@ function MitgliedDetail() {
label="Lehrgang"
value={
atemschutz.atemschutz_lehrgang
? `Ja${atemschutz.lehrgang_datum ? ` (${new Date(atemschutz.lehrgang_datum).toLocaleDateString('de-AT')})` : ''}`
? `Ja${atemschutz.lehrgang_theorie_only ? ' (nur Theorie)' : ''}${atemschutz.lehrgang_datum ? ` (${new Date(atemschutz.lehrgang_datum).toLocaleDateString('de-AT')})` : ''}`
: 'Nein'
}
/>

View File

@@ -16,6 +16,7 @@ export interface AtemschutzTraeger {
id: string;
user_id: string;
atemschutz_lehrgang: boolean;
lehrgang_theorie_only: boolean;
lehrgang_datum: string | null;
untersuchung_datum: string | null;
untersuchung_gueltig_bis: string | null;

View File

@@ -290,12 +290,15 @@ export async function syncToDatabase(
}
/**
* Scans the ausbildung table for AT20 courses with erfolgscode = 'mit Erfolg'
* and upserts atemschutz_traeger records accordingly.
* Must run AFTER syncToDatabase() has committed — i.e. on the same pool, outside the transaction.
* Scans the ausbildung table for atemschutz-related courses and upserts atemschutz_traeger.
* Handles:
* - AT20 with 'mit Erfolg' / 'mit ausgezeichnetem Erfolg' → full lehrgang
* - AGL with 'mit Erfolg' → full lehrgang
* - AT20 with 'mit Erfolg Theorie' → theory-only (yellow)
* Must run AFTER syncToDatabase() has committed.
*/
export async function syncAT20ToAtemschutz(pool: Pool): Promise<void> {
// First, log a sample of what's actually stored so we can verify the filter strings match.
export async function syncLehrgangToAtemschutz(pool: Pool): Promise<void> {
// Diagnostic: log course distribution
const sample = await pool.query<{ kurs_kurzbezeichnung: string | null; erfolgscode: string | null; count: string }>(
`SELECT kurs_kurzbezeichnung, erfolgscode, COUNT(*)::text AS count
FROM ausbildung
@@ -304,24 +307,52 @@ export async function syncAT20ToAtemschutz(pool: Pool): Promise<void> {
ORDER BY count DESC
LIMIT 20`
);
log(`AT20-Sync: kurs_kurzbezeichnung/erfolgscode distribution (top 20):`);
log(`Lehrgang-Sync: kurs_kurzbezeichnung/erfolgscode distribution (top 20):`);
for (const row of sample.rows) {
log(` kurzbezeichnung=${JSON.stringify(row.kurs_kurzbezeichnung)} erfolgscode=${JSON.stringify(row.erfolgscode)} count=${row.count}`);
}
const result = await pool.query<{ rowCount: number }>(
`INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum)
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum)
// Pass 1: Full lehrgang — AT20 (mit Erfolg / mit ausgezeichnetem Erfolg) or AGL (mit Erfolg)
const fullResult = await pool.query(
`INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum, lehrgang_theorie_only)
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum), false
FROM ausbildung a
WHERE TRIM(a.kurs_kurzbezeichnung) = 'AT20'
AND TRIM(a.erfolgscode) IN ('mit Erfolg', 'mit ausgezeichnetem Erfolg')
WHERE (
(TRIM(a.kurs_kurzbezeichnung) = 'AT20' AND TRIM(a.erfolgscode) IN ('mit Erfolg', 'mit ausgezeichnetem Erfolg'))
OR
(TRIM(a.kurs_kurzbezeichnung) = 'AGL' AND TRIM(a.erfolgscode) IN ('mit Erfolg', 'mit ausgezeichnetem Erfolg'))
)
GROUP BY a.user_id
ON CONFLICT (user_id) DO UPDATE
SET atemschutz_lehrgang = true,
lehrgang_theorie_only = false,
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
updated_at = NOW()`
);
log(`AT20-Sync: ${result.rowCount ?? 0} atemschutz_traeger rows upserted`);
log(`Lehrgang-Sync: ${fullResult.rowCount ?? 0} full lehrgang rows upserted`);
// Pass 2: Theory-only — AT20 with 'mit Erfolg Theorie', only for users without a full lehrgang
const theorieResult = await pool.query(
`INSERT INTO atemschutz_traeger (id, user_id, atemschutz_lehrgang, lehrgang_datum, lehrgang_theorie_only)
SELECT uuid_generate_v4(), a.user_id, true, MIN(a.kurs_datum), true
FROM ausbildung a
WHERE TRIM(a.kurs_kurzbezeichnung) = 'AT20'
AND TRIM(a.erfolgscode) = 'mit Erfolg Theorie'
AND NOT EXISTS (
SELECT 1 FROM atemschutz_traeger at2
WHERE at2.user_id = a.user_id
AND at2.atemschutz_lehrgang = true
AND at2.lehrgang_theorie_only = false
)
GROUP BY a.user_id
ON CONFLICT (user_id) DO UPDATE
SET atemschutz_lehrgang = true,
lehrgang_theorie_only = true,
lehrgang_datum = COALESCE(atemschutz_traeger.lehrgang_datum, EXCLUDED.lehrgang_datum),
updated_at = NOW()
WHERE atemschutz_traeger.lehrgang_theorie_only IS DISTINCT FROM false`
);
log(`Lehrgang-Sync: ${theorieResult.rowCount ?? 0} theory-only lehrgang rows upserted`);
}
/**

View File

@@ -2,7 +2,7 @@ import 'dotenv/config';
import * as http from 'http';
import { Pool } from 'pg';
import { scrapeAll } from './scraper';
import { syncToDatabase, syncAT20ToAtemschutz, syncUntersuchungenToAtemschutz } from './db';
import { syncToDatabase, syncLehrgangToAtemschutz, syncUntersuchungenToAtemschutz } from './db';
// In-memory log ring buffer — last 500 lines captured from all modules
const LOG_BUFFER_MAX = 500;
@@ -70,7 +70,7 @@ async function runSync(force = false): Promise<void> {
const { members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen } = await scrapeAll(username, password);
await syncToDatabase(pool, members, ausbildungen, befoerderungen, untersuchungen, fahrgenehmigungen, force);
log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen, ${befoerderungen.length} Beförderungen, ${untersuchungen.length} Untersuchungen, ${fahrgenehmigungen.length} Fahrgenehmigungen`);
await syncAT20ToAtemschutz(pool);
await syncLehrgangToAtemschutz(pool);
await syncUntersuchungenToAtemschutz(pool);
} finally {
syncRunning = false;

View File

@@ -967,8 +967,7 @@ async function scrapeMemberUntersuchungen(
): Promise<FdiskUntersuchung[]> {
const url = `${BASE_URL}/fdisk/module/mgvw/untersuchungen/UntersuchungenList.aspx`
+ `?search=1&searchid_mitgliedschaften=${idMitgliedschaft}&id_personen=${idPersonen}`
+ `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`
+ `&anzeige_count=ALLE&offset=0`;
+ `&id_mitgliedschaften=${idMitgliedschaft}&searchid_personen=${idPersonen}&searchid_maskmode=`;
// Always dump for diagnosis when debug is on
await frame_goto(frame, url);
@@ -980,8 +979,14 @@ async function scrapeMemberUntersuchungen(
return [];
}
// Show all rows
// Show all rows: select "ALLE", then explicitly submit the form so ASP.NET
// processes the request with its server-generated ViewState.
await selectAlleAnzeige(frame);
const submitBtn = frame.locator('input[type="image"][src*="suchen"]');
if (await submitBtn.count() > 0) {
await submitBtn.click();
await frame.waitForLoadState('networkidle').catch(() => {});
}
// Try to navigate to history/detail view if available
// FDISK may show only the most recent per exam type on the list page.