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.