GExtension-Abgleich mit Handpruefung fuer unklare Pakete
Die Profilseiten sind ohne Login abrufbar und betten die Kaeufe als JSON ins HTML ein. Gelesen wird dieses JSON statt der gerenderten Tabelle: das ist unempfindlich gegen Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag und den Storno-Status. Ein Headless-Browser ist nicht noetig. Der Abgleich bucht nichts von sich aus. Eindeutige Treffer landen als regulaerer Import in der bestehenden Vorschau, die per package_bought_id dedupliziert. Titel, die nach CC klingen, sich aber keiner Guthaben-Art sicher zuordnen lassen, gehen in eine Handpruefung statt geraten zu werden - sichtbar als Hinweis auf der Uebersicht und als Box beim Spieler. Beachtet werden dabei: stornierte Kaeufe werden uebersprungen, der Web- Bereich enthaelt nie CC-Pakete, und ein Redesign-Titel zaehlt als Redesign, auch wenn Custom Character darin vorkommt. Letzteres war auch in den Standard-Mappings des Datei-Imports falsch herum einsortiert. Der Lauf geht bewusst nur ueber bereits erfasste Spieler und haelt eine Sekunde Abstand zwischen den Anfragen, da die Seite fremde Infrastruktur ist. Voraussetzung dafuer ist, dass SteamIDs auf eine Schreibweise gebracht werden: die Legacy-Form, die Steam3-Form und die 64-Bit-Zahl bezeichnen dieselbe Person, wurden bisher aber als verschiedene Spieler gefuehrt. Gespeichert wird jetzt immer die 64-Bit-Form, Eingabe und Suche akzeptieren alle drei.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import Notice from './Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
import type { Resolution, UnmatchedPackage } from '../lib/types';
|
||||
|
||||
const CHOICES: { value: Resolution; label: string; hint: string }[] = [
|
||||
{ value: 'CC_PACKAGE', label: 'Gekauftes CC-Paket', hint: '+1 CC und +1 Redesign' },
|
||||
{ value: 'REDESIGN_PACKAGE', label: 'Gekauftes Redesign', hint: '+1 Redesign (bezahlt)' },
|
||||
{ value: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hint: '+1 Kulanz' },
|
||||
{ value: 'IGNORE', label: 'Nichts davon', hint: 'ohne Buchung abhaken' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Pakete, die nach CC klingen, aber von keiner Regel sicher zugeordnet wurden.
|
||||
* Sie werden bewusst nie automatisch gebucht, sondern hier entschieden.
|
||||
*/
|
||||
export default function UnmatchedBox({
|
||||
items,
|
||||
canResolve,
|
||||
onResolved,
|
||||
}: {
|
||||
items: UnmatchedPackage[];
|
||||
canResolve: boolean;
|
||||
onResolved: () => void;
|
||||
}) {
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const resolve = async (id: number, resolution: Resolution) => {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await api(`/api/scrape/unmatched/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
body: { resolution },
|
||||
});
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Konnte nicht gespeichert werden');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="block block-flag">
|
||||
<h2>Ungeklaerte Eintraege ({items.length})</h2>
|
||||
<Notice kind="warn">
|
||||
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
|
||||
liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
|
||||
sie hier einordnest.
|
||||
</Notice>
|
||||
|
||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||
|
||||
<ul className="cc-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="cc-item">
|
||||
<div>
|
||||
<strong>{item.rawTitle}</strong>
|
||||
<span className="badge">{item.rawTag}</span>
|
||||
<p className="muted small">
|
||||
{item.amount && `${item.amount} · `}
|
||||
Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{canResolve && (
|
||||
<div className="cc-actions">
|
||||
{CHOICES.map((choice) => (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
className={`btn btn-small${choice.value === 'IGNORE' ? ' btn-ghost' : ''}`}
|
||||
title={choice.hint}
|
||||
disabled={busyId !== null}
|
||||
onClick={() => void resolve(item.id, choice.value)}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,40 @@ export interface Player {
|
||||
forumName: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
gextensionUsage: number | null;
|
||||
gextensionSyncedAt: string | null;
|
||||
}
|
||||
|
||||
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
||||
|
||||
export interface UnmatchedPackage {
|
||||
id: number;
|
||||
externalId: string;
|
||||
playerId: number | null;
|
||||
steamId: string;
|
||||
rawTitle: string;
|
||||
rawTag: string;
|
||||
amount: string | null;
|
||||
purchasedAt: string | null;
|
||||
scrapedAt: string;
|
||||
resolved: boolean;
|
||||
resolvedAs: string | null;
|
||||
player: { id: number; displayName: string } | null;
|
||||
}
|
||||
|
||||
export interface UnmatchedOverview {
|
||||
total: number;
|
||||
players: { playerId: number; displayName: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface ScrapeSummary {
|
||||
importId: number | null;
|
||||
scannedPlayers: number;
|
||||
failedPlayers: { steamId: string; reason: string }[];
|
||||
newRows: number;
|
||||
duplicates: number;
|
||||
unclear: number;
|
||||
usageCounters: number;
|
||||
}
|
||||
|
||||
export interface CharacterRef {
|
||||
@@ -87,6 +121,7 @@ export interface PlayerDetail {
|
||||
characters: Character[];
|
||||
ledger: LedgerEntry[];
|
||||
ledgerTotal: number;
|
||||
unmatched: UnmatchedPackage[];
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Modal from '../components/Modal';
|
||||
import Notice from '../components/Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
import { useSession } from '../lib/session';
|
||||
import type { PlayerSummary } from '../lib/types';
|
||||
import type { PlayerSummary, ScrapeSummary, UnmatchedOverview } from '../lib/types';
|
||||
|
||||
interface SearchResult {
|
||||
total: number;
|
||||
@@ -26,6 +26,9 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (search: string, signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
@@ -42,6 +45,37 @@ export default function Dashboard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFlagged = useCallback(async () => {
|
||||
try {
|
||||
setFlagged(await api<UnmatchedOverview>('/api/scrape/unmatched/overview'));
|
||||
} catch {
|
||||
// Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stoeren.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFlagged();
|
||||
}, [loadFlagged]);
|
||||
|
||||
const runSync = async () => {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
|
||||
const parts = [`${result.scannedPlayers} Profile geprueft`];
|
||||
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestaetigung`);
|
||||
if (result.unclear > 0) parts.push(`${result.unclear} ungeklaert`);
|
||||
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(', ') + '.');
|
||||
await Promise.all([load(query), loadFlagged()]);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest.
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -56,11 +90,18 @@ export default function Dashboard() {
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<h1>Spieler</h1>
|
||||
{can('EDITOR') && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
Spieler anlegen
|
||||
</button>
|
||||
)}
|
||||
<div className="head-actions">
|
||||
{can('ADMIN') && (
|
||||
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
|
||||
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
|
||||
</button>
|
||||
)}
|
||||
{can('EDITOR') && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
Spieler anlegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -73,6 +114,24 @@ export default function Dashboard() {
|
||||
/>
|
||||
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
|
||||
|
||||
{flagged.total > 0 && (
|
||||
<Notice kind="warn">
|
||||
<strong>
|
||||
{flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '}
|
||||
{flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}:
|
||||
</strong>{' '}
|
||||
{flagged.players.map((entry, index) => (
|
||||
<span key={entry.playerId}>
|
||||
{index > 0 && ', '}
|
||||
<Link to={`/players/${entry.playerId}`}>
|
||||
{entry.displayName} ({entry.count})
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
@@ -185,7 +244,16 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
|
||||
/>
|
||||
|
||||
<label htmlFor="steamId">SteamID (optional)</label>
|
||||
<input id="steamId" value={steamId} onChange={(event) => setSteamId(event.target.value)} />
|
||||
<input
|
||||
id="steamId"
|
||||
value={steamId}
|
||||
onChange={(event) => setSteamId(event.target.value)}
|
||||
placeholder="76561198... oder STEAM_0:1:..."
|
||||
/>
|
||||
<p className="muted small">
|
||||
Beide Schreibweisen sind moeglich, auch <code className="mono">[U:1:...]</code>.
|
||||
Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.
|
||||
</p>
|
||||
|
||||
<label htmlFor="forumName">Forum-Name (optional)</label>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import BalanceCards from '../components/BalanceCards';
|
||||
import UnmatchedBox from '../components/UnmatchedBox';
|
||||
import Modal from '../components/Modal';
|
||||
import Notice from '../components/Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
@@ -87,6 +88,21 @@ export default function PlayerDetail() {
|
||||
|
||||
<BalanceCards balances={detail.balances} />
|
||||
|
||||
{detail.player.gextensionUsage !== null && (
|
||||
<p className="muted small usage-hint">
|
||||
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
|
||||
{detail.player.gextensionSyncedAt &&
|
||||
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
|
||||
. Nur zum Abgleich — wird nicht automatisch verrechnet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<UnmatchedBox
|
||||
items={detail.unmatched ?? []}
|
||||
canResolve={can('ADMIN')}
|
||||
onResolved={() => void load()}
|
||||
/>
|
||||
|
||||
{editor && (
|
||||
<section className="action-bar">
|
||||
<button
|
||||
@@ -140,6 +156,25 @@ export default function PlayerDetail() {
|
||||
>
|
||||
Neuer CC
|
||||
</button>
|
||||
{can('ADMIN') && detail.player.steamId && (
|
||||
<button
|
||||
type="button"
|
||||
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.')
|
||||
}
|
||||
>
|
||||
GExtension abgleichen
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -817,3 +817,19 @@ textarea {
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- GExtension-Abgleich --- */
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.block-flag {
|
||||
border-color: var(--warn);
|
||||
}
|
||||
|
||||
.usage-hint {
|
||||
margin: -0.5rem 0 1.25rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user