Antraege durchsuchbar, Fortschritt sichtbar, Spieler an einem Stueck

Der Abgleich zeigt jetzt, was er tut. Gelesen wird zuerst von jedem Board die
erste Seite - danach steht der Umfang fest, und der Balken zeigt einen
ehrlichen Anteil statt einer Schaetzung. Ein Protokoll schreibt Seite fuer
Seite mit.

Beide Antragslisten lassen sich durchsuchen und wahlweise mit dem neuesten
oder dem aeltesten Antrag zuerst anzeigen. Die Liste ohne zugeordneten
Spieler blaettert seitenweise, statt bei fuenfhundert Eintraegen die ersten
fuenfzig zu zeigen und den Rest zu verschweigen.

Neu darueber: "Ersteller ohne Spieler" - dieselben Antraege, nach Ersteller
zusammengefasst. Wer dreiundzwanzig Antraege gestellt hat, faellt dort auf,
waehrend er zwischen fuenfhundert Einzelantraegen untergeht. Ein Klick legt
den Spieler mit bereits eingetragenem Forum-Namen an.

Ein Spieler laesst sich jetzt an einem Stueck abarbeiten, statt zwischen drei
Seiten zu wechseln:

- Seine Forums-Antraege stehen auf der Spielerseite, aeltester zuerst, jeder
  als Link ins Forum. Offene sind dort direkt entscheidbar, ueber denselben
  Weg wie auf der Antraege-Seite - danach stehen sie dort nicht mehr offen.
- Pakete, die noch in einer Import-Vorschau liegen, erscheinen unter "Offene
  Pakete aus dem Abgleich" und lassen sich einzeln oder am Stueck buchen.
  Gebuchtes wird ueberall abgehakt, wo dieselbe Bestellung noch offen liegt,
  auch in einem zweiten Import - doppelt buchen ist ausgeschlossen. Wer ein
  Paket gar nicht buchen will, legt es weg, ohne das Konto zu beruehren.

Die beiden Antrags-Dialoge liegen dafuer jetzt unter components/, weil beide
Seiten sie brauchen.
This commit is contained in:
2026-09-21 00:49:38 +02:00
parent e65fdfc0ad
commit 293d882cb4
16 changed files with 1655 additions and 431 deletions
+18
View File
@@ -10,6 +10,7 @@ import {
listBoardChangeNotices,
listThreads,
listUnassignedThreads,
listUntrackedAuthors,
startScan,
} from '../services/forum.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -31,6 +32,8 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
open: z.enum(['true', 'false']).default('true'),
playerId: z.coerce.number().int().positive().optional(),
board: z.nativeEnum(ForumBoard).optional(),
q: z.string().trim().max(120).optional(),
order: z.enum(['newest', 'oldest']).default('newest'),
}),
request.query,
);
@@ -38,8 +41,10 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
return {
items: await listThreads({
onlyOpen: query.open === 'true',
order: query.order,
...(query.playerId !== undefined ? { playerId: query.playerId } : {}),
...(query.board ? { board: query.board } : {}),
...(query.q ? { query: query.q } : {}),
}),
};
});
@@ -49,16 +54,29 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
z.object({
q: z.string().trim().max(120).optional(),
take: z.coerce.number().int().min(1).max(200).default(50),
skip: z.coerce.number().int().min(0).default(0),
order: z.enum(['newest', 'oldest']).default('newest'),
}),
request.query,
);
return listUnassignedThreads({
take: query.take,
skip: query.skip,
order: query.order,
...(query.q ? { query: query.q } : {}),
});
});
/** Ersteller aus dem Forum, die hier unter keinem Namen geführt werden. */
app.get('/api/forum/authors/untracked', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({ take: z.coerce.number().int().min(1).max(500).default(100) }),
request.query,
);
return listUntrackedAuthors(query.take);
});
app.get('/api/forum/notices', { preHandler: requireAuth }, async () => {
return { items: await listBoardChangeNotices() };
});
+24
View File
@@ -10,6 +10,8 @@ import {
getImport,
listImports,
parseUpload,
bookRows,
dismissRows,
} from '../services/imports.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -63,6 +65,28 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
return commitImport(id, contextOf(request));
});
/**
* Einzelne Zeilen sofort buchen, statt auf den ganzen Import zu warten.
* Gedacht für den Weg über die Spielerseite: einen Spieler an einem Stück
* abarbeiten. Gebuchtes verschwindet danach aus der Vorschau.
*/
app.post('/api/imports/rows/book', { preHandler: requireEditor }, async (request) => {
const body = parseWith(
z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
request.body,
);
return bookRows(body.rowIds, contextOf(request));
});
/** Zeilen aus der Vorschau nehmen, ohne sie zu buchen. */
app.post('/api/imports/rows/dismiss', { preHandler: requireEditor }, async (request) => {
const body = parseWith(
z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
request.body,
);
return dismissRows(body.rowIds, contextOf(request));
});
app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request));
+126 -27
View File
@@ -14,6 +14,13 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
{ board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' },
];
/// Die Board-Pfade in lesbarer Form, für das Protokoll des Laufs.
const BOARD_NAMES: Record<string, string> = {
'board/66-custom-characters': 'Antragsübersicht',
'board/355-aktive-ccs': 'Aktive CCs',
'board/113-cc-archiv': 'Archiv',
};
const MIN_REQUEST_GAP_MS = 1200;
/// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin.
const MAX_REQUEST_GAP_MS = 10000;
@@ -118,6 +125,10 @@ export interface ScanState {
/// Woran gerade gelesen wird, für die Anzeige während des Laufs.
currentBoard: string | null;
pagesRead: number;
/// Steht fest, sobald von jedem Board die erste Seite gelesen ist. Vorher 0.
totalPages: number;
/// Was der Lauf bisher getan hat, jüngster Eintrag zuletzt.
log: string[];
summary: ForumScanSummary | null;
error: string | null;
}
@@ -136,10 +147,20 @@ const scanState: ScanState = {
finishedAt: null,
currentBoard: null,
pagesRead: 0,
totalPages: 0,
log: [],
summary: null,
error: null,
};
/// Damit ein langer Lauf den Speicher nicht vollschreibt.
const MAX_LOG_LINES = 200;
function protokoll(zeile: string): void {
scanState.log.push(zeile);
if (scanState.log.length > MAX_LOG_LINES) scanState.log.shift();
}
export function getScanState(): ScanState {
return { ...scanState };
}
@@ -155,6 +176,8 @@ export function startScan(ctx: ActionContext): ScanState {
scanState.finishedAt = null;
scanState.currentBoard = null;
scanState.pagesRead = 0;
scanState.totalPages = 0;
scanState.log = [];
scanState.summary = null;
scanState.error = null;
@@ -206,40 +229,62 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
// dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben.
const gefunden = new Map<number, { thread: ForumThread; board: ForumBoard }>();
// Erst von jedem Board die erste Seite. Danach steht der Umfang des Laufs
// fest, und die Anzeige kann einen ehrlichen Fortschritt zeigen statt zu
// raten, wie viel noch kommt.
const offen: { board: ForumBoard; path: string; seiten: number; gelesen: number }[] = [];
for (const { board, path } of BOARDS) {
let gelesen = 0;
scanState.currentBoard = path;
protokoll(`${BOARD_NAMES[path] ?? path}: Umfang wird ermittelt`);
try {
const first = await fetchPage(path, 1);
gelesen += 1;
summary.scannedPages += 1;
scanState.pagesRead = summary.scannedPages;
const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board });
for (let page = 2; page <= seiten; page += 1) {
const html = await fetchPage(path, page);
gelesen += 1;
const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
scanState.totalPages += seiten;
offen.push({ board, path, seiten, gelesen: 1 });
protokoll(`${BOARD_NAMES[path] ?? path}: Seite 1 von ${seiten} gelesen`);
} catch (error) {
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
summary.failedBoards.push({ board: path, reason: grund, pagesRead: 0 });
protokoll(`${BOARD_NAMES[path] ?? path}: nicht erreichbar (${grund})`);
}
}
for (const eintrag of offen) {
const name = BOARD_NAMES[eintrag.path] ?? eintrag.path;
scanState.currentBoard = eintrag.path;
try {
for (let page = 2; page <= eintrag.seiten; page += 1) {
const html = await fetchPage(eintrag.path, page);
eintrag.gelesen += 1;
summary.scannedPages += 1;
scanState.pagesRead = summary.scannedPages;
for (const thread of parseBoard(html)) gefunden.set(thread.threadId, { thread, board });
for (const thread of parseBoard(html)) {
gefunden.set(thread.threadId, { thread, board: eintrag.board });
}
protokoll(`${name}: Seite ${page} von ${eintrag.seiten} gelesen`);
}
summary.scannedBoards += 1;
protokoll(`${name}: fertig`);
} catch (error) {
// Was bis hierher gelesen wurde, bleibt erhalten und wird gleich
// verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16
// Seiten mit in den Papierkorb nehmen.
summary.failedBoards.push({
board: path,
reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
pagesRead: gelesen,
});
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
summary.failedBoards.push({ board: eintrag.path, reason: grund, pagesRead: eintrag.gelesen });
protokoll(`${name}: abgebrochen nach Seite ${eintrag.gelesen} (${grund})`);
}
}
summary.rateLimited = rateLimitHits;
scanState.currentBoard = null;
protokoll(`${gefunden.size} Threads gelesen, wird gespeichert`);
const relevant: { thread: ForumThread; board: ForumBoard }[] = [];
for (const eintrag of gefunden.values()) {
@@ -353,6 +398,12 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
});
}
protokoll(
summary.newThreads > 0
? `${summary.newThreads} neue Anträge erfasst`
: 'Keine neuen Anträge',
);
await recordAudit(prisma, ctx, {
action: 'forum.scan',
targetType: 'forum',
@@ -388,10 +439,33 @@ async function forumNameIndex(): Promise<Map<string, number>> {
return index;
}
/// Neueste oder älteste zuerst. Massgeblich ist die Thread-ID: sie waechst
/// mit der Zeit und bleibt stabil, waehrend der Zeitpunkt des letzten Abrufs
/// nach einem Lauf bei allen Threads derselbe ist.
export type ThreadOrder = 'newest' | 'oldest';
function orderOf(order: ThreadOrder | undefined): Prisma.ForumThreadOrderByWithRelationInput {
return { threadId: order === 'oldest' ? 'asc' : 'desc' };
}
/** Freitextsuche über Titel und Ersteller. */
function sucheNach(query: string | undefined): Prisma.ForumThreadWhereInput {
const suche = query?.trim();
if (!suche) return {};
return {
OR: [
{ title: { contains: suche, mode: 'insensitive' } },
{ authorName: { contains: suche, mode: 'insensitive' } },
],
};
}
export async function listThreads(options: {
onlyOpen?: boolean;
playerId?: number;
board?: ForumBoard;
query?: string;
order?: ThreadOrder;
}) {
return prisma.forumThread.findMany({
where: {
@@ -401,8 +475,9 @@ export async function listThreads(options: {
...(options.onlyOpen ? { needsClassification: true, playerId: { not: null } } : {}),
...(options.playerId !== undefined ? { playerId: options.playerId } : {}),
...(options.board ? { currentBoard: options.board } : {}),
...sucheNach(options.query),
},
orderBy: [{ lastSeenAt: 'desc' }, { threadId: 'desc' }],
orderBy: orderOf(options.order),
include: {
player: { select: { id: true, displayName: true } },
linkedCharacter: { select: { id: true, name: true } },
@@ -413,30 +488,29 @@ export async function listThreads(options: {
/**
* Threads, zu denen kein Spieler gefunden wurde. Das Archiv reicht Jahre
* zurück und enthält hunderte Anträge von Leuten, die hier nie erfasst
* wurden - deshalb durchsuchbar und begrenzt statt als eine lange Liste.
* wurden - deshalb durchsuchbar und seitenweise statt als eine lange Liste.
*/
export async function listUnassignedThreads(options: { query?: string; take?: number }) {
export async function listUnassignedThreads(options: {
query?: string;
take?: number;
skip?: number;
order?: ThreadOrder;
}) {
const take = Math.min(Math.max(options.take ?? 50, 1), 200);
const suche = options.query?.trim();
const skip = Math.max(options.skip ?? 0, 0);
const where: Prisma.ForumThreadWhereInput = {
playerId: null,
needsClassification: true,
...(suche
? {
OR: [
{ title: { contains: suche, mode: 'insensitive' } },
{ authorName: { contains: suche, mode: 'insensitive' } },
],
}
: {}),
...sucheNach(options.query),
};
const [items, total] = await Promise.all([
prisma.forumThread.findMany({
where,
orderBy: [{ threadId: 'desc' }],
orderBy: orderOf(options.order),
take,
skip,
include: {
player: { select: { id: true, displayName: true } },
linkedCharacter: { select: { id: true, name: true } },
@@ -445,7 +519,32 @@ export async function listUnassignedThreads(options: { query?: string; take?: nu
prisma.forumThread.count({ where }),
]);
return { items, total };
return { items, total, take, skip };
}
/**
* Wer im Forum Anträge gestellt hat, hier aber unter keinem Namen geführt
* wird - zusammengefasst je Ersteller statt je Antrag. So sieht man auf einen
* Blick, wen anzulegen sich lohnt: drei Anträge unter einem Namen sind ein
* anderes Signal als ein einzelner.
*/
export async function listUntrackedAuthors(take = 100) {
const gruppen = await prisma.forumThread.groupBy({
by: ['authorName'],
where: { playerId: null, needsClassification: true, authorName: { not: null } },
_count: { _all: true },
_max: { threadId: true },
orderBy: { _count: { threadId: 'desc' } },
take: Math.min(Math.max(take, 1), 500),
});
return {
items: gruppen.map((gruppe) => ({
authorName: gruppe.authorName as string,
threadCount: gruppe._count._all,
latestThreadId: gruppe._max.threadId,
})),
};
}
/**
+166
View File
@@ -485,6 +485,172 @@ export async function commitImport(importId: number, ctx: ActionContext) {
return result;
}
/**
* Offene Paket-Zeilen eines Spielers, quer über alle noch nicht bestätigten
* Importe. Damit lässt sich ein Spieler an einem Stück abarbeiten, statt
* seine Zeilen in der Import-Vorschau zwischen fremden zu suchen.
*/
export async function listPendingRowsFor(playerId: number) {
return prisma.importRow.findMany({
where: {
resolvedPlayerId: playerId,
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
import: { status: ImportStatus.PENDING },
},
orderBy: [{ purchasedAt: 'asc' }, { lineNo: 'asc' }],
select: {
id: true,
importId: true,
productName: true,
purchasedAt: true,
target: true,
externalId: true,
import: { select: { id: true, filename: true, createdAt: true } },
},
});
}
/**
* Bucht einzelne Zeilen sofort, ohne auf die Bestätigung des ganzen Imports
* zu warten. Gebuchte Zeilen werden als erledigt vermerkt - und zwar überall,
* wo dieselbe Bestellung noch offen liegt. Ohne das stünde sie später in der
* Import-Vorschau ein zweites Mal zur Buchung bereit.
*
* Gebucht wird über dieselbe Regel wie beim Import: ein CC-Paket schreibt
* zwei Zeilen, ein Redesign-Paket eine. Es gibt keinen zweiten Buchungsweg.
*/
export async function bookRows(rowIds: number[], ctx: ActionContext) {
if (rowIds.length === 0) {
throw badRequest('NOTHING_SELECTED', 'Es ist nichts zum Buchen ausgewählt');
}
const rows = await prisma.importRow.findMany({
where: {
id: { in: rowIds },
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
import: { status: ImportStatus.PENDING },
},
orderBy: [{ purchasedAt: 'asc' }, { lineNo: 'asc' }],
});
if (rows.length === 0) {
throw conflict('NOTHING_TO_BOOK', 'Keine dieser Zeilen steht noch zur Buchung an');
}
return prisma.$transaction(async (tx) => {
let booked = 0;
let skipped = 0;
const erledigt: number[] = [];
const externalIds: string[] = [];
for (const row of rows) {
if (row.resolvedPlayerId === null) {
// Hier wird gezielt für einen bekannten Spieler gebucht; einen neuen
// anzulegen ist Sache der Import-Vorschau.
skipped += 1;
continue;
}
if (row.externalId) {
const bereitsGebucht = await tx.importedOrder.findUnique({
where: { externalId: row.externalId },
select: { id: true },
});
if (bereitsGebucht) {
skipped += 1;
erledigt.push(row.id);
externalIds.push(row.externalId);
continue;
}
}
const target =
row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE';
const groupId = randomUUID();
await tx.creditLedger.createMany({
data: buildPackageRows(target, {
playerId: row.resolvedPlayerId,
groupId,
note: row.productName ? `Import: ${row.productName}` : `Import #${row.importId}`,
importId: row.importId,
actorId: ctx.actorId,
}),
});
if (row.externalId) {
await tx.importedOrder.create({
data: {
externalId: row.externalId,
importId: row.importId,
playerId: row.resolvedPlayerId,
target: row.target ?? ProductTarget.CC_PACKAGE,
groupId,
},
});
externalIds.push(row.externalId);
}
erledigt.push(row.id);
booked += 1;
}
// Die gebuchten Zeilen selbst und jede andere offene Zeile mit derselben
// Bestellung abhaken, damit nichts ein zweites Mal gebucht wird.
if (erledigt.length > 0) {
await tx.importRow.updateMany({
where: { id: { in: erledigt } },
data: { plan: PLANS.SKIP_DUPLICATE },
});
}
if (externalIds.length > 0) {
await tx.importRow.updateMany({
where: {
externalId: { in: externalIds },
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
},
data: { plan: PLANS.SKIP_DUPLICATE },
});
}
await recordAudit(tx, ctx, {
action: 'import.rows_booked',
targetType: 'player',
targetId: rows[0]?.resolvedPlayerId ?? null,
details: { booked, skipped, rowIds: erledigt },
});
return { booked, skipped };
});
}
/**
* Nimmt offene Zeilen aus der Vorschau, ohne sie zu buchen. Für Pakete, die
* hier nichts verloren haben - ein Kauf, der schon von Hand eingetragen war.
*/
export async function dismissRows(rowIds: number[], ctx: ActionContext) {
if (rowIds.length === 0) {
throw badRequest('NOTHING_SELECTED', 'Es ist nichts ausgewählt');
}
const result = await prisma.importRow.updateMany({
where: {
id: { in: rowIds },
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
import: { status: ImportStatus.PENDING },
},
data: { plan: PLANS.SKIP_IGNORED },
});
await recordAudit(prisma, ctx, {
action: 'import.rows_dismissed',
targetType: 'import_row',
targetId: null,
details: { count: result.count, rowIds },
});
return { dismissed: result.count };
}
export async function discardImport(importId: number, ctx: ActionContext): Promise<void> {
const record = await prisma.import.findUnique({ where: { id: importId } });
if (!record) throw notFound('Import');
+25 -14
View File
@@ -80,20 +80,29 @@ export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler');
const [balances, characters, ledger, unmatched, aliases] = await Promise.all([
getBalances(playerId),
listCharacters(playerId),
listLedger({ playerId, take: 200 }),
prisma.unmatchedPackage.findMany({
where: { playerId, resolved: false },
orderBy: { scrapedAt: 'desc' },
include: { player: { select: { id: true, displayName: true } } },
}),
prisma.playerAlias.findMany({
where: { playerId },
orderBy: [{ type: 'asc' }, { value: 'asc' }],
}),
]);
// Threads und offene Paket-Zeilen kommen mit, damit sich ein Spieler an
// einem Stück abarbeiten lässt, statt zwischen drei Seiten zu wechseln.
const { listThreads } = await import('./forum.js');
const { listPendingRowsFor } = await import('./imports.js');
const [balances, characters, ledger, unmatched, aliases, threads, pendingRows] =
await Promise.all([
getBalances(playerId),
listCharacters(playerId),
listLedger({ playerId, take: 200 }),
prisma.unmatchedPackage.findMany({
where: { playerId, resolved: false },
orderBy: { scrapedAt: 'desc' },
include: { player: { select: { id: true, displayName: true } } },
}),
prisma.playerAlias.findMany({
where: { playerId },
orderBy: [{ type: 'asc' }, { value: 'asc' }],
}),
// Ältester zuerst: Anträge werden der Reihe nach abgearbeitet.
listThreads({ playerId, order: 'oldest' }),
listPendingRowsFor(playerId),
]);
return {
player,
@@ -103,6 +112,8 @@ export async function getPlayerDetail(playerId: number) {
ledgerTotal: ledger.total,
unmatched,
aliases,
threads,
pendingRows,
};
}
+104
View File
@@ -311,3 +311,107 @@ describe('Konten beim Abgleich', () => {
assert.deepEqual(await scraper.steamIdsOf(spieler.id), []);
});
});
describe('Sortieren, Blättern und Suchen', () => {
before(async () => {
// Eigener Namensraum, damit die Faelle davor nicht hineinspielen.
for (const id of [8801, 8802, 8803, 8804, 8805]) {
await offenerThread(id, `Serienantrag ${id}`, 'Vielschreiber');
}
});
it('sortiert nach jüngstem und ältestem Antrag', async () => {
const neueste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'newest' });
assert.deepEqual(
neueste.items.map((t) => t.threadId),
[8805, 8804, 8803, 8802, 8801],
);
const aelteste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'oldest' });
assert.deepEqual(
aelteste.items.map((t) => t.threadId),
[8801, 8802, 8803, 8804, 8805],
);
});
it('blättert seitenweise durch und nennt die volle Zahl', async () => {
const erste = await forum.listUnassignedThreads({
query: 'Vielschreiber',
order: 'oldest',
take: 2,
});
assert.deepEqual(
erste.items.map((t) => t.threadId),
[8801, 8802],
);
assert.equal(erste.total, 5);
const zweite = await forum.listUnassignedThreads({
query: 'Vielschreiber',
order: 'oldest',
take: 2,
skip: 2,
});
assert.deepEqual(
zweite.items.map((t) => t.threadId),
[8803, 8804],
'keine Ueberschneidung mit der ersten Seite',
);
});
it('durchsucht auch die Arbeitsliste', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Suchbar', steamId: '76561198000015555', forumName: 'Suchbar' },
ctx,
);
await offenerThread(8901, 'Ein ganz bestimmter Antrag', 'Suchbar');
await offenerThread(8902, 'Etwas voellig anderes', 'Suchbar');
await forum.claimThreadsForPlayer(spieler.id, ['Suchbar']);
const treffer = await forum.listThreads({ onlyOpen: true, query: 'bestimmter' });
assert.deepEqual(
treffer.map((t) => t.threadId),
[8901],
);
const nachAutor = await forum.listThreads({ onlyOpen: true, query: 'Suchbar' });
assert.equal(nachAutor.length, 2, 'die Suche greift auch auf den Ersteller');
});
});
describe('Ersteller ohne Spieler', () => {
it('fasst die Anträge je Ersteller zusammen', async () => {
const liste = await forum.listUntrackedAuthors();
const vielschreiber = liste.items.find((a) => a.authorName === 'Vielschreiber');
assert.ok(vielschreiber, 'wer fuenf Antraege gestellt hat, faellt auf');
assert.equal(vielschreiber?.threadCount, 5);
assert.equal(vielschreiber?.latestThreadId, 8805);
});
it('führt niemanden auf, der längst einem Spieler gehört', async () => {
const liste = await forum.listUntrackedAuthors();
assert.equal(
liste.items.some((a) => a.authorName === 'Suchbar'),
false,
);
});
it('verschwindet aus der Liste, sobald der Spieler angelegt ist', async () => {
assert.equal(
(await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'),
true,
);
await players.createPlayer(
{ displayName: 'Vielschreiber', steamId: '76561198000016666', forumName: 'Vielschreiber' },
ctx,
);
assert.equal(
(await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'),
false,
'seine Antraege stehen jetzt in der Arbeitsliste',
);
});
});
+142
View File
@@ -241,3 +241,145 @@ describe('Kollisionen beim Buchen', () => {
assert.equal(balances.CC, 1, 'genau einmal gebucht, nicht doppelt');
});
});
describe('Einzelne Zeilen beim Spieler buchen', () => {
// Jeder Fall bekommt eigene Bestellnummern: ein gebuchter Dedup-Anker gilt
// dauerhaft und wuerde sonst den naechsten Fall stillschweigend leerlaufen
// lassen. Die Spieler stehen fest - beim Weg ueber die Spielerseite geht es
// genau darum, einen Bekannten abzuarbeiten.
let eule = 0;
let specht = 0;
function datei(praefix: string): Buffer {
return Buffer.from(
[
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
`${praefix}-1;76561198000000011;Eule;CC-Paket Groß;2025-02-01`,
`${praefix}-2;76561198000000011;Eule;Redesign Gutschein;2025-02-02`,
`${praefix}-3;76561198000000012;Specht;CC-Paket Klein;2025-02-03`,
].join('\n'),
'utf8',
);
}
async function vorschau(praefix: string, name = `${praefix}.csv`) {
const parsed = imports.parseUpload(name, datei(praefix));
const id = await imports.createImport(name, parsed, ctx);
await imports.applyMapping(id, MAPPING, ctx);
return id;
}
before(async () => {
eule = (
await db.prisma.player.create({
data: { displayName: 'Eule', steamId: '76561198000000011' },
})
).id;
specht = (
await db.prisma.player.create({
data: { displayName: 'Specht', steamId: '76561198000000012' },
})
).id;
});
it('bucht nur die gewählte Zeile und lässt den Rest der Vorschau offen', async () => {
const importId = await vorschau('EINZ');
const offen = await imports.listPendingRowsFor(eule);
assert.equal(offen.length, 2, 'ein CC-Paket und ein Redesign-Gutschein');
assert.equal(offen[0]?.productName, 'CC-Paket Groß', 'ältester Kauf zuerst');
const ergebnis = await imports.bookRows([offen[0]!.id], ctx);
assert.equal(ergebnis.booked, 1);
// Ein CC-Paket schreibt zwei Zeilen - dieselbe Regel wie beim Import.
const stand = await credits.getBalances(eule);
assert.equal(stand.CC, 1);
assert.equal(stand.REDESIGN_PAID, 1);
assert.equal(
(await imports.listPendingRowsFor(eule)).length,
1,
'die gebuchte Zeile ist aus der Liste, die andere wartet weiter',
);
const nachher = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } });
assert.equal(nachher.status, 'PENDING', 'der Import als Ganzes bleibt offen');
await imports.discardImport(importId, ctx);
});
it('bucht dieselbe Bestellung nicht ein zweites Mal über den Import', async () => {
const importId = await vorschau('ZWEI');
const offen = await imports.listPendingRowsFor(specht);
assert.equal(offen.length, 1);
await imports.bookRows([offen[0]!.id], ctx);
const vorher = await credits.getBalances(specht);
// Der Import wird danach ganz normal bestätigt - die eben gebuchte Zeile
// steht dabei gar nicht mehr zur Buchung an, sie ist ja abgehakt.
const ergebnis = await imports.commitImport(importId, ctx);
assert.equal(ergebnis.booked, 2, 'nur die beiden Zeilen der Eule bleiben übrig');
assert.deepEqual(
await credits.getBalances(specht),
vorher,
'sonst stünde das Paket doppelt im Konto',
);
});
it('hakt dieselbe Bestellung auch in einem zweiten offenen Import ab', async () => {
// Zweimal dieselbe Datei eingelesen, etwa weil der Abgleich doppelt lief.
const ersterImport = await vorschau('DREI', 'lauf-a.csv');
const zweiterImport = await vorschau('DREI', 'lauf-b.csv');
const offen = await imports.listPendingRowsFor(eule);
const redesigns = offen.filter((row) => row.externalId === 'DREI-2');
assert.equal(redesigns.length, 2, 'dieselbe Bestellung liegt in beiden Vorschauen');
const vorher = await credits.getBalances(eule);
await imports.bookRows([redesigns[0]!.id], ctx);
assert.equal(
(await imports.listPendingRowsFor(eule)).filter((row) => row.externalId === 'DREI-2').length,
0,
'die Zwillingszeile im anderen Import ist mit abgehakt',
);
assert.equal(
(await credits.getBalances(eule)).REDESIGN_PAID,
vorher.REDESIGN_PAID + 1,
'einmal gekauft, einmal gebucht',
);
await imports.discardImport(ersterImport, ctx);
await imports.discardImport(zweiterImport, ctx);
});
it('weist eine leere Auswahl ab', async () => {
await assert.rejects(
() => imports.bookRows([], ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_SELECTED',
);
});
it('nimmt Zeilen auch ohne Buchung aus der Vorschau', async () => {
const importId = await vorschau('VIER');
const offen = await imports.listPendingRowsFor(specht);
assert.equal(offen.length, 1);
const vorher = await credits.getBalances(specht);
const ergebnis = await imports.dismissRows(
offen.map((row) => row.id),
ctx,
);
assert.equal(ergebnis.dismissed, 1);
assert.deepEqual(await imports.listPendingRowsFor(specht), []);
assert.deepEqual(await credits.getBalances(specht), vorher, 'weggelegt ist nicht gebucht');
await imports.discardImport(importId, ctx);
});
});