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:
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
|
||||||
|
export interface ActionContext {
|
||||||
|
// null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login.
|
||||||
|
actorId: number | null;
|
||||||
|
actorName: string;
|
||||||
|
ip?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId?: string | number | null;
|
||||||
|
details?: Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client = Pick<typeof prisma, 'auditLog'>;
|
||||||
|
|
||||||
|
export async function recordAudit(
|
||||||
|
client: Client,
|
||||||
|
ctx: ActionContext,
|
||||||
|
entry: AuditEntry,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorUserId: ctx.actorId,
|
||||||
|
actorName: ctx.actorName,
|
||||||
|
action: entry.action,
|
||||||
|
targetType: entry.targetType,
|
||||||
|
targetId: entry.targetId == null ? null : String(entry.targetId),
|
||||||
|
details: entry.details ?? undefined,
|
||||||
|
ip: ctx.ip ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
CharacterStatus,
|
||||||
|
CreditKind,
|
||||||
|
CreditSource,
|
||||||
|
InactiveReason,
|
||||||
|
type Character,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { getBalances, type Balances } from './credits.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
export interface CharacterResult {
|
||||||
|
character: Character;
|
||||||
|
balances: Balances;
|
||||||
|
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
|
||||||
|
// trotzdem ausgefuehrt, das Ergebnis gehoert aber sichtbar gemacht.
|
||||||
|
overdrawn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe.
|
||||||
|
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
||||||
|
// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich CC-Guthaben gezogen werden.
|
||||||
|
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
|
||||||
|
|
||||||
|
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
|
||||||
|
export type ReviveKind = (typeof REVIVE_KINDS)[number];
|
||||||
|
|
||||||
|
function isOverdrawn(balances: Balances): boolean {
|
||||||
|
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCharacter(id: number): Promise<Character> {
|
||||||
|
const character = await prisma.character.findUnique({ where: { id } });
|
||||||
|
if (!character) throw notFound('CC');
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafuer nicht einsetzbar. */
|
||||||
|
export async function createCharacter(
|
||||||
|
playerId: number,
|
||||||
|
input: { name: string; loreText?: string | undefined; note?: string | undefined },
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
||||||
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.character.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
name: input.name,
|
||||||
|
loreText: input.loreText ?? null,
|
||||||
|
status: CharacterStatus.ACTIVE,
|
||||||
|
lineageId: randomUUID(),
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
kind: CreditKind.CC,
|
||||||
|
amount: -1,
|
||||||
|
source: CreditSource.CONSUME,
|
||||||
|
groupId: randomUUID(),
|
||||||
|
characterId: created.id,
|
||||||
|
note: input.note ?? `Neuer CC: ${created.name}`,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.create',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: created.id,
|
||||||
|
details: { playerId, name: created.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
|
||||||
|
* entsteht als eigener Datensatz in derselben Kette. Damit greift spaeter
|
||||||
|
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurueckkommt.
|
||||||
|
*/
|
||||||
|
export async function redesignCharacter(
|
||||||
|
characterId: number,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
loreText?: string | undefined;
|
||||||
|
useKind: RedesignKind;
|
||||||
|
note?: string | undefined;
|
||||||
|
},
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<CharacterResult & { predecessor: Character }> {
|
||||||
|
const old = await loadCharacter(characterId);
|
||||||
|
|
||||||
|
if (old.status !== CharacterStatus.ACTIVE) {
|
||||||
|
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
|
||||||
|
}
|
||||||
|
if (!REDESIGN_KINDS.includes(input.useKind)) {
|
||||||
|
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const predecessor = await tx.character.update({
|
||||||
|
where: { id: old.id },
|
||||||
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await tx.character.create({
|
||||||
|
data: {
|
||||||
|
playerId: old.playerId,
|
||||||
|
name: input.name,
|
||||||
|
loreText: input.loreText ?? null,
|
||||||
|
status: CharacterStatus.ACTIVE,
|
||||||
|
lineageId: old.lineageId,
|
||||||
|
predecessorId: old.id,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: old.playerId,
|
||||||
|
kind: input.useKind,
|
||||||
|
amount: -1,
|
||||||
|
source: CreditSource.CONSUME,
|
||||||
|
groupId: randomUUID(),
|
||||||
|
characterId: created.id,
|
||||||
|
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.redesign',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: created.id,
|
||||||
|
details: {
|
||||||
|
playerId: old.playerId,
|
||||||
|
predecessorId: old.id,
|
||||||
|
from: old.name,
|
||||||
|
to: created.name,
|
||||||
|
useKind: input.useKind,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { created, predecessor };
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(old.playerId);
|
||||||
|
return {
|
||||||
|
character: result.created,
|
||||||
|
predecessor: result.predecessor,
|
||||||
|
balances,
|
||||||
|
overdrawn: isOverdrawn(balances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivitaet. */
|
||||||
|
export async function deactivateCharacter(
|
||||||
|
characterId: number,
|
||||||
|
reason: InactiveReason,
|
||||||
|
ctx: ActionContext,
|
||||||
|
note?: string,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const existing = await loadCharacter(characterId);
|
||||||
|
if (existing.status === CharacterStatus.INACTIVE) {
|
||||||
|
throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv');
|
||||||
|
}
|
||||||
|
if (reason === InactiveReason.REWORKED) {
|
||||||
|
throw badRequest(
|
||||||
|
'USE_REDESIGN',
|
||||||
|
'Ein Rework entsteht ueber die Redesign-Aktion, nicht ueber eine Deaktivierung',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: reason },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.deactivate',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: { reason, note: note ?? null, name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(character.playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gratis-Reaktivierung. Greift nur, wenn der CC ausschliesslich wegen
|
||||||
|
* Spieler-Inaktivitaet vom Server geputzt wurde: er existiert unveraendert
|
||||||
|
* weiter und kostet deshalb kein Guthaben.
|
||||||
|
*/
|
||||||
|
export async function reactivateFree(
|
||||||
|
characterId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const existing = await loadCharacter(characterId);
|
||||||
|
|
||||||
|
if (existing.status === CharacterStatus.ACTIVE) {
|
||||||
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
||||||
|
}
|
||||||
|
if (existing.inactiveReason !== InactiveReason.PLAYER_INACTIVITY) {
|
||||||
|
throw conflict(
|
||||||
|
'NOT_FREE',
|
||||||
|
'Nur ein wegen Spieler-Inaktivitaet entfernter CC kommt gratis zurueck. Dieser CC braucht eine bezahlte Reaktivierung.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.reactivate_free',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: { playerId: updated.playerId, name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(character.playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bezahlte Reaktivierung fuer CCs, die im Original nicht mehr existieren
|
||||||
|
* (reworkt oder aus sonstigem Grund inaktiv). Kostet ein Redesign- oder
|
||||||
|
* ein CC-Guthaben, der Nutzer entscheidet welches.
|
||||||
|
*/
|
||||||
|
export async function reactivatePaid(
|
||||||
|
characterId: number,
|
||||||
|
useKind: ReviveKind,
|
||||||
|
ctx: ActionContext,
|
||||||
|
note?: string,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const existing = await loadCharacter(characterId);
|
||||||
|
|
||||||
|
if (existing.status === CharacterStatus.ACTIVE) {
|
||||||
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
||||||
|
}
|
||||||
|
if (existing.inactiveReason === InactiveReason.PLAYER_INACTIVITY) {
|
||||||
|
throw conflict(
|
||||||
|
'IS_FREE',
|
||||||
|
'Dieser CC kommt gratis zurueck, eine Guthaben-Buchung waere falsch',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!REVIVE_KINDS.includes(useKind)) {
|
||||||
|
throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung');
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: updated.playerId,
|
||||||
|
kind: useKind,
|
||||||
|
amount: -1,
|
||||||
|
source: CreditSource.CONSUME,
|
||||||
|
groupId: randomUUID(),
|
||||||
|
characterId: updated.id,
|
||||||
|
note: note ?? `Reaktivierung: ${updated.name}`,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.reactivate_paid',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: {
|
||||||
|
playerId: updated.playerId,
|
||||||
|
name: updated.name,
|
||||||
|
useKind,
|
||||||
|
previousReason: existing.inactiveReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(character.playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCharacter(
|
||||||
|
characterId: number,
|
||||||
|
input: { name?: string; loreText?: string | null },
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<Character> {
|
||||||
|
await loadCharacter(characterId);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: {
|
||||||
|
...(input.name !== undefined ? { name: input.name } : {}),
|
||||||
|
...(input.loreText !== undefined ? { loreText: input.loreText } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.update',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: { name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen
|
||||||
|
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger
|
||||||
|
* kehrt in den aktiven Zustand zurueck.
|
||||||
|
*/
|
||||||
|
export async function revertCharacter(
|
||||||
|
characterId: number,
|
||||||
|
reason: string,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ balances: Balances; restoredPredecessorId: number | null }> {
|
||||||
|
const existing = await prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
include: { successors: { select: { id: true } }, predecessor: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw notFound('CC');
|
||||||
|
|
||||||
|
if (existing.successors.length > 0) {
|
||||||
|
throw conflict(
|
||||||
|
'HAS_SUCCESSORS',
|
||||||
|
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den juengsten CC zuruecknehmen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await prisma.creditLedger.findMany({
|
||||||
|
where: { characterId, source: CreditSource.CONSUME },
|
||||||
|
include: { reversedBy: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
const open = consumed.filter((entry) => entry.reversedBy === null);
|
||||||
|
|
||||||
|
const predecessor = existing.predecessor;
|
||||||
|
const restorePredecessor =
|
||||||
|
predecessor !== null &&
|
||||||
|
predecessor.status === CharacterStatus.INACTIVE &&
|
||||||
|
predecessor.inactiveReason === InactiveReason.REWORKED;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const entry of open) {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: entry.playerId,
|
||||||
|
kind: entry.kind,
|
||||||
|
amount: -entry.amount,
|
||||||
|
source: CreditSource.REVERSAL,
|
||||||
|
groupId: entry.groupId,
|
||||||
|
note: `Ruecknahme "${existing.name}": ${reason}`,
|
||||||
|
reversalOfId: entry.id,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restorePredecessor && predecessor) {
|
||||||
|
await tx.character.update({
|
||||||
|
where: { id: predecessor.id },
|
||||||
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.character.delete({ where: { id: characterId } });
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.revert',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: {
|
||||||
|
playerId: existing.playerId,
|
||||||
|
name: existing.name,
|
||||||
|
reason,
|
||||||
|
reversedLedgerIds: open.map((e) => e.id),
|
||||||
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
balances: await getBalances(existing.playerId),
|
||||||
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCharacters(playerId: number) {
|
||||||
|
return prisma.character.findMany({
|
||||||
|
where: { playerId },
|
||||||
|
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: {
|
||||||
|
predecessor: { select: { id: true, name: true } },
|
||||||
|
successors: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { CreditKind, CreditSource, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
export interface Balances {
|
||||||
|
CC: number;
|
||||||
|
REDESIGN_PAID: number;
|
||||||
|
REDESIGN_KULANZ: number;
|
||||||
|
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
|
||||||
|
redesignTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 };
|
||||||
|
|
||||||
|
function assemble(sums: Partial<Record<CreditKind, number>>): Balances {
|
||||||
|
const cc = sums.CC ?? 0;
|
||||||
|
const paid = sums.REDESIGN_PAID ?? 0;
|
||||||
|
const kulanz = sums.REDESIGN_KULANZ ?? 0;
|
||||||
|
return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalances(playerId: number): Promise<Balances> {
|
||||||
|
const rows = await prisma.creditLedger.groupBy({
|
||||||
|
by: ['kind'],
|
||||||
|
where: { playerId },
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sums: Partial<Record<CreditKind, number>> = {};
|
||||||
|
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
|
||||||
|
return assemble(sums);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
|
||||||
|
const result = new Map<number, Balances>();
|
||||||
|
if (playerIds.length === 0) return result;
|
||||||
|
|
||||||
|
const rows = await prisma.creditLedger.groupBy({
|
||||||
|
by: ['playerId', 'kind'],
|
||||||
|
where: { playerId: { in: playerIds } },
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = new Map<number, Partial<Record<CreditKind, number>>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const entry = grouped.get(row.playerId) ?? {};
|
||||||
|
entry[row.kind] = row._sum.amount ?? 0;
|
||||||
|
grouped.set(row.playerId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of playerIds) {
|
||||||
|
const sums = grouped.get(id);
|
||||||
|
result.set(id, sums ? assemble(sums) : { ...EMPTY });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requirePlayer(playerId: number): Promise<void> {
|
||||||
|
const exists = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
||||||
|
if (!exists) throw notFound('Spieler');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageRowParams {
|
||||||
|
playerId: number;
|
||||||
|
groupId: string;
|
||||||
|
note?: string | null;
|
||||||
|
importId?: number | null;
|
||||||
|
actorId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
|
||||||
|
* immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle
|
||||||
|
* Eingabe und Import nicht auseinanderlaufen koennen.
|
||||||
|
*/
|
||||||
|
export function buildPackageRows(
|
||||||
|
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
||||||
|
params: PackageRowParams,
|
||||||
|
): Prisma.CreditLedgerCreateManyInput[] {
|
||||||
|
const base = {
|
||||||
|
playerId: params.playerId,
|
||||||
|
groupId: params.groupId,
|
||||||
|
note: params.note ?? null,
|
||||||
|
importId: params.importId ?? null,
|
||||||
|
createdById: params.actorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (target === 'REDESIGN_PACKAGE') {
|
||||||
|
return [
|
||||||
|
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.REDESIGN_PACKAGE },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ ...base, kind: CreditKind.CC, amount: 1, source: CreditSource.CC_PACKAGE },
|
||||||
|
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.CC_PACKAGE },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
|
||||||
|
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
|
||||||
|
* gemeinsam zurueckholt.
|
||||||
|
*/
|
||||||
|
export async function bookCcPackage(
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
const groupId = options.groupId ?? randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.createMany({
|
||||||
|
data: buildPackageRows('CC_PACKAGE', {
|
||||||
|
playerId,
|
||||||
|
groupId,
|
||||||
|
note: options.note ?? null,
|
||||||
|
importId: options.importId ?? null,
|
||||||
|
actorId: ctx.actorId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.cc_package',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bookRedesignPackage(
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
const groupId = options.groupId ?? randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.createMany({
|
||||||
|
data: buildPackageRows('REDESIGN_PACKAGE', {
|
||||||
|
playerId,
|
||||||
|
groupId,
|
||||||
|
note: options.note ?? null,
|
||||||
|
importId: options.importId ?? null,
|
||||||
|
actorId: ctx.actorId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.redesign_package',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bookKulanz(
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
options: { note?: string | undefined } = {},
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
const groupId = randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
kind: CreditKind.REDESIGN_KULANZ,
|
||||||
|
amount: 1,
|
||||||
|
source: CreditSource.KULANZ,
|
||||||
|
groupId,
|
||||||
|
note: options.note ?? null,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.kulanz',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, note: options.note ?? null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */
|
||||||
|
export async function bookAdjustment(
|
||||||
|
playerId: number,
|
||||||
|
kind: CreditKind,
|
||||||
|
amount: number,
|
||||||
|
note: string,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
if (!Number.isInteger(amount) || amount === 0) {
|
||||||
|
throw badRequest('INVALID_AMOUNT', 'Korrekturbetrag muss eine ganze Zahl ungleich 0 sein');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
kind,
|
||||||
|
amount,
|
||||||
|
source: CreditSource.ADJUSTMENT,
|
||||||
|
groupId,
|
||||||
|
note,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.adjustment',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, kind, amount, note },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie
|
||||||
|
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||||
|
* ueber die zugehoerige CC-Aktion zurueckgenommen.
|
||||||
|
*/
|
||||||
|
export async function reverseGroup(
|
||||||
|
ledgerId: number,
|
||||||
|
reason: string,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ balances: Balances; reversed: number }> {
|
||||||
|
const anchor = await prisma.creditLedger.findUnique({ where: { id: ledgerId } });
|
||||||
|
if (!anchor) throw notFound('Buchung');
|
||||||
|
|
||||||
|
if (anchor.source === CreditSource.CONSUME) {
|
||||||
|
throw conflict(
|
||||||
|
'CONSUME_NOT_REVERSIBLE',
|
||||||
|
'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (anchor.source === CreditSource.REVERSAL) {
|
||||||
|
throw conflict('ALREADY_REVERSAL', 'Eine Stornobuchung kann nicht storniert werden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await prisma.creditLedger.findMany({
|
||||||
|
where: { groupId: anchor.groupId, source: { not: CreditSource.REVERSAL } },
|
||||||
|
include: { reversedBy: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = entries.filter((e) => e.reversedBy === null);
|
||||||
|
if (open.length === 0) {
|
||||||
|
throw conflict('ALREADY_REVERSED', 'Diese Buchung wurde bereits storniert');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const entry of open) {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: entry.playerId,
|
||||||
|
kind: entry.kind,
|
||||||
|
amount: -entry.amount,
|
||||||
|
source: CreditSource.REVERSAL,
|
||||||
|
groupId: entry.groupId,
|
||||||
|
note: reason,
|
||||||
|
reversalOfId: entry.id,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.reverse',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: anchor.playerId,
|
||||||
|
details: { groupId: anchor.groupId, reversedIds: open.map((e) => e.id), reason },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { balances: await getBalances(anchor.playerId), reversed: open.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LedgerFilter {
|
||||||
|
playerId: number;
|
||||||
|
kind?: CreditKind;
|
||||||
|
take?: number;
|
||||||
|
skip?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLedger(filter: LedgerFilter) {
|
||||||
|
const where: Prisma.CreditLedgerWhereInput = { playerId: filter.playerId };
|
||||||
|
if (filter.kind) where.kind = filter.kind;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.creditLedger.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: filter.take ?? 100,
|
||||||
|
skip: filter.skip ?? 0,
|
||||||
|
include: {
|
||||||
|
createdBy: { select: { username: true } },
|
||||||
|
character: { select: { id: true, name: true } },
|
||||||
|
reversedBy: { select: { id: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.creditLedger.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { parse } from 'csv-parse/sync';
|
||||||
|
import { ImportStatus, ProductTarget, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { buildPackageRows } from './credits.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
export const PLANS = {
|
||||||
|
BOOK: 'BOOK',
|
||||||
|
CREATE_PLAYER_AND_BOOK: 'CREATE_PLAYER_AND_BOOK',
|
||||||
|
SKIP_DUPLICATE: 'SKIP_DUPLICATE',
|
||||||
|
SKIP_IGNORED: 'SKIP_IGNORED',
|
||||||
|
SKIP_UNMAPPED: 'SKIP_UNMAPPED',
|
||||||
|
SKIP_NO_PLAYER: 'SKIP_NO_PLAYER',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Plan = (typeof PLANS)[keyof typeof PLANS];
|
||||||
|
|
||||||
|
export interface ColumnMapping {
|
||||||
|
playerKey: string;
|
||||||
|
playerName?: string | undefined;
|
||||||
|
product: string;
|
||||||
|
orderId?: string | undefined;
|
||||||
|
purchasedAt?: string | undefined;
|
||||||
|
createMissingPlayers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedFile {
|
||||||
|
columns: string[];
|
||||||
|
rows: Record<string, string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ROWS = 20000;
|
||||||
|
|
||||||
|
// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma.
|
||||||
|
function detectDelimiter(sample: string): string {
|
||||||
|
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
||||||
|
const candidates = [',', ';', '\t', '|'];
|
||||||
|
let best = ',';
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const count = firstLine.split(candidate).length - 1;
|
||||||
|
if (count > bestCount) {
|
||||||
|
best = candidate;
|
||||||
|
bestCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRow(row: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
out[key] = '';
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
out[key] = JSON.stringify(value);
|
||||||
|
} else {
|
||||||
|
out[key] = String(value).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||||
|
const text = content.toString('utf8').replace(/^/, '');
|
||||||
|
if (text.trim().length === 0) throw badRequest('EMPTY_FILE', 'Die Datei ist leer');
|
||||||
|
|
||||||
|
const isJson = filename.toLowerCase().endsWith('.json') || /^\s*[[{]/.test(text);
|
||||||
|
let rows: Record<string, string>[];
|
||||||
|
|
||||||
|
if (isJson) {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
||||||
|
}
|
||||||
|
// Manche Exporte verpacken die Liste in ein Huellobjekt.
|
||||||
|
const list = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: typeof data === 'object' && data !== null
|
||||||
|
? Object.values(data).find((v): v is unknown[] => Array.isArray(v))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen');
|
||||||
|
}
|
||||||
|
rows = list
|
||||||
|
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
||||||
|
.map(normalizeRow);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const parsed = parse(text, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
trim: true,
|
||||||
|
relax_column_count: true,
|
||||||
|
delimiter: detectDelimiter(text),
|
||||||
|
bom: true,
|
||||||
|
}) as Record<string, unknown>[];
|
||||||
|
rows = parsed.map(normalizeRow);
|
||||||
|
} catch {
|
||||||
|
throw badRequest('INVALID_CSV', 'Die CSV-Datei konnte nicht gelesen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen');
|
||||||
|
if (rows.length > MAX_ROWS) {
|
||||||
|
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
||||||
|
return { columns, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createImport(
|
||||||
|
filename: string,
|
||||||
|
parsed: ParsedFile,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<number> {
|
||||||
|
const created = await prisma.import.create({
|
||||||
|
data: {
|
||||||
|
filename,
|
||||||
|
status: ImportStatus.PENDING,
|
||||||
|
rowCount: parsed.rows.length,
|
||||||
|
importedById: ctx.actorId,
|
||||||
|
rows: {
|
||||||
|
create: parsed.rows.map((raw, index) => ({
|
||||||
|
lineNo: index + 1,
|
||||||
|
plan: PLANS.SKIP_UNMAPPED,
|
||||||
|
raw: raw as Prisma.InputJsonValue,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'import.upload',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: created.id,
|
||||||
|
details: { filename, rowCount: parsed.rows.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTarget(
|
||||||
|
productName: string,
|
||||||
|
mappings: { matchPattern: string; target: ProductTarget }[],
|
||||||
|
): ProductTarget | null {
|
||||||
|
const haystack = productName.toLowerCase();
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
if (haystack.includes(mapping.matchPattern.toLowerCase())) return mapping.target;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: string | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
||||||
|
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
||||||
|
*/
|
||||||
|
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
||||||
|
const record = await prisma.import.findUnique({
|
||||||
|
where: { id: importId },
|
||||||
|
include: { rows: { orderBy: { lineNo: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
if (record.status !== ImportStatus.PENDING) {
|
||||||
|
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const productMappings = await prisma.productMapping.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
|
||||||
|
select: { matchPattern: true, target: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawRows = record.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
lineNo: row.lineNo,
|
||||||
|
raw: row.raw as Record<string, string>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const playerKeys = [
|
||||||
|
...new Set(rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const players = await prisma.player.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
||||||
|
},
|
||||||
|
select: { id: true, steamId: true, displayName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bySteamId = new Map(players.filter((p) => p.steamId).map((p) => [p.steamId as string, p.id]));
|
||||||
|
const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
|
||||||
|
|
||||||
|
const orderIds = mapping.orderId
|
||||||
|
? [...new Set(rawRows.map((r) => (r.raw[mapping.orderId as string] ?? '').trim()).filter(Boolean))]
|
||||||
|
: [];
|
||||||
|
const alreadyBooked = new Set(
|
||||||
|
orderIds.length > 0
|
||||||
|
? (
|
||||||
|
await prisma.importedOrder.findMany({
|
||||||
|
where: { externalId: { in: orderIds } },
|
||||||
|
select: { externalId: true },
|
||||||
|
})
|
||||||
|
).map((o) => o.externalId)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Faengt Dubletten innerhalb derselben Datei ab.
|
||||||
|
const seenInFile = new Set<string>();
|
||||||
|
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||||
|
const summary: Record<Plan, number> = {
|
||||||
|
BOOK: 0,
|
||||||
|
CREATE_PLAYER_AND_BOOK: 0,
|
||||||
|
SKIP_DUPLICATE: 0,
|
||||||
|
SKIP_IGNORED: 0,
|
||||||
|
SKIP_UNMAPPED: 0,
|
||||||
|
SKIP_NO_PLAYER: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of rawRows) {
|
||||||
|
const playerKey = (row.raw[mapping.playerKey] ?? '').trim();
|
||||||
|
const playerName = mapping.playerName ? (row.raw[mapping.playerName] ?? '').trim() : '';
|
||||||
|
const productName = (row.raw[mapping.product] ?? '').trim();
|
||||||
|
const externalId = mapping.orderId ? (row.raw[mapping.orderId] ?? '').trim() : '';
|
||||||
|
const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined);
|
||||||
|
|
||||||
|
const target = productName ? matchTarget(productName, productMappings) : null;
|
||||||
|
const resolvedPlayerId =
|
||||||
|
bySteamId.get(playerKey) ?? byName.get(playerKey.toLowerCase()) ?? null;
|
||||||
|
|
||||||
|
let plan: Plan;
|
||||||
|
if (target === null) {
|
||||||
|
plan = PLANS.SKIP_UNMAPPED;
|
||||||
|
} else if (target === ProductTarget.IGNORE) {
|
||||||
|
plan = PLANS.SKIP_IGNORED;
|
||||||
|
} else if (externalId && (alreadyBooked.has(externalId) || seenInFile.has(externalId))) {
|
||||||
|
plan = PLANS.SKIP_DUPLICATE;
|
||||||
|
} else if (resolvedPlayerId !== null) {
|
||||||
|
plan = PLANS.BOOK;
|
||||||
|
} else if (mapping.createMissingPlayers && (playerKey || playerName)) {
|
||||||
|
plan = PLANS.CREATE_PLAYER_AND_BOOK;
|
||||||
|
} else {
|
||||||
|
plan = PLANS.SKIP_NO_PLAYER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalId && (plan === PLANS.BOOK || plan === PLANS.CREATE_PLAYER_AND_BOOK)) {
|
||||||
|
seenInFile.add(externalId);
|
||||||
|
}
|
||||||
|
summary[plan] += 1;
|
||||||
|
|
||||||
|
updates.push(
|
||||||
|
prisma.importRow.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
playerKey: playerKey || null,
|
||||||
|
playerName: playerName || null,
|
||||||
|
productName: productName || null,
|
||||||
|
externalId: externalId || null,
|
||||||
|
purchasedAt,
|
||||||
|
target,
|
||||||
|
resolvedPlayerId,
|
||||||
|
plan,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
...updates,
|
||||||
|
prisma.import.update({
|
||||||
|
where: { id: importId },
|
||||||
|
data: { mapping: mapping as unknown as Prisma.InputJsonValue },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'import.preview',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: importId,
|
||||||
|
details: { mapping: mapping as unknown as Prisma.InputJsonValue, summary },
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImport(importId: number, includeRows = true) {
|
||||||
|
const record = await prisma.import.findUnique({
|
||||||
|
where: { id: importId },
|
||||||
|
include: {
|
||||||
|
importedBy: { select: { username: true } },
|
||||||
|
...(includeRows ? { rows: { orderBy: { lineNo: 'asc' as const } } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
||||||
|
export async function commitImport(importId: number, ctx: ActionContext) {
|
||||||
|
const record = await prisma.import.findUnique({
|
||||||
|
where: { id: importId },
|
||||||
|
include: { rows: { orderBy: { lineNo: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
if (record.status !== ImportStatus.PENDING) {
|
||||||
|
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookable = record.rows.filter(
|
||||||
|
(row) => row.plan === PLANS.BOOK || row.plan === PLANS.CREATE_PLAYER_AND_BOOK,
|
||||||
|
);
|
||||||
|
if (bookable.length === 0) {
|
||||||
|
throw conflict('NOTHING_TO_BOOK', 'Es gibt keine Zeilen zum Buchen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Mehrere Zeilen koennen denselben neuen Spieler betreffen.
|
||||||
|
const createdPlayers = new Map<string, number>();
|
||||||
|
let booked = 0;
|
||||||
|
|
||||||
|
for (const row of bookable) {
|
||||||
|
const key = (row.playerKey ?? '').trim();
|
||||||
|
let playerId = row.resolvedPlayerId;
|
||||||
|
|
||||||
|
if (playerId === null) {
|
||||||
|
const cacheKey = key.toLowerCase();
|
||||||
|
const cached = createdPlayers.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
playerId = cached;
|
||||||
|
} else {
|
||||||
|
const created = await tx.player.create({
|
||||||
|
data: {
|
||||||
|
displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`,
|
||||||
|
steamId: key || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdPlayers.set(cacheKey, created.id);
|
||||||
|
playerId = created.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE';
|
||||||
|
const groupId = randomUUID();
|
||||||
|
|
||||||
|
await tx.creditLedger.createMany({
|
||||||
|
data: buildPackageRows(target, {
|
||||||
|
playerId,
|
||||||
|
groupId,
|
||||||
|
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
|
||||||
|
importId: record.id,
|
||||||
|
actorId: ctx.actorId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.externalId) {
|
||||||
|
await tx.importedOrder.create({
|
||||||
|
data: {
|
||||||
|
externalId: row.externalId,
|
||||||
|
importId: record.id,
|
||||||
|
playerId,
|
||||||
|
target: row.target ?? ProductTarget.CC_PACKAGE,
|
||||||
|
groupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
booked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await tx.import.update({
|
||||||
|
where: { id: importId },
|
||||||
|
data: {
|
||||||
|
status: ImportStatus.COMMITTED,
|
||||||
|
committedAt: new Date(),
|
||||||
|
bookedCount: booked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'import.commit',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: importId,
|
||||||
|
details: {
|
||||||
|
filename: record.filename,
|
||||||
|
booked,
|
||||||
|
createdPlayers: createdPlayers.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { booked, createdPlayers: createdPlayers.size, import: updated };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discardImport(importId: number, ctx: ActionContext): Promise<void> {
|
||||||
|
const record = await prisma.import.findUnique({ where: { id: importId } });
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
if (record.status === ImportStatus.COMMITTED) {
|
||||||
|
throw conflict('ALREADY_COMMITTED', 'Ein gebuchter Import kann nicht verworfen werden');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.import.update({
|
||||||
|
where: { id: importId },
|
||||||
|
data: { status: ImportStatus.DISCARDED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'import.discard',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: importId,
|
||||||
|
details: { filename: record.filename },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listImports(take = 50) {
|
||||||
|
return prisma.import.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take,
|
||||||
|
include: { importedBy: { select: { username: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user