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,256 @@
|
||||
import { ImportStatus, type Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import {
|
||||
classifyPackage,
|
||||
fetchProfile,
|
||||
type ScrapedProfile,
|
||||
} from '../lib/gextension.js';
|
||||
import { PLANS } from './imports.js';
|
||||
import { recordAudit, type ActionContext } from './audit.js';
|
||||
|
||||
/** SteamIDs, die im CC-Manager bereits erfasst sind. */
|
||||
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
|
||||
const players = await prisma.player.findMany({
|
||||
where: { steamId: { not: null } },
|
||||
select: { id: true, steamId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
return players.map((player) => ({ playerId: player.id, steamId: player.steamId as string }));
|
||||
}
|
||||
|
||||
export interface ScrapeSummary {
|
||||
importId: number | null;
|
||||
scannedPlayers: number;
|
||||
failedPlayers: { steamId: string; reason: string }[];
|
||||
newRows: number;
|
||||
duplicates: number;
|
||||
unclear: number;
|
||||
usageCounters: number;
|
||||
}
|
||||
|
||||
// Eigener Namensraum, damit sich Scraper und Datei-Import beim Dedup nicht
|
||||
// in die Quere kommen.
|
||||
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
|
||||
|
||||
/**
|
||||
* Holt die Profile der uebergebenen Spieler und legt daraus einen Import an,
|
||||
* der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts
|
||||
* automatisch gebucht.
|
||||
*/
|
||||
export async function scrapePlayers(
|
||||
targets: { playerId: number; steamId: string }[],
|
||||
ctx: ActionContext,
|
||||
): Promise<ScrapeSummary> {
|
||||
const summary: ScrapeSummary = {
|
||||
importId: null,
|
||||
scannedPlayers: 0,
|
||||
failedPlayers: [],
|
||||
newRows: 0,
|
||||
duplicates: 0,
|
||||
unclear: 0,
|
||||
usageCounters: 0,
|
||||
};
|
||||
|
||||
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
|
||||
let lineNo = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
let profile: ScrapedProfile;
|
||||
try {
|
||||
profile = await fetchProfile(target.steamId);
|
||||
summary.scannedPlayers += 1;
|
||||
} catch (error) {
|
||||
summary.failedPlayers.push({
|
||||
steamId: target.steamId,
|
||||
reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const pkg of profile.packages) {
|
||||
const classification = classifyPackage(pkg);
|
||||
const externalId = externalIdFor(pkg.packageBoughtId);
|
||||
|
||||
if (classification.kind === 'usage_counter') {
|
||||
await prisma.player.update({
|
||||
where: { id: target.playerId },
|
||||
data: { gextensionUsage: classification.times, gextensionSyncedAt: new Date() },
|
||||
});
|
||||
summary.usageCounters += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (classification.kind === 'unclear') {
|
||||
await prisma.unmatchedPackage.upsert({
|
||||
where: { externalId },
|
||||
create: {
|
||||
externalId,
|
||||
playerId: target.playerId,
|
||||
steamId: profile.steamId64,
|
||||
rawTitle: pkg.packageName,
|
||||
rawTag: pkg.serverBundle,
|
||||
amount: pkg.amount || null,
|
||||
purchasedAt: pkg.purchasedAt,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
summary.unclear += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (classification.kind === 'ignore') continue;
|
||||
|
||||
const alreadyBooked = await prisma.importedOrder.findUnique({
|
||||
where: { externalId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyBooked) {
|
||||
summary.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
lineNo += 1;
|
||||
rows.push({
|
||||
lineNo,
|
||||
externalId,
|
||||
playerKey: profile.steamId64,
|
||||
playerName: profile.nickname,
|
||||
productName: pkg.packageName,
|
||||
purchasedAt: pkg.purchasedAt,
|
||||
target: classification.target,
|
||||
resolvedPlayerId: target.playerId,
|
||||
plan: PLANS.BOOK,
|
||||
raw: {
|
||||
package_name: pkg.packageName,
|
||||
serverbundle_name: pkg.serverBundle,
|
||||
amount: pkg.amount,
|
||||
gateway: pkg.gateway,
|
||||
} as Prisma.InputJsonValue,
|
||||
});
|
||||
summary.newRows += 1;
|
||||
}
|
||||
|
||||
// Auch ohne neue Pakete festhalten, dass das Profil gesehen wurde.
|
||||
await prisma.player.update({
|
||||
where: { id: target.playerId },
|
||||
data: { gextensionSyncedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
const created = await prisma.import.create({
|
||||
data: {
|
||||
filename: `GExtension-Abgleich ${new Date().toLocaleDateString('de-DE')}`,
|
||||
status: ImportStatus.PENDING,
|
||||
rowCount: rows.length,
|
||||
importedById: ctx.actorId,
|
||||
mapping: { source: 'gextension' } as Prisma.InputJsonValue,
|
||||
rows: { createMany: { data: rows } },
|
||||
},
|
||||
});
|
||||
summary.importId = created.id;
|
||||
}
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: 'scrape.run',
|
||||
targetType: 'import',
|
||||
targetId: summary.importId,
|
||||
details: {
|
||||
scannedPlayers: summary.scannedPlayers,
|
||||
newRows: summary.newRows,
|
||||
duplicates: summary.duplicates,
|
||||
unclear: summary.unclear,
|
||||
failed: summary.failedPlayers.length,
|
||||
},
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// --- Handpruefung der unklaren Eintraege ---
|
||||
|
||||
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
||||
|
||||
export async function listUnmatched(onlyOpen = true) {
|
||||
return prisma.unmatchedPackage.findMany({
|
||||
where: onlyOpen ? { resolved: false } : {},
|
||||
orderBy: [{ scrapedAt: 'desc' }, { id: 'desc' }],
|
||||
include: { player: { select: { id: true, displayName: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */
|
||||
export async function unmatchedOverview() {
|
||||
const open = await prisma.unmatchedPackage.findMany({
|
||||
where: { resolved: false },
|
||||
select: { playerId: true, player: { select: { displayName: true } } },
|
||||
});
|
||||
|
||||
const byPlayer = new Map<number, { playerId: number; displayName: string; count: number }>();
|
||||
for (const entry of open) {
|
||||
if (entry.playerId === null) continue;
|
||||
const current = byPlayer.get(entry.playerId);
|
||||
if (current) {
|
||||
current.count += 1;
|
||||
} else {
|
||||
byPlayer.set(entry.playerId, {
|
||||
playerId: entry.playerId,
|
||||
displayName: entry.player?.displayName ?? 'Unbekannt',
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { total: open.length, players: [...byPlayer.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
||||
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
|
||||
* ein Redesign mitbringt.
|
||||
*/
|
||||
export async function resolveUnmatched(
|
||||
id: number,
|
||||
resolution: Resolution,
|
||||
ctx: ActionContext,
|
||||
): Promise<void> {
|
||||
const entry = await prisma.unmatchedPackage.findUnique({ where: { id } });
|
||||
if (!entry) throw badRequest('NOT_FOUND', 'Eintrag nicht gefunden');
|
||||
if (entry.resolved) throw badRequest('ALREADY_RESOLVED', 'Dieser Eintrag ist schon entschieden');
|
||||
|
||||
if (resolution !== 'IGNORE') {
|
||||
if (entry.playerId === null) {
|
||||
throw badRequest('NO_PLAYER', 'Dem Eintrag ist kein Spieler zugeordnet');
|
||||
}
|
||||
|
||||
const { bookCcPackage, bookKulanz, bookRedesignPackage } = await import('./credits.js');
|
||||
const note = `GExtension: ${entry.rawTitle}`;
|
||||
|
||||
if (resolution === 'CC_PACKAGE') {
|
||||
await bookCcPackage(entry.playerId, ctx, { note });
|
||||
} else if (resolution === 'REDESIGN_PACKAGE') {
|
||||
await bookRedesignPackage(entry.playerId, ctx, { note });
|
||||
} else {
|
||||
await bookKulanz(entry.playerId, ctx, { note });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.unmatchedPackage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
resolved: true,
|
||||
resolvedAs: resolution,
|
||||
resolvedById: ctx.actorId,
|
||||
resolvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: 'scrape.resolve',
|
||||
targetType: 'unmatched_package',
|
||||
targetId: id,
|
||||
details: { rawTitle: entry.rawTitle, resolution, playerId: entry.playerId },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user