From f7b48a9891b437a38dffa29b33ae902d92f8635d Mon Sep 17 00:00:00 2001 From: Harlekin Date: Mon, 21 Sep 2026 18:50:27 +0200 Subject: [PATCH] Guthaben: "von wie vielen" anzeigen und Zugaben zuruecknehmen In den Kaesten steht jetzt neben dem offenen Stand, wie viel jemals in den Topf kam: "0 von 3" liest sich anders als "0 von 0" - das eine heisst aufgebraucht, das andere nie etwas gehabt. Gezaehlt werden dabei nur echte Zugaenge: Die Ruecknahme eines Verbrauchs zaehlt dorthin, wo die stornierte Zeile zaehlte, sonst saehe ein zurueckgenommenes Redesign wie ein zusaetzliches Paket aus. Neben jedem Plus steht ein Minus, das die zuletzt von Hand eingetragene Zugabe dieser Art zurueckholt - fuer den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. Zurueckgenommen wird ueber eine Gegenbuchung; bei einem CC-Paket gehen beide Zeilen mit. Aus einem Import gebuchte Pakete bleiben aussen vor: die gehoeren zu einer Bestellung und werden dort storniert, wo der Zusammenhang sichtbar ist. Die Rechte folgen dem Eintragen - Kulanz bleibt bei den Admins. Nebenbei zwei wacklige Tests festgezurrt: Die Testdatenbanken weichen auf den naechsten Port aus, wenn einer aus einem frueheren Lauf noch haengt, und der Sperr-Test des Abgleichs klopft nicht mehr bei GExtension an, sondern laeuft gegen eine tote Adresse. --- README.md | 14 ++++ server/src/routes/credits.routes.ts | 36 ++++++++- server/src/services/credits.ts | 113 ++++++++++++++++++++++++++-- server/test/harness.ts | 28 ++++++- server/test/http.test.ts | 2 + server/test/rules.test.ts | 90 ++++++++++++++++++++++ server/test/zeitplan.test.ts | 4 + web/src/components/BalanceCards.tsx | 29 +++++-- web/src/lib/types.ts | 2 + web/src/pages/PlayerDetail.tsx | 91 +++++++++++++++++----- web/src/styles.css | 26 +++++++ 11 files changed, 397 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index dd9b879..a9e7325 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,20 @@ An jedem Eintrag hängen beliebig viele Verweise - das Steam-Addon des Ersteller eine Absprache. Eingetragen wird ein **Name** und eine **Adresse**; in der Liste steht dann der Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*. +## Guthaben von Hand eintragen + +Auf der Spielerseite stehen drei Paare: **+ CC-Paket**, **+ Redesign-Paket**, **+ Kulanz** - und +daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt. Für +den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. + +Zurückgenommen wird über eine Gegenbuchung; in der Historie bleibt beides stehen. Bei einem +CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthaltene Redesign. +**Aus einem Import gebuchte Pakete rührt das nicht an** - die gehören zu einer Bestellung und +werden dort storniert, wo dieser Zusammenhang sichtbar ist. + +In den Kästen oben steht neben dem offenen Stand, wie viel jemals hereinkam: *0 von 3* liest +sich anders als *0 von 0*. Das eine heißt aufgebraucht, das andere nie etwas gehabt. + ## Gruppen-CCs Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen diff --git a/server/src/routes/credits.routes.ts b/server/src/routes/credits.routes.ts index c2761a2..463c2e3 100644 --- a/server/src/routes/credits.routes.ts +++ b/server/src/routes/credits.routes.ts @@ -1,12 +1,13 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { CreditKind } from '@prisma/client'; +import { CreditKind, CreditSource } from '@prisma/client'; import { requireAdmin, requireEditor } from '../auth/guard.js'; import { bookAdjustment, bookCcPackage, bookKulanz, bookRedesignPackage, + removeLastGrant, reverseGroup, } from '../services/credits.js'; import { contextOf, idParam, optionalNote, parseWith } from './helpers.js'; @@ -38,6 +39,39 @@ export async function creditRoutes(app: FastifyInstance): Promise { return bookKulanz(id, contextOf(request), { note: body.note }); }); + /** + * Nimmt die zuletzt von Hand eingetragene Zugabe zurück. + * + * Die Rechte folgen dem Eintragen: Wer ein Paket eintragen darf, darf es + * auch wieder zurücknehmen; bei Kulanz bleibt beides bei den Admins. + */ + app.post( + '/api/players/:id/credits/cc-package/remove', + { preHandler: requireEditor }, + async (request) => { + const { id } = parseWith(idParam, request.params); + return removeLastGrant(id, CreditSource.CC_PACKAGE, contextOf(request)); + }, + ); + + app.post( + '/api/players/:id/credits/redesign-package/remove', + { preHandler: requireEditor }, + async (request) => { + const { id } = parseWith(idParam, request.params); + return removeLastGrant(id, CreditSource.REDESIGN_PACKAGE, contextOf(request)); + }, + ); + + app.post( + '/api/players/:id/credits/kulanz/remove', + { preHandler: requireAdmin }, + async (request) => { + const { id } = parseWith(idParam, request.params); + return removeLastGrant(id, CreditSource.KULANZ, contextOf(request)); + }, + ); + app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => { const { id } = parseWith(idParam, request.params); const body = parseWith( diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts index 32cf0c3..c0258ba 100644 --- a/server/src/services/credits.ts +++ b/server/src/services/credits.ts @@ -10,27 +10,71 @@ export interface Balances { REDESIGN_KULANZ: number; // Für ein Redesign zählen beide Redesign-Töpfe zusammen. redesignTotal: number; + /** + * Wie viel jemals in den Topf kam - Käufe, Kulanz, Korrekturen, abzüglich + * dessen, was davon storniert wurde. Der Stand oben sagt, was noch offen + * ist; das hier sagt, wovon. "0 von 3" liest sich anders als "0 von 0". + */ + granted: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; } -const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 }; +const EMPTY: Balances = { + CC: 0, + REDESIGN_PAID: 0, + REDESIGN_KULANZ: 0, + redesignTotal: 0, + granted: { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 }, +}; -function assemble(sums: Partial>): Balances { +function assemble( + sums: Partial>, + granted?: Partial>, +): 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 }; + return { + CC: cc, + REDESIGN_PAID: paid, + REDESIGN_KULANZ: kulanz, + redesignTotal: paid + kulanz, + granted: { + CC: granted?.CC ?? 0, + REDESIGN_PAID: granted?.REDESIGN_PAID ?? 0, + REDESIGN_KULANZ: granted?.REDESIGN_KULANZ ?? 0, + }, + }; } export async function getBalances(playerId: number): Promise { - const rows = await prisma.creditLedger.groupBy({ - by: ['kind'], + // Für den Stand genügte eine Summe je Topf. Für "von wie vielen" muss + // jede Zeile angesehen werden: ein Storno zählt dorthin, wohin die Zeile + // zählte, die es zurücknimmt - sonst sähe die Rücknahme eines Verbrauchs + // wie ein zusätzliches Paket aus. + const eintraege = await prisma.creditLedger.findMany({ where: { playerId }, - _sum: { amount: true }, + select: { + kind: true, + amount: true, + source: true, + reversalOf: { select: { source: true } }, + }, }); const sums: Partial> = {}; - for (const row of rows) sums[row.kind] = row._sum.amount ?? 0; - return assemble(sums); + const granted: Partial> = {}; + + for (const eintrag of eintraege) { + sums[eintrag.kind] = (sums[eintrag.kind] ?? 0) + eintrag.amount; + + const zweck = + eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source; + if (zweck !== CreditSource.CONSUME) { + granted[eintrag.kind] = (granted[eintrag.kind] ?? 0) + eintrag.amount; + } + } + + return assemble(sums, granted); } export async function getBalancesForMany(playerIds: number[]): Promise> { @@ -243,6 +287,59 @@ export async function bookAdjustment( * bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden * über die zugehörige CC-Aktion zurückgenommen. */ +/// Was sich über die Knöpfe auf der Spielerseite eintragen lässt. +const HANDEINTRAEGE = [ + CreditSource.CC_PACKAGE, + CreditSource.REDESIGN_PACKAGE, + CreditSource.KULANZ, +] as const; +export type Handeintrag = (typeof HANDEINTRAEGE)[number]; + +/** + * Nimmt die zuletzt von Hand eingetragene Zugabe wieder zurück. + * + * Wer sich vertippt oder zweimal geklickt hat, soll das geradeziehen können, + * ohne in der Historie nach der richtigen Zeile zu suchen. Zurückgenommen + * wird die jüngste offene Zugabe dieser Art - über eine Gegenbuchung, wie + * jede Korrektur hier. Bei einem CC-Paket sind das beide Zeilen: das + * CC-Guthaben und das Redesign, das darin steckt. + * + * Eingetragen aus einem Import wird dabei ausgelassen. Ein gebuchtes Paket + * gehört zu einer Bestellung; wer das zurücknehmen will, tut es dort, wo der + * Zusammenhang sichtbar ist - in der Historie. + */ +export async function removeLastGrant( + playerId: number, + source: Handeintrag, + ctx: ActionContext, +): Promise<{ balances: Balances; reversed: number }> { + await requirePlayer(playerId); + + if (!HANDEINTRAEGE.includes(source)) { + throw badRequest('INVALID_SOURCE', 'Diese Art lässt sich nicht von Hand zurücknehmen'); + } + + const juengste = await prisma.creditLedger.findFirst({ + where: { playerId, source, importId: null, reversedBy: null }, + orderBy: { id: 'desc' }, + }); + + if (!juengste) { + const was = + source === CreditSource.CC_PACKAGE + ? 'CC-Paket' + : source === CreditSource.REDESIGN_PACKAGE + ? 'Redesign-Paket' + : 'Kulanz'; + throw conflict( + 'NOTHING_TO_REMOVE', + `Es gibt kein von Hand eingetragenes ${was}, das sich zurücknehmen liesse. Aus einem Import gebuchte Pakete werden über die Historie storniert.`, + ); + } + + return reverseGroup(juengste.id, 'Von Hand zurückgenommen', ctx); +} + export async function reverseGroup( ledgerId: number, reason: string, diff --git a/server/test/harness.ts b/server/test/harness.ts index 7cbd5e4..957d6d4 100644 --- a/server/test/harness.ts +++ b/server/test/harness.ts @@ -31,13 +31,35 @@ export async function startTestDatabase(port: number): Promise { await db.exec(readFileSync(file, 'utf8')); } - const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' }); - await server.start(); + // Die Testdateien laufen nebeneinander und bekommen feste Ports. Hängt aus + // einem vorherigen Lauf noch einer fest, wäre der Start sonst ohne Grund + // fehlgeschlagen - deshalb ein paar Versuche mit dem nächsten Port. + let server: PGLiteSocketServer | null = null; + let benutzt = port; + + for (let versuch = 0; versuch < 20; versuch += 1) { + const kandidat = new PGLiteSocketServer({ db, port: benutzt, host: '127.0.0.1' }); + try { + await kandidat.start(); + server = kandidat; + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EADDRINUSE') throw error; + benutzt += 1; + } + } + + if (!server) { + throw new Error(`Kein freier Port ab ${port} für die Testdatenbank gefunden`); + } + + const belegterPort = benutzt; return { // connection_limit=1, weil die Testinstanz immer nur eine Verbindung // gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht. - url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`, + url: `postgresql://postgres:postgres@127.0.0.1:${belegterPort}/postgres?connection_limit=1`, stop: async () => { await server.stop(); await db.close(); diff --git a/server/test/http.test.ts b/server/test/http.test.ts index 7d9041d..d8ae015 100644 --- a/server/test/http.test.ts +++ b/server/test/http.test.ts @@ -385,6 +385,8 @@ describe('Guthaben über die API', () => { REDESIGN_PAID: 1, REDESIGN_KULANZ: 0, redesignTotal: 1, + // Was offen ist, und wovon: "1 von 1". + granted: { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0 }, }); }); diff --git a/server/test/rules.test.ts b/server/test/rules.test.ts index 76bb4d7..dd8a6bd 100644 --- a/server/test/rules.test.ts +++ b/server/test/rules.test.ts @@ -314,3 +314,93 @@ describe('Korrekturen', () => { ); }); }); + +describe('Von Hand eingetragene Guthaben zurücknehmen', () => { + it('zählt, wie viel jemals in den Topf kam', async () => { + const playerId = await freshPlayer('Zaehler'); + await credits.bookCcPackage(playerId, ctx); + await credits.bookCcPackage(playerId, ctx); + await characters.createCharacter(playerId, { name: 'Verbraucht eines' }, ctx); + + const stand = await credits.getBalances(playerId); + assert.equal(stand.CC, 1, 'eines ist noch offen'); + assert.equal(stand.granted.CC, 2, 'zwei kamen jemals herein'); + assert.equal(stand.granted.REDESIGN_PAID, 2, 'jedes Paket bringt eines mit'); + }); + + it('zählt die Rücknahme eines Verbrauchs nicht als Zugang', async () => { + // Sonst sähe ein storniertes Redesign wie ein zusätzliches Paket aus. + const playerId = await freshPlayer('Stornierer'); + await credits.bookCcPackage(playerId, ctx); + const cc = await characters.createCharacter(playerId, { name: 'Wird zurückgenommen' }, ctx); + await characters.revertCharacter(cc.character.id, 'Fehleintrag', ctx); + + const stand = await credits.getBalances(playerId); + assert.equal(stand.CC, 1, 'das Guthaben ist zurück'); + assert.equal(stand.granted.CC, 1, 'hereingekommen ist trotzdem nur eines'); + }); + + it('nimmt das zuletzt eingetragene CC-Paket zurück, beide Zeilen', async () => { + const playerId = await freshPlayer('Vertipper'); + await credits.bookCcPackage(playerId, ctx); + await credits.bookCcPackage(playerId, ctx); + + const { balances } = await credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx); + + assert.equal(balances.CC, 1); + assert.equal(balances.REDESIGN_PAID, 1, 'das mitgelieferte Redesign geht mit zurück'); + assert.equal(balances.granted.CC, 1, 'und zählt auch nicht mehr als erhalten'); + }); + + it('nimmt eine Kulanz zurück', async () => { + const playerId = await freshPlayer('Kulanzfall'); + await credits.bookKulanz(playerId, ctx, { note: 'War zu grosszügig' }); + + const { balances } = await credits.removeLastGrant(playerId, 'KULANZ', ctx); + assert.equal(balances.REDESIGN_KULANZ, 0); + assert.equal(balances.granted.REDESIGN_KULANZ, 0); + }); + + it('meldet sich, wenn es nichts zurückzunehmen gibt', async () => { + const playerId = await freshPlayer('Leerer Topf'); + + await assert.rejects( + () => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx), + (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', + ); + }); + + it('lässt gebuchte Import-Pakete in Ruhe', async () => { + // Ein Paket aus einer Bestellung gehört in die Historie, nicht unter + // einen Knopf, der "das letzte weg" bedeutet. + const playerId = await freshPlayer('Importiert'); + const importRecord = await db.prisma.import.create({ + data: { filename: 'shop.csv', status: 'COMMITTED', rowCount: 1 }, + }); + await db.prisma.creditLedger.create({ + data: { + playerId, + kind: 'CC', + amount: 1, + source: 'CC_PACKAGE', + importId: importRecord.id, + }, + }); + + await assert.rejects( + () => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx), + (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', + ); + }); + + it('nimmt dieselbe Zugabe nicht zweimal zurück', async () => { + const playerId = await freshPlayer('Doppelklicker'); + await credits.bookKulanz(playerId, ctx); + + await credits.removeLastGrant(playerId, 'KULANZ', ctx); + await assert.rejects( + () => credits.removeLastGrant(playerId, 'KULANZ', ctx), + (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', + ); + }); +}); diff --git a/server/test/zeitplan.test.ts b/server/test/zeitplan.test.ts index 487af75..1499c91 100644 --- a/server/test/zeitplan.test.ts +++ b/server/test/zeitplan.test.ts @@ -21,6 +21,10 @@ before(async () => { process.env.DATABASE_URL = database.url; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.NODE_ENV = 'test'; + // Der Sperr-Test stösst einen echten Abruf an. Er soll sofort scheitern + // statt in ein Zeitlimit zu laufen - und vor allem nicht wirklich bei + // GExtension anklopfen, nur weil hier Tests laufen. + process.env.GEXTENSION_BASE_URL = 'http://127.0.0.1:1'; db = await import('../src/db.js'); schedule = await import('../src/services/schedule.js'); diff --git a/web/src/components/BalanceCards.tsx b/web/src/components/BalanceCards.tsx index 6356e56..f03ab35 100644 --- a/web/src/components/BalanceCards.tsx +++ b/web/src/components/BalanceCards.tsx @@ -1,11 +1,19 @@ import type { Balances } from '../lib/types'; -function Card({ label, value, hint }: { label: string; value: number; hint?: string }) { +/** + * Ein Topf: was noch offen ist, und wovon. + * + * "0 von 3" liest sich anders als "0 von 0" - das eine heisst aufgebraucht, + * das andere nie etwas gehabt. Ohne die zweite Zahl sehen beide gleich aus. + */ +function Card({ label, value, granted }: { label: string; value: number; granted: number }) { return (
{label} - {value} - {hint && {hint}} + + {value} + {granted > 0 && von {granted}} +
); } @@ -13,13 +21,22 @@ function Card({ label, value, hint }: { label: string; value: number; hint?: str export default function BalanceCards({ balances }: { balances: Balances }) { const canCreate = balances.CC >= 1; const canRedesign = balances.redesignTotal >= 1; + const granted = balances.granted ?? { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 }; return (
- - - + + +
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 91a3ea6..b13f570 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -29,6 +29,8 @@ export interface Balances { REDESIGN_PAID: number; REDESIGN_KULANZ: number; redesignTotal: number; + /// Wie viel jemals in den Topf kam, abzüglich stornierter Zugänge. + granted?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; } export interface PlayerSummary { diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index 86b18e1..ac9a725 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -101,6 +101,22 @@ export default function PlayerDetail() { } }; + /** + * Nimmt die zuletzt von Hand eingetragene Zugabe zurück. + * + * Zurückgenommen wird über eine Gegenbuchung - in der Historie bleibt + * beides stehen. Gebuchte Import-Pakete rührt das nicht an; die gehören zu + * einer Bestellung und werden dort storniert, wo der Zusammenhang steht. + */ + const zugabeZuruecknehmen = async (pfad: string, was: string) => { + if (!window.confirm(`Zuletzt eingetragenes ${was} zurücknehmen?`)) return; + await run( + () => + api(`/api/players/${playerId}/credits/${pfad}/remove`, { method: 'POST', body: {} }), + `${was} zurückgenommen.`, + ); + }; + // Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung // auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten // des Spielers, deshalb kann auch ein einzelner Lauf dauern. @@ -259,32 +275,67 @@ export default function PlayerDetail() { {editor && (
- - - {/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */} - {can('ADMIN') && ( + + + + + + + + + + {/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */} + {can('ADMIN') && ( + + + + )}