Nacherfassung an alten Kettengliedern, Filter fuer die Spielerliste

Beim Durcharbeiten von Spieler A stellt sich heraus, dass er schon vor drei
Redesigns bei einem Gruppen-CC von Spieler B dabei war. Das ging bisher nicht:
"Einem inaktiven CC kann niemand beitreten" - richtig fuer den laufenden
Betrieb, falsch fuers Nacherfassen. Dort geht es nicht um jetzt, sondern um
eine Geschichte, die laengst gelaufen ist.

An einem stillgelegten CC wird deshalb nachgetragen statt beigetreten: mit
Zeitraum, und die Guthaben-Angabe ist freigestellt, weil die Buchung im Konto
stehen kann. Wer weiter unten in derselben Kette schon steht, kommt auch an
einem laufenden CC ohne neue Buchung dazu - sein Platz wurde einmal bezahlt,
und ein Redesign tauscht ihn aus. Fuer alle anderen bleibt die Angabe Pflicht.
Damit vier Kettenglieder nicht vier Klicks bedeuten, traegt ein Aufruf einen
Spieler ab einem Glied in die ganze weitere Kette ein; gebucht wird einmal.

Ein Test spielt den ganzen Fall durch: B fertig, A nachgetragen, C steigt in
der Mitte ein und wieder aus, D kommt erst am Ende dazu - und am Schluss sieht
jeder seine eigene Geschichte.

Leere Import-Vorschauen blieben in der Liste stehen, sobald alle Zeilen beim
Spieler gebucht waren. Bestaetigen liess sich so eine Huelle nicht ("nichts zu
buchen"), sie war nur im Weg. Jetzt gilt sie als erledigt - sie ist es ja.
Auch der Bestand wird beim naechsten Aufruf der Liste abgeraeumt.

Und die Spielerliste laesst sich eingrenzen: nur mit aktivem CC, nur mit
ausschliesslich stillgelegten, nur Beteiligte eines Gruppen-CCs, nur ohne
jeden CC, nur Problemfaelle. Sortiert wird nach Namen, nach der Zahl der
CC-Eintraege oder nach Anlage.
This commit is contained in:
2026-09-21 16:17:00 +02:00
parent 2527f6a240
commit fe89032c03
13 changed files with 747 additions and 87 deletions
+147 -27
View File
@@ -1106,11 +1106,11 @@ const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.RE
* Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger.
*/
async function ladeAufgegebenenCC(
input: { useKind: JoinKind; predecessorId?: number | undefined },
input: { useKind?: JoinKind | undefined; predecessorId?: number | undefined },
playerId: number,
characterId: number,
): Promise<Character | null> {
if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) {
if (input.useKind === undefined || !JOIN_REDESIGN_KINDS.includes(input.useKind)) {
if (input.predecessorId !== undefined) {
throw badRequest(
'NO_PREDECESSOR_NEEDED',
@@ -1149,6 +1149,27 @@ async function ladeAufgegebenenCC(
return vorgaenger;
}
/**
* Ob der Spieler in einem früheren Glied derselben Kette geführt wird.
*
* Für die Nacherfassung entscheidend: Wer bei einem CC dabei war, der später
* redesignt wurde, ist beim Nachfolger dabei, ohne ein zweites Mal zu zahlen.
* Sein Platz wurde einmal gebucht und ist derselbe geblieben.
*/
async function warSchonInDerKette(character: Character, playerId: number): Promise<boolean> {
const treffer = await prisma.characterMember.findFirst({
where: {
playerId,
character: {
lineageId: character.lineageId,
chainPosition: { lt: character.chainPosition },
},
},
select: { id: true },
});
return treffer !== null;
}
/**
* Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für
* einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder
@@ -1157,17 +1178,42 @@ async function ladeAufgegebenenCC(
export async function addMember(
characterId: number,
playerId: number,
input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined },
input: {
/// Fehlt bei einer reinen Nacherfassung an einem inaktiven CC: dort wird
/// eingetragen, was war, und die Buchung steht längst im Konto.
useKind?: JoinKind | undefined;
predecessorId?: number | undefined;
note?: string | undefined;
/// Zeitraum, wenn nachgetragen wird statt jetzt beizutreten.
joinedAt?: Date | undefined;
leftAt?: Date | undefined;
},
ctx: ActionContext,
): Promise<CharacterResult & { memberCount: number }> {
const character = await loadCharacter(characterId);
const historisch = character.status !== CharacterStatus.ACTIVE;
if (character.status !== CharacterStatus.ACTIVE) {
throw conflict('NOT_ACTIVE', 'Einem inaktiven CC kann niemand beitreten');
// Wer weiter unten in derselben Kette schon dabei war, setzt hier nur
// fort: sein Platz wurde damals bezahlt, und ein Redesign tauscht einen
// Platz aus, statt einen zweiten zu verlangen. Genau das tut ein Redesign
// im laufenden Betrieb auch - es nimmt die Gruppe mit.
const schonInDerKette = await warSchonInDerKette(character, playerId);
// Sonst gilt: an einem laufenden CC kostet ein Platz etwas. An einem
// inaktiven wird nachgetragen, was einmal war - dort ist die Angabe
// freigestellt, weil die Buchung längst im Konto stehen kann.
if (!historisch && !schonInDerKette && input.useKind === undefined) {
throw badRequest(
'NEED_KIND',
'Ein Platz an einem laufenden CC kostet ein Guthaben - bitte angeben, welches',
);
}
if (!JOIN_KINDS.includes(input.useKind)) {
if (input.useKind !== undefined && !JOIN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt');
}
if (input.leftAt && input.joinedAt && input.leftAt < input.joinedAt) {
throw badRequest('BAD_RANGE', 'Das Ende liegt vor dem Anfang');
}
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
if (!player) throw notFound('Spieler');
@@ -1204,9 +1250,9 @@ export async function addMember(
await tx.characterMember.update({
where: { id: vorhanden.id },
data: {
leftAt: null,
joinedAt: new Date(),
paidWith: input.useKind,
leftAt: input.leftAt ?? null,
joinedAt: input.joinedAt ?? new Date(),
paidWith: input.useKind ?? null,
predecessorId: vorgaenger?.id ?? null,
note: input.note ?? null,
createdById: ctx.actorId,
@@ -1217,30 +1263,35 @@ export async function addMember(
data: {
characterId,
playerId,
paidWith: input.useKind,
paidWith: input.useKind ?? null,
predecessorId: vorgaenger?.id ?? null,
note: input.note ?? null,
createdById: ctx.actorId,
...(input.joinedAt ? { joinedAt: input.joinedAt } : {}),
...(input.leftAt ? { leftAt: input.leftAt } : {}),
},
});
}
await tx.creditLedger.create({
data: {
playerId,
kind: input.useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId,
note:
input.note ??
(vorgaenger
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
: `Beitritt zu "${character.name}"`),
createdById: ctx.actorId,
},
});
if (input.useKind !== undefined) {
await tx.creditLedger.create({
data: {
playerId,
kind: input.useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId,
note:
input.note ??
(vorgaenger
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
: `Beitritt zu "${character.name}"`),
createdById: ctx.actorId,
...(input.joinedAt ? { occurredAt: input.joinedAt } : {}),
},
});
}
await assertSlotsReichen(tx, playerId, 'Der Beitritt');
@@ -1251,7 +1302,8 @@ export async function addMember(
details: {
playerId,
name: character.name,
useKind: input.useKind,
useKind: input.useKind ?? null,
nachgetragen: historisch,
predecessorId: vorgaenger?.id ?? null,
predecessorName: vorgaenger?.name ?? null,
},
@@ -1543,6 +1595,74 @@ export async function reactivateMember(
return { memberCount };
}
/**
* Trägt einen Spieler ab einem Kettenglied in die gesamte weitere Kette ein.
*
* Für die Nacherfassung: Stellt sich heraus, dass jemand schon beim dritten
* von sechs Gliedern dabei war, wäre es Unfug, ihn viermal einzeln
* einzutragen. Gebucht wird genau einmal - beim Einstieg -, denn ein
* Redesign tauscht seinen Platz aus, statt einen weiteren zu verlangen.
*
* Wer schon irgendwo in der Kette steht, wird dort nicht angerührt.
*/
export async function addMemberToChain(
characterId: number,
playerId: number,
input: {
useKind?: JoinKind | undefined;
predecessorId?: number | undefined;
note?: string | undefined;
joinedAt?: Date | undefined;
},
ctx: ActionContext,
): Promise<{ added: number; memberCount: number }> {
const einstieg = await loadCharacter(characterId);
const kette = await prisma.character.findMany({
where: { lineageId: einstieg.lineageId, chainPosition: { gte: einstieg.chainPosition } },
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
select: { id: true, name: true },
});
const vorhandene = new Set(
(
await prisma.characterMember.findMany({
where: { playerId, characterId: { in: kette.map((c) => c.id) } },
select: { characterId: true },
})
).map((m) => m.characterId),
);
let added = 0;
for (const [index, glied] of kette.entries()) {
if (vorhandene.has(glied.id)) continue;
// Nur der Einstieg kostet. Alles Weitere ist dieselbe Mitgliedschaft,
// durch die Kette fortgeschrieben.
await addMember(
glied.id,
playerId,
{
...(index === 0 ? { useKind: input.useKind } : {}),
...(index === 0 && input.predecessorId !== undefined
? { predecessorId: input.predecessorId }
: {}),
...(input.note !== undefined ? { note: input.note } : {}),
...(input.joinedAt !== undefined ? { joinedAt: input.joinedAt } : {}),
},
ctx,
);
added += 1;
}
const letztes = kette[kette.length - 1];
const memberCount = letztes
? await prisma.characterMember.count({ where: { characterId: letztes.id, leftAt: null } })
: 0;
return { added, memberCount };
}
/**
* Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht -
* wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die
+32 -1
View File
@@ -591,7 +591,7 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) {
throw conflict('NOTHING_TO_BOOK', 'Keine dieser Zeilen steht noch zur Buchung an');
}
return prisma.$transaction(async (tx) => {
const ergebnis = await prisma.$transaction(async (tx) => {
let booked = 0;
let skipped = 0;
const erledigt: number[] = [];
@@ -677,6 +677,10 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) {
return { booked, skipped };
});
// Bleibt von einer Vorschau nichts übrig, ist sie erledigt.
await schliesseLeere();
return ergebnis;
}
/**
@@ -704,6 +708,7 @@ export async function dismissRows(rowIds: number[], ctx: ActionContext) {
details: { count: result.count, rowIds },
});
await schliesseLeere();
return { dismissed: result.count };
}
@@ -730,7 +735,33 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi
});
}
/**
* Schliesst Importe ab, an denen nichts mehr zu tun ist.
*
* Wer die Zeilen eines Spielers direkt auf dessen Seite bucht, lässt eine
* leere Vorschau zurück. Sie stünde weiter in der Liste, liesse sich nicht
* bestätigen ("nichts zu buchen") und wäre nur im Weg. Also gilt sie als
* erledigt - sie ist es ja.
*/
async function schliesseLeere(): Promise<number> {
const leer = await prisma.import.findMany({
where: {
status: ImportStatus.PENDING,
rows: { none: { plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] } } },
},
select: { id: true },
});
if (leer.length === 0) return 0;
const result = await prisma.import.updateMany({
where: { id: { in: leer.map((i) => i.id) } },
data: { status: ImportStatus.COMMITTED, committedAt: new Date() },
});
return result.count;
}
export async function listImports(take = 50) {
await schliesseLeere();
return prisma.import.findMany({
orderBy: { createdAt: 'desc' },
take,
+59 -3
View File
@@ -17,19 +17,68 @@ export interface PlayerInput {
problemNote?: string | null;
}
/// Womit die Liste eingegrenzt wird.
export type PlayerFilter = 'all' | 'problems' | 'group' | 'active' | 'onlyInactive' | 'none';
/// Wonach sie sortiert wird.
export type PlayerSort = 'name' | 'characters' | 'newest';
/**
* Spieler, die an einem CC mit mehr als einem Nutzer beteiligt sind.
*
* Das lässt sich nicht in einer Bedingung ausdrücken, weil dabei ein CC mit
* sich selbst verglichen würde: erst stehen die Gruppen-CCs fest, dann ihre
* Nutzer.
*/
async function spielerMitGruppenCC(): Promise<number[]> {
const gruppen = await prisma.characterMember.groupBy({
by: ['characterId'],
where: { leftAt: null },
having: { characterId: { _count: { gt: 1 } } },
});
if (gruppen.length === 0) return [];
const beteiligte = await prisma.characterMember.findMany({
where: { characterId: { in: gruppen.map((g) => g.characterId) }, leftAt: null },
select: { playerId: true },
});
return [...new Set(beteiligte.map((b) => b.playerId))];
}
export async function searchPlayers(
query: string,
take = 50,
skip = 0,
onlyProblems = false,
filter: PlayerFilter = 'all',
sort: PlayerSort = 'name',
) {
const trimmed = query.trim();
// Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die
// 64-Bit-Form gespeichert ist.
const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null;
// Massgeblich ist die Mitgliedschaft, nicht wer den CC angelegt hat: wer
// einem Gruppen-CC angehört, hat dort einen Platz, auch ohne ihn erstellt
// zu haben.
const eingrenzung: Prisma.PlayerWhereInput =
filter === 'problems'
? { isProblem: true }
: filter === 'group'
? { id: { in: await spielerMitGruppenCC() } }
: filter === 'active'
? { memberships: { some: { leftAt: null, character: { status: 'ACTIVE' } } } }
: filter === 'onlyInactive'
? {
AND: [
{ memberships: { some: {} } },
{ memberships: { none: { leftAt: null, character: { status: 'ACTIVE' } } } },
],
}
: filter === 'none'
? { memberships: { none: {} } }
: {};
const where: Prisma.PlayerWhereInput = {
...(onlyProblems ? { isProblem: true } : {}),
...eingrenzung,
...(trimmed.length === 0
? {}
: {
@@ -41,10 +90,17 @@ export async function searchPlayers(
}),
};
const orderBy: Prisma.PlayerOrderByWithRelationInput[] =
sort === 'characters'
? [{ characters: { _count: 'desc' } }, { displayName: 'asc' }]
: sort === 'newest'
? [{ createdAt: 'desc' }]
: [{ displayName: 'asc' }];
const [players, total] = await Promise.all([
prisma.player.findMany({
where,
orderBy: { displayName: 'asc' },
orderBy,
take,
skip,
include: { _count: { select: { characters: true } } },