Offene Vorschauen sichtbar machen und doppelt aufnehmen verhindern
Der Abgleich bucht bewusst nichts, sondern legt eine Vorschau zur Bestaetigung an. Nur fuehrte von dort kein Weg weiter: die Import-Seite zeigte allein den Datei-Upload, und die Rueckmeldung sagte lediglich "abgeglichen". Die gefundenen Pakete lagen damit unsichtbar herum und der Knopf wirkte, als tue er nichts. Die Import-Seite listet jetzt alle Importe, die noch auf Bestaetigung warten, und oeffnet sie in derselben Vorschau wie einen frischen Upload. Die Meldung nach dem Abgleich nennt die Zahl der Funde und den naechsten Schritt. Ausserdem beruecksichtigt der Abgleich jetzt offene Vorschauen: bisher pruefte er nur gegen bereits gebuchte Pakete. Ein zweiter Lauf legte dieselben Zeilen deshalb ein weiteres Mal an, und beim Buchen kollidierten beide Importe an der eindeutigen Bestell-ID - der zweite schlug komplett fehl.
This commit is contained in:
@@ -27,6 +27,8 @@ export interface ScrapeSummary {
|
|||||||
failedPlayers: { steamId: string; reason: string }[];
|
failedPlayers: { steamId: string; reason: string }[];
|
||||||
newRows: number;
|
newRows: number;
|
||||||
duplicates: number;
|
duplicates: number;
|
||||||
|
/// Lag schon in einer offenen Vorschau und wurde deshalb nicht erneut aufgenommen.
|
||||||
|
awaitingConfirmation: number;
|
||||||
unclear: number;
|
unclear: number;
|
||||||
usageCounters: number;
|
usageCounters: number;
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,7 @@ export async function scrapePlayers(
|
|||||||
failedPlayers: [],
|
failedPlayers: [],
|
||||||
newRows: 0,
|
newRows: 0,
|
||||||
duplicates: 0,
|
duplicates: 0,
|
||||||
|
awaitingConfirmation: 0,
|
||||||
unclear: 0,
|
unclear: 0,
|
||||||
usageCounters: 0,
|
usageCounters: 0,
|
||||||
};
|
};
|
||||||
@@ -57,6 +60,15 @@ export async function scrapePlayers(
|
|||||||
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
|
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
|
||||||
let lineNo = 0;
|
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) {
|
for (const target of targets) {
|
||||||
let profile: ScrapedProfile;
|
let profile: ScrapedProfile;
|
||||||
try {
|
try {
|
||||||
@@ -112,6 +124,12 @@ export async function scrapePlayers(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (alreadyPending.has(externalId)) {
|
||||||
|
summary.awaitingConfirmation += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
alreadyPending.add(externalId);
|
||||||
|
|
||||||
lineNo += 1;
|
lineNo += 1;
|
||||||
rows.push({
|
rows.push({
|
||||||
lineNo,
|
lineNo,
|
||||||
@@ -162,6 +180,7 @@ export async function scrapePlayers(
|
|||||||
scannedPlayers: summary.scannedPlayers,
|
scannedPlayers: summary.scannedPlayers,
|
||||||
newRows: summary.newRows,
|
newRows: summary.newRows,
|
||||||
duplicates: summary.duplicates,
|
duplicates: summary.duplicates,
|
||||||
|
awaitingConfirmation: summary.awaitingConfirmation,
|
||||||
unclear: summary.unclear,
|
unclear: summary.unclear,
|
||||||
failed: summary.failedPlayers.length,
|
failed: summary.failedPlayers.length,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -140,3 +140,69 @@ describe('SteamID-Schreibweisen', () => {
|
|||||||
assert.equal(looksLikeSteamId('Darkbrine44'), false);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface ScrapeSummary {
|
|||||||
failedPlayers: { steamId: string; reason: string }[];
|
failedPlayers: { steamId: string; reason: string }[];
|
||||||
newRows: number;
|
newRows: number;
|
||||||
duplicates: number;
|
duplicates: number;
|
||||||
|
awaitingConfirmation: number;
|
||||||
unclear: number;
|
unclear: number;
|
||||||
usageCounters: number;
|
usageCounters: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,11 +63,21 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
|
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
|
||||||
const parts = [`${result.scannedPlayers} Profile geprüft`];
|
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.unclear > 0) parts.push(`${result.unclear} ungeklärt`);
|
||||||
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
|
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
|
||||||
if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`);
|
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()]);
|
await Promise.all([load(query), loadFlagged()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import Notice from '../components/Notice';
|
import Notice from '../components/Notice';
|
||||||
import { ApiError, api, uploadFile } from '../lib/api';
|
import { ApiError, api, uploadFile } from '../lib/api';
|
||||||
import { PLAN_LABEL, formatDate } from '../lib/format';
|
import { PLAN_LABEL, formatDate } from '../lib/format';
|
||||||
@@ -62,12 +62,53 @@ export default function Import() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [flash, setFlash] = useState<string | null>(null);
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [open, setOpen] = useState<ImportRecord[]>([]);
|
||||||
|
|
||||||
|
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 = () => {
|
const reset = () => {
|
||||||
setUpload(null);
|
setUpload(null);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setMapping(EMPTY_MAPPING);
|
setMapping(EMPTY_MAPPING);
|
||||||
setError(null);
|
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<ImportRecord>(`/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<ImportPlan, number>;
|
||||||
|
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) => {
|
const handleUpload = async (file: File) => {
|
||||||
@@ -154,6 +195,41 @@ export default function Import() {
|
|||||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
{!upload && open.length > 0 && (
|
||||||
|
<section className="block block-flag">
|
||||||
|
<h2>Wartet auf Bestätigung ({open.length})</h2>
|
||||||
|
<Notice kind="warn">
|
||||||
|
Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet
|
||||||
|
ebenfalls hier — erst nach dem Bestätigen entstehen Guthaben.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<ul className="cc-list">
|
||||||
|
{open.map((record) => (
|
||||||
|
<li key={record.id} className="cc-item">
|
||||||
|
<div>
|
||||||
|
<strong>{record.filename}</strong>
|
||||||
|
<p className="muted small">
|
||||||
|
{record.rowCount} {record.rowCount === 1 ? 'Zeile' : 'Zeilen'} · angelegt{' '}
|
||||||
|
{formatDate(record.createdAt)}
|
||||||
|
{record.importedBy && ` von ${record.importedBy.username}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void openExisting(record)}
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{!upload && (
|
{!upload && (
|
||||||
<section className="block">
|
<section className="block">
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import Notice from '../components/Notice';
|
|||||||
import { ApiError, api } from '../lib/api';
|
import { ApiError, api } from '../lib/api';
|
||||||
import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format';
|
import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format';
|
||||||
import { useSession } from '../lib/session';
|
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 DialogState =
|
||||||
| { type: 'none' }
|
| { 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<ScrapeSummary>(`/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 <p className="muted">Lade …</p>;
|
if (loading) return <p className="muted">Lade …</p>;
|
||||||
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
|
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
|
||||||
|
|
||||||
@@ -162,15 +207,7 @@ export default function PlayerDetail() {
|
|||||||
className="btn"
|
className="btn"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
title="Pakete dieses Spielers von GExtension abrufen"
|
title="Pakete dieses Spielers von GExtension abrufen"
|
||||||
onClick={() =>
|
onClick={() => void syncFromGextension()}
|
||||||
void run(async () => {
|
|
||||||
const result = await api<{ newRows: number; unclear: number }>(
|
|
||||||
`/api/scrape/player/${playerId}`,
|
|
||||||
{ method: 'POST', body: {} },
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}, 'GExtension abgeglichen.')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
GExtension abgleichen
|
GExtension abgleichen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user