Fachlogik fuer Guthaben, CC-Lebenszyklus und Import

Die beiden zentralen Regeln stehen jeweils an genau einer Stelle:

Ein CC-Paket erzeugt immer CC +1 und REDESIGN_PAID +1. Die Zeilen dafuer baut
buildPackageRows, das sowohl die manuelle Eingabe als auch der Import nutzen -
so koennen beide Wege nicht auseinanderlaufen.

Ein CC kommt nur dann gratis zurueck, wenn er wegen Spieler-Inaktivitaet
entfernt wurde. Ein reworkter CC existiert im Original nicht mehr und kostet
deshalb ein Redesign- oder CC-Guthaben.

Ueberzogene Toepfe werden zugelassen statt blockiert; die Aktion meldet das
ueber ein overdrawn-Flag zurueck. Storniert wird per Gegenbuchung, nie durch
Loeschen. Der Import berechnet den Plan vorab und dedupliziert ueber die
Bestell-ID, sowohl gegen bereits gebuchte als auch innerhalb einer Datei.
This commit is contained in:
2026-09-18 11:37:31 +02:00
parent 3d2b87297e
commit f3e31cdd17
5 changed files with 1379 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { conflict, notFound } from '../errors.js';
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
import { listCharacters } from './characters.js';
import { recordAudit, type ActionContext } from './audit.js';
export interface PlayerInput {
displayName: string;
steamId?: string | null;
forumName?: string | null;
notes?: string | null;
}
export async function searchPlayers(query: string, take = 50, skip = 0) {
const trimmed = query.trim();
const where: Prisma.PlayerWhereInput =
trimmed.length === 0
? {}
: {
OR: [
{ displayName: { contains: trimmed, mode: 'insensitive' } },
{ steamId: { contains: trimmed, mode: 'insensitive' } },
{ forumName: { contains: trimmed, mode: 'insensitive' } },
],
};
const [players, total] = await Promise.all([
prisma.player.findMany({
where,
orderBy: { displayName: 'asc' },
take,
skip,
include: { _count: { select: { characters: true } } },
}),
prisma.player.count({ where }),
]);
const balances = await getBalancesForMany(players.map((p) => p.id));
const activeCounts = await prisma.character.groupBy({
by: ['playerId'],
where: { playerId: { in: players.map((p) => p.id) }, status: 'ACTIVE' },
_count: { _all: true },
});
const activeByPlayer = new Map(activeCounts.map((c) => [c.playerId, c._count._all]));
return {
total,
items: players.map((player) => ({
id: player.id,
displayName: player.displayName,
steamId: player.steamId,
forumName: player.forumName,
characterCount: player._count.characters,
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
balances: balances.get(player.id) as Balances,
})),
};
}
export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler');
const [balances, characters, ledger] = await Promise.all([
getBalances(playerId),
listCharacters(playerId),
listLedger({ playerId, take: 200 }),
]);
return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total };
}
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
const existing = await prisma.player.findUnique({ where: { steamId }, select: { id: true } });
if (existing && existing.id !== exceptId) {
throw conflict('STEAMID_TAKEN', 'Diese SteamID ist bereits einem Spieler zugeordnet');
}
}
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
const steamId = input.steamId?.trim() || null;
if (steamId) await assertSteamIdFree(steamId);
return prisma.$transaction(async (tx) => {
const created = await tx.player.create({
data: {
displayName: input.displayName.trim(),
steamId,
forumName: input.forumName?.trim() || null,
notes: input.notes?.trim() || null,
},
});
await recordAudit(tx, ctx, {
action: 'player.create',
targetType: 'player',
targetId: created.id,
details: { displayName: created.displayName, steamId: created.steamId },
});
return created;
});
}
export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) {
const existing = await prisma.player.findUnique({ where: { id: playerId } });
if (!existing) throw notFound('Spieler');
const steamId = input.steamId === undefined ? undefined : input.steamId?.trim() || null;
if (steamId) await assertSteamIdFree(steamId, playerId);
return prisma.$transaction(async (tx) => {
const updated = await tx.player.update({
where: { id: playerId },
data: {
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
...(steamId !== undefined ? { steamId } : {}),
...(input.forumName !== undefined ? { forumName: input.forumName?.trim() || null } : {}),
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
},
});
await recordAudit(tx, ctx, {
action: 'player.update',
targetType: 'player',
targetId: playerId,
details: { displayName: updated.displayName, steamId: updated.steamId },
});
return updated;
});
}
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
const existing = await prisma.player.findUnique({
where: { id: playerId },
include: { _count: { select: { characters: true, ledger: true } } },
});
if (!existing) throw notFound('Spieler');
await prisma.$transaction(async (tx) => {
await tx.player.delete({ where: { id: playerId } });
await recordAudit(tx, ctx, {
action: 'player.delete',
targetType: 'player',
targetId: playerId,
details: {
displayName: existing.displayName,
steamId: existing.steamId,
removedCharacters: existing._count.characters,
removedLedgerRows: existing._count.ledger,
},
});
});
}