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.
This commit is contained in:
@@ -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<void> {
|
||||
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(
|
||||
|
||||
@@ -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<Record<CreditKind, number>>): Balances {
|
||||
function assemble(
|
||||
sums: Partial<Record<CreditKind, number>>,
|
||||
granted?: 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 };
|
||||
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<Balances> {
|
||||
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<Record<CreditKind, number>> = {};
|
||||
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
|
||||
return assemble(sums);
|
||||
const granted: Partial<Record<CreditKind, number>> = {};
|
||||
|
||||
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<Map<number, Balances>> {
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user