diff --git a/backend/src/database/migrations/097_add_lehrgang_theorie_only.sql b/backend/src/database/migrations/097_add_lehrgang_theorie_only.sql new file mode 100644 index 0000000..ed8ee3f --- /dev/null +++ b/backend/src/database/migrations/097_add_lehrgang_theorie_only.sql @@ -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. diff --git a/frontend/src/pages/Atemschutz.tsx b/frontend/src/pages/Atemschutz.tsx index 03f5641..b8e4ec6 100644 --- a/frontend/src/pages/Atemschutz.tsx +++ b/frontend/src/pages/Atemschutz.tsx @@ -483,9 +483,15 @@ function Atemschutz() { )}, { key: 'atemschutz_lehrgang', label: 'Lehrgang', align: 'center', render: (item) => ( item.atemschutz_lehrgang ? ( - - - + item.lehrgang_theorie_only ? ( + + + + ) : ( + + + + ) ) : ( ) diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index f78910c..b74b83a 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -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' } /> diff --git a/frontend/src/types/atemschutz.types.ts b/frontend/src/types/atemschutz.types.ts index 2c82b8c..9188eca 100644 --- a/frontend/src/types/atemschutz.types.ts +++ b/frontend/src/types/atemschutz.types.ts @@ -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; diff --git a/sync/src/db.ts b/sync/src/db.ts index 81ad6e6..2e5dc87 100644 --- a/sync/src/db.ts +++ b/sync/src/db.ts @@ -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 { - // First, log a sample of what's actually stored so we can verify the filter strings match. +export async function syncLehrgangToAtemschutz(pool: Pool): Promise { + // 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 { 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`); } /** diff --git a/sync/src/index.ts b/sync/src/index.ts index d92bd84..56eac1b 100644 --- a/sync/src/index.ts +++ b/sync/src/index.ts @@ -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 { 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; diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index bf16406..d53db8f 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -967,8 +967,7 @@ async function scrapeMemberUntersuchungen( ): Promise { 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.