Die Rechnung auf eine Regel bringen, plus Knopf zum Nachrechnen
Die Regel lautet: so viele CC-Pakete wie Ketten, so viele Redesigns wie Eintraege minus Ketten. Anders gesagt - wer keinen Vorgaenger hat, ist der Anfang einer Kette und damit ein gekaufter CC; wer einen hat, ist dessen Redesign. In welcher Linie ein Eintrag gefuehrt wird, spielt keine Rolle. Ich hatte das mit einer Sonderbehandlung fuer "Herkunft ausserhalb der Linie" gebaut. Die faellt weg: Sie war ein Umweg um dieselbe Regel und hat sie an einer Stelle verfehlt. Jetzt steht sie direkt im Code, und der Fall mit dem fremden Gruppen-CC folgt von selbst daraus - dessen eigener Strang hat einen Vorgaenger, ist also kein Kauf. Fuer Bestaende, die aus einer Zeit stammen, in der eine Buchung blieb, wo sie beim Anlegen entstand, gibt es "Buchungen nachrechnen" auf der Spielerseite. Es richtet alle Buchungen eines Spielers nach dieser Regel aus, fasst nur an, was nicht passt, bucht ueber Gegenbuchungen um und laesst Kulanz- Entscheidungen stehen. Vier Tests halten die Rechnung fest, einer davon genau in der Form, in der sie formuliert wurde: Ketten zaehlen, Eintraege zaehlen, Buchungen zaehlen, vergleichen.
This commit is contained in:
@@ -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<void> {
|
||||
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 ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user