diff --git a/README.md b/README.md index 8bb7920..bc580e9 100644 --- a/README.md +++ b/README.md @@ -61,17 +61,21 @@ desselben Spielers. Ein nachgetragener CC wird also schlicht dorthin gezogen, wo *Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in diese Kette gehörte. -**Die Stelle in der Kette bestimmt, was ein Eintrag kostet.** Das unterste Glied ist der CC, der -gekauft wurde - es verbraucht ein CC-Paket. Alles darüber sind seine Redesigns und verbrauchen -Redesign-Guthaben. Wandert ein Eintrag, wandert seine Bedeutung mit: Ein eigenständiger CC, den +**Die Stelle in der Kette bestimmt, was ein Eintrag kostet**, und die Rechnung ist schlicht: + +> So viele **CC-Pakete** wie Ketten. So viele **Redesigns** wie Einträge minus Ketten. + +Drei Ketten mit zusammen sechzehn Einträgen sind also drei Pakete und dreizehn Redesigns. Anders +gesagt: Wer keinen Vorgänger hat, ist der Anfang einer Kette und damit ein gekaufter CC; wer +einen hat, ist dessen Redesign. In welcher Linie ein Eintrag geführt wird, spielt dabei keine +Rolle - die Kette ist die Kette. Wandert ein Eintrag, wandert seine Bedeutung mit: Ein eigenständiger CC, den du mitten in eine Kette schiebst, ist von da an ein Redesign, und das CC-Guthaben kommt zurück. Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und kostet wieder ein CC-Paket. -Eine Ausnahme gibt es: **Hat die Kette eine Herkunft außerhalb**, ist auch ihr unterstes Glied -kein Kauf. Wer aus einem fremden Gruppen-CC herausredesignt, beginnt zwar eine eigene Kette - -bezahlt hat er sie trotzdem mit einem Redesign. Sonst stünde derselbe Platz zweimal im Konto: -einmal für den Beitritt, einmal für den eigenen CC. +Daraus folgt auch der Fall mit dem fremden Gruppen-CC: Wer aus einem herausredesignt, dessen +eigener Strang hat einen Vorgänger - den Gruppen-CC. Also ist sein erstes Glied kein Kauf, +sondern ein Redesign. Bezahlt wurde der Platz beim Beitritt, und zwar einmal. Umgebucht wird über eine Gegenbuchung, nicht durch Überschreiben - in der Historie bleibt sichtbar, was passiert ist. Reicht das Guthaben nicht, geht das Konto ins Minus und zeigt das an. @@ -86,7 +90,12 @@ woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadur muss er gedeckt sein wie jeder andere. Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen - -die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird umgestellt, woraus ein Eintrag +die Thread-Nummer genügt, die volle Adresse geht auch. Stimmt ein Bestand nicht mehr - etwa weil er aus einer Zeit stammt, in der eine Buchung blieb, +wo sie beim Anlegen entstand -, richtet **Buchungen nachrechnen** auf der Spielerseite alles +nach dieser Regel aus. Angefasst wird nur, was nicht passt; umgebucht wird über eine +Gegenbuchung, und zwischen bezahltem Redesign und Kulanz wird nichts gewechselt. + +Über *Bezahlart* wird umgestellt, woraus ein Eintrag bezahlt wurde - aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz; umgebucht wird über eine Gegenbuchung, in der Historie bleibt beides sichtbar. diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index eeda6d0..5271b6d 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -26,6 +26,7 @@ import { detachFromChain, changeFunding, addMemberToChain, + rebuildFunding, } from '../services/characters.js'; import { parseThreadId } from '../lib/forum.js'; import { linkThreadToCharacter } from '../services/forum.js'; @@ -198,6 +199,15 @@ export async function characterRoutes(app: FastifyInstance): Promise { return changeFunding(id, body.useKind, contextOf(request)); }); + /** + * Rechnet die Buchungen eines Spielers anhand seiner Ketten nach: jede + * Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns. + */ + app.post('/api/players/:id/credits/rebuild', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + return rebuildFunding(id, contextOf(request)); + }); + // --- Ketten --- /** diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 6ee8950..382d7e3 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -942,33 +942,16 @@ async function verketteNeu( const kettenBesitzer = glieder[0]?.playerId ?? null; const inDieserLinie = new Set(glieder.map((g) => g.id)); - /** - * Eine Herkunft ausserhalb dieser Kette. - * - * Wer aus einem fremden Gruppen-CC herausredesignt, beginnt eine eigene - * Kette - bezahlt hat er sie trotzdem mit einem Redesign, nicht mit einem - * Paket. Ihr unterstes Glied ist deshalb kein Kauf, und der Verweis auf - * die Herkunft bleibt stehen: er ist das Einzige, was noch zeigt, woher - * die Kette kommt. - */ - const herkunft = glieder[0]?.predecessorId ?? null; - const hatHerkunft = herkunft !== null && !inDieserLinie.has(herkunft); - let vorheriges: number | null = null; for (const [index, glied] of glieder.entries()) { const letztes = index === glieder.length - 1; const erstes = index === 0; const daten: Prisma.CharacterUpdateInput = {}; - // Das unterste Glied ist der gekaufte CC - es sei denn, ihm ging etwas - // voraus. Alles darüber sind seine Redesigns. - if (kettenBesitzer !== null) { - await buchungFolgtPosition(tx, glied, kettenBesitzer, erstes && !hatHerkunft, actorId); - } - if (erstes) { // Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser - // Linie ins Leere zeigt. Eine Herkunft von aussen bleibt. + // Linie ins Leere zeigt. Eine Herkunft von aussen bleibt: sie ist das + // Einzige, was noch zeigt, woher die Kette kommt. if (glied.predecessorId !== null && inDieserLinie.has(glied.predecessorId)) { daten.predecessor = { disconnect: true }; } @@ -976,6 +959,20 @@ async function verketteNeu( daten.predecessor = { connect: { id: vorheriges as number } }; } + // Wer nach dem Ordnen keinen Vorgänger hat, ist der Anfang einer Kette + // und damit ein gekaufter CC; wer einen hat, ist dessen Redesign. Ob + // der Vorgänger in derselben Linie geführt wird, spielt keine Rolle - + // die Kette ist die Kette. + const vorgaengerDanach = erstes + ? inDieserLinie.has(glied.predecessorId ?? -1) + ? null + : glied.predecessorId + : vorheriges; + + if (kettenBesitzer !== null) { + await buchungFolgtPosition(tx, glied, kettenBesitzer, vorgaengerDanach === null, actorId); + } + // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine // Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein // und auch nicht "geputzt": es ist redesignt, Punkt. @@ -1001,6 +998,61 @@ async function verketteNeu( } } +/** + * Rechnet alle Buchungen eines Spielers anhand der Ketten nach. + * + * Die Regel ist schlicht: **Jede Kette beginnt mit einem gekauften CC, alles + * Weitere darin sind Redesigns.** Bei drei Ketten mit zusammen sechzehn + * Einträgen sind das drei CC-Pakete und dreizehn Redesigns. + * + * Gebraucht wird das, wenn ein Bestand aus einer Zeit stammt, in der eine + * Buchung noch blieb, wo sie beim Anlegen entstand - oder wenn beim + * Nacherfassen viel umsortiert wurde. Angefasst wird nur, was nicht passt, + * und umgebucht wird wie sonst auch: über eine Gegenbuchung. Zwischen + * bezahltem Redesign und Kulanz wird nichts gewechselt. + */ +export async function rebuildFunding( + playerId: number, + ctx: ActionContext, +): Promise<{ geprueft: number; umgebucht: number; balances: Balances }> { + const mitgliedschaften = await prisma.characterMember.findMany({ + where: { playerId }, + select: { character: { select: { id: true, name: true, predecessorId: true } } }, + }); + + let umgebucht = 0; + + await prisma.$transaction(async (tx) => { + for (const { character } of mitgliedschaften) { + // Kein Vorgänger heisst: hier fing eine Kette an, also ein gekaufter + // CC. Sonst ist es das Redesign seines Vorgängers. + const geaendert = await buchungFolgtPosition( + tx, + character, + playerId, + character.predecessorId === null, + ctx.actorId, + ); + if (geaendert) umgebucht += 1; + } + + if (umgebucht > 0) { + await recordAudit(tx, ctx, { + action: 'character.funding_rebuilt', + targetType: 'player', + targetId: playerId, + details: { geprueft: mitgliedschaften.length, umgebucht }, + }); + } + }); + + return { + geprueft: mitgliedschaften.length, + umgebucht, + balances: await getBalances(playerId), + }; +} + /** * Verschiebt einen CC innerhalb seiner Kette oder in eine andere. * diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index 7ab9e7b..f8145de 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -837,3 +837,133 @@ describe('Eine Kette mit Vorgeschichte ausserhalb', () => { ); }); }); + +describe('Die Rechnung geht auf', () => { + /** + * Die Regel in einem Satz: **So viele CC-Pakete wie Ketten, so viele + * Redesigns wie Einträge minus Ketten.** Bei drei Ketten mit zusammen + * sechzehn Einträgen sind das drei Pakete und dreizehn Redesigns. + */ + async function zaehle(playerId: number) { + const eintraege = await db.prisma.characterMember.findMany({ + where: { playerId }, + select: { character: { select: { predecessorId: true } } }, + }); + + const ketten = eintraege.filter((e) => e.character.predecessorId === null).length; + return { eintraege: eintraege.length, ketten }; + } + + async function verbraeuche(playerId: number) { + const zeilen = await db.prisma.creditLedger.findMany({ + where: { playerId, source: 'CONSUME', reversedBy: null }, + select: { kind: true }, + }); + return { + cc: zeilen.filter((z) => z.kind === 'CC').length, + redesign: zeilen.filter((z) => z.kind !== 'CC').length, + }; + } + + it('bucht so viele Pakete wie Ketten und den Rest als Redesign', async () => { + const spieler = await spielerMitPaketen('Rechenbeispiel', 3); + + // Drei Ketten mit 3, 2 und 1 Eintrag: zusammen sechs. + const ketten: number[] = []; + for (const [name, laenge] of [ + ['Kette A', 3], + ['Kette B', 2], + ['Kette C', 1], + ] as const) { + const erster = await characters.createCharacter(spieler, { name: `${name} 1` }, ctx); + let letzter = erster.character.id; + for (let i = 2; i <= laenge; i += 1) { + await credits.bookRedesignPackage(spieler, ctx); + const naechster = await characters.redesignCharacter( + letzter, + { name: `${name} ${i}`, useKind: 'REDESIGN_PAID' }, + ctx, + ); + letzter = naechster.character.id; + } + ketten.push(erster.character.id); + } + + const struktur = await zaehle(spieler); + assert.equal(struktur.ketten, 3); + assert.equal(struktur.eintraege, 6); + + const gebucht = await verbraeuche(spieler); + assert.equal(gebucht.cc, struktur.ketten, 'so viele Pakete wie Ketten'); + assert.equal( + gebucht.redesign, + struktur.eintraege - struktur.ketten, + 'und der Rest sind Redesigns', + ); + }); + + it('rechnet einen verbogenen Bestand wieder gerade', async () => { + const spieler = await spielerMitPaketen('Verbogen', 2); + const eins = await characters.createCharacter(spieler, { name: 'Anfang' }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Fortsetzung', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + // So sah es aus, bevor die Buchung der Kette folgte: das zweite Glied + // stand als eigener Kauf im Konto. + const falsch = await db.prisma.creditLedger.findFirstOrThrow({ + where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, + }); + await db.prisma.creditLedger.update({ where: { id: falsch.id }, data: { kind: 'CC' } }); + + const vorher = await verbraeuche(spieler); + assert.equal(vorher.cc, 2, 'zwei Käufe bei nur einer Kette - das kann nicht stimmen'); + + const ergebnis = await characters.rebuildFunding(spieler, ctx); + assert.equal(ergebnis.umgebucht, 1); + + const struktur = await zaehle(spieler); + const gebucht = await verbraeuche(spieler); + assert.equal(gebucht.cc, struktur.ketten, 'jetzt passt es wieder'); + assert.equal(gebucht.redesign, struktur.eintraege - struktur.ketten); + }); + + it('fasst nichts an, wenn schon alles stimmt', async () => { + const spieler = await spielerMitPaketen('Sauber', 1); + const eins = await characters.createCharacter(spieler, { name: 'Passt' }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + await characters.redesignCharacter( + eins.character.id, + { name: 'Passt auch', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const vorher = await credits.getBalances(spieler); + const ergebnis = await characters.rebuildFunding(spieler, ctx); + + assert.equal(ergebnis.umgebucht, 0); + assert.deepEqual(ergebnis.balances, vorher); + }); + + it('lässt eine Kulanz-Entscheidung auch beim Nachrechnen stehen', async () => { + const spieler = await spielerMitPaketen('Kulanz bleibt', 1); + const eins = await characters.createCharacter(spieler, { name: 'Start' }, ctx); + await credits.bookKulanz(spieler, 1, 'Absprache', ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' }, + ctx, + ); + + await characters.rebuildFunding(spieler, ctx); + + const buchung = await db.prisma.creditLedger.findFirst({ + where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null }, + orderBy: { id: 'desc' }, + }); + assert.equal(buchung?.kind, 'REDESIGN_KULANZ'); + }); +}); diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index 3a210cf..a356239 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -101,6 +101,42 @@ export default function PlayerDetail() { } }; + /** + * Richtet die Buchungen nach den Ketten aus. + * + * Die Regel steckt in der Anzeige: so viele CC-Pakete wie Plätze, alles + * Weitere sind Redesigns. Für Bestände, die aus einer Zeit stammen, in der + * eine Buchung blieb, wo sie beim Anlegen entstand. + */ + const buchungenNachrechnen = async () => { + if ( + !window.confirm( + 'Buchungen nach den Ketten ausrichten?\n\nJede Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns. Was nicht passt, wird umgebucht - über eine Gegenbuchung, nichts geht verloren.', + ) + ) { + return; + } + + setBusy(true); + setError(null); + try { + const ergebnis = await api<{ geprueft: number; umgebucht: number }>( + `/api/players/${playerId}/credits/rebuild`, + { method: 'POST', body: {} }, + ); + await load(); + melden( + ergebnis.umgebucht > 0 + ? `${ergebnis.umgebucht} von ${ergebnis.geprueft} Buchungen umgestellt.` + : `Alles passt - ${ergebnis.geprueft} Buchungen geprüft, nichts zu ändern.`, + ); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Nachrechnen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + /** * Nimmt die zuletzt von Hand eingetragene Zugabe zurück. * @@ -345,6 +381,15 @@ export default function PlayerDetail() { > Neuer CC + {can('EDITOR') && detail.player.steamId && (