diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts index a5f0e60..656b1a4 100644 --- a/server/src/services/scraper.ts +++ b/server/src/services/scraper.ts @@ -27,6 +27,8 @@ export interface ScrapeSummary { failedPlayers: { steamId: string; reason: string }[]; newRows: number; duplicates: number; + /// Lag schon in einer offenen Vorschau und wurde deshalb nicht erneut aufgenommen. + awaitingConfirmation: number; unclear: number; usageCounters: number; } @@ -50,6 +52,7 @@ export async function scrapePlayers( failedPlayers: [], newRows: 0, duplicates: 0, + awaitingConfirmation: 0, unclear: 0, usageCounters: 0, }; @@ -57,6 +60,15 @@ export async function scrapePlayers( const rows: Prisma.ImportRowCreateManyImportInput[] = []; let lineNo = 0; + // Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne + // diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren + // Import, und beim Buchen kollidieren sie miteinander. + const pending = await prisma.importRow.findMany({ + where: { externalId: { not: null }, import: { status: ImportStatus.PENDING } }, + select: { externalId: true }, + }); + const alreadyPending = new Set(pending.map((row) => row.externalId as string)); + for (const target of targets) { let profile: ScrapedProfile; try { @@ -112,6 +124,12 @@ export async function scrapePlayers( continue; } + if (alreadyPending.has(externalId)) { + summary.awaitingConfirmation += 1; + continue; + } + alreadyPending.add(externalId); + lineNo += 1; rows.push({ lineNo, @@ -162,6 +180,7 @@ export async function scrapePlayers( scannedPlayers: summary.scannedPlayers, newRows: summary.newRows, duplicates: summary.duplicates, + awaitingConfirmation: summary.awaitingConfirmation, unclear: summary.unclear, failed: summary.failedPlayers.length, }, diff --git a/server/test/scraper.test.ts b/server/test/scraper.test.ts index f82a59d..047b54e 100644 --- a/server/test/scraper.test.ts +++ b/server/test/scraper.test.ts @@ -140,3 +140,69 @@ describe('SteamID-Schreibweisen', () => { assert.equal(looksLikeSteamId('Darkbrine44'), false); }); }); + +// Der Ablauf mit Datenbank: was passiert bei einem zweiten Abgleich? +describe('Mehrfacher Abgleich', () => { + it('nimmt Pakete aus einer offenen Vorschau nicht erneut auf', async () => { + const { startTestDatabase } = await import('./harness.js'); + const database = await startTestDatabase(54334); + process.env.DATABASE_URL = database.url; + process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; + process.env.NODE_ENV = 'test'; + + const db = await import('../src/db.js'); + const { PLANS } = await import('../src/services/imports.js'); + + const user = await db.prisma.user.create({ + data: { username: 'abgleicher', passwordHash: 'x', role: 'ADMIN' }, + }); + const player = await db.prisma.player.create({ + data: { displayName: 'Testspieler', steamId: '76561198000000999' }, + }); + + // Erster Lauf: ein Paket landet in einer offenen Vorschau. + const first = await db.prisma.import.create({ + data: { + filename: 'Abgleich 1', + status: 'PENDING', + rowCount: 1, + importedById: user.id, + rows: { + create: [ + { + lineNo: 1, + externalId: 'gext:9001', + playerKey: '76561198000000999', + productName: 'Custom Character', + target: 'CC_PACKAGE', + resolvedPlayerId: player.id, + plan: PLANS.BOOK, + raw: {}, + }, + ], + }, + }, + }); + + // Genau diese Pruefung macht der zweite Abgleich. + const pending = await db.prisma.importRow.findMany({ + where: { externalId: { not: null }, import: { status: 'PENDING' } }, + select: { externalId: true }, + }); + const wartend = new Set(pending.map((row) => row.externalId as string)); + + assert.ok(wartend.has('gext:9001'), 'das Paket wird als wartend erkannt'); + assert.equal(wartend.size, 1); + + // Nach dem Buchen zaehlt stattdessen die Dedup ueber ImportedOrder. + await db.prisma.import.update({ where: { id: first.id }, data: { status: 'COMMITTED' } }); + const danach = await db.prisma.importRow.findMany({ + where: { externalId: { not: null }, import: { status: 'PENDING' } }, + select: { externalId: true }, + }); + assert.equal(danach.length, 0, 'ein gebuchter Import blockiert nicht mehr'); + + await db.prisma.$disconnect(); + await database.stop(); + }); +}); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 37c5953..1b2c500 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -75,6 +75,7 @@ export interface ScrapeSummary { failedPlayers: { steamId: string; reason: string }[]; newRows: number; duplicates: number; + awaitingConfirmation: number; unclear: number; usageCounters: number; } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 37ef463..a170452 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -63,11 +63,21 @@ export default function Dashboard() { try { const result = await api('/api/scrape/run', { method: 'POST', body: {} }); const parts = [`${result.scannedPlayers} Profile geprüft`]; - if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestätigung`); + if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete gefunden`); + if (result.awaitingConfirmation > 0) { + parts.push(`${result.awaitingConfirmation} warten schon`); + } if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`); if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`); if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`); - setSyncResult(parts.join(', ') + '.'); + + const offen = result.newRows + result.awaitingConfirmation; + setSyncResult( + parts.join(', ') + + (offen > 0 + ? '. Unter Import bestätigen, damit die Guthaben entstehen.' + : '.'), + ); await Promise.all([load(query), loadFlagged()]); } catch (err) { setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); diff --git a/web/src/pages/Import.tsx b/web/src/pages/Import.tsx index d55ded0..d8e9b1f 100644 --- a/web/src/pages/Import.tsx +++ b/web/src/pages/Import.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Notice from '../components/Notice'; import { ApiError, api, uploadFile } from '../lib/api'; import { PLAN_LABEL, formatDate } from '../lib/format'; @@ -62,12 +62,53 @@ export default function Import() { const [error, setError] = useState(null); const [flash, setFlash] = useState(null); const [busy, setBusy] = useState(false); + const [open, setOpen] = useState([]); + + const loadOpen = useCallback(async () => { + try { + const result = await api<{ items: ImportRecord[] }>('/api/imports'); + setOpen(result.items.filter((entry) => entry.status === 'PENDING')); + } catch { + // Die Liste ist Beiwerk; ein Fehler hier darf den Upload nicht blockieren. + } + }, []); + + useEffect(() => { + void loadOpen(); + }, [loadOpen]); const reset = () => { setUpload(null); setPreview(null); setMapping(EMPTY_MAPPING); setError(null); + void loadOpen(); + }; + + /** Einen bereits angelegten Import (etwa aus dem GExtension-Abgleich) oeffnen. */ + const openExisting = async (record: ImportRecord) => { + setBusy(true); + setError(null); + try { + const full = await api(`/api/imports/${record.id}`); + const rows = full.rows ?? []; + const summary = { + BOOK: 0, + CREATE_PLAYER_AND_BOOK: 0, + SKIP_DUPLICATE: 0, + SKIP_IGNORED: 0, + SKIP_UNMAPPED: 0, + SKIP_NO_PLAYER: 0, + } as Record; + for (const row of rows) summary[row.plan] += 1; + + setUpload({ importId: full.id, columns: [], rowCount: full.rowCount, sample: [] }); + setPreview({ summary, import: full }); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Import konnte nicht geoeffnet werden'); + } finally { + setBusy(false); + } }; const handleUpload = async (file: File) => { @@ -154,6 +195,41 @@ export default function Import() { {error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} + {!upload && open.length > 0 && ( +
+

Wartet auf Bestätigung ({open.length})

+ + Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet + ebenfalls hier — erst nach dem Bestätigen entstehen Guthaben. + + +
    + {open.map((record) => ( +
  • +
    + {record.filename} +

    + {record.rowCount} {record.rowCount === 1 ? 'Zeile' : 'Zeilen'} · angelegt{' '} + {formatDate(record.createdAt)} + {record.importedBy && ` von ${record.importedBy.username}`} +

    +
    +
    + +
    +
  • + ))} +
+
+ )} + {!upload && (
diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index 49d7976..badca77 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -7,7 +7,13 @@ import Notice from '../components/Notice'; import { ApiError, api } from '../lib/api'; import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format'; import { useSession } from '../lib/session'; -import type { Character, CreditKind, LedgerEntry, PlayerDetail as Detail } from '../lib/types'; +import type { + Character, + CreditKind, + LedgerEntry, + PlayerDetail as Detail, + ScrapeSummary, +} from '../lib/types'; type DialogState = | { type: 'none' } @@ -61,6 +67,45 @@ export default function PlayerDetail() { } }; + // Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung + // auch sagen, sonst wirkt der Knopf wirkungslos. + const syncFromGextension = async () => { + setBusy(true); + setError(null); + try { + const result = await api(`/api/scrape/player/${playerId}`, { + method: 'POST', + body: {}, + }); + await load(); + + if (result.newRows > 0) { + setFlash( + `${result.newRows} ${result.newRows === 1 ? 'Paket' : 'Pakete'} gefunden. ` + + 'Unter Import bestätigen, damit die Guthaben entstehen.', + ); + } else if (result.awaitingConfirmation > 0) { + setFlash( + `${result.awaitingConfirmation} ${result.awaitingConfirmation === 1 ? 'Paket wartet' : 'Pakete warten'} ` + + 'bereits unter Import auf Bestätigung.', + ); + } else if (result.unclear > 0) { + setFlash(`${result.unclear} unklare Einträge — siehe Kasten oben.`); + } else if (result.duplicates > 0) { + setFlash('Nichts Neues — alle Pakete sind bereits gebucht.'); + } else if (result.failedPlayers.length > 0) { + setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`); + } else { + setFlash('Keine CC-relevanten Pakete im Profil gefunden.'); + } + window.setTimeout(() => setFlash(null), 8000); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + if (loading) return

Lade …

; if (!detail) return {error ?? 'Spieler nicht gefunden'}; @@ -162,15 +207,7 @@ export default function PlayerDetail() { className="btn" disabled={busy} title="Pakete dieses Spielers von GExtension abrufen" - onClick={() => - void run(async () => { - const result = await api<{ newRows: number; unclear: number }>( - `/api/scrape/player/${playerId}`, - { method: 'POST', body: {} }, - ); - return result; - }, 'GExtension abgeglichen.') - } + onClick={() => void syncFromGextension()} > GExtension abgleichen