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:
2026-09-21 19:26:00 +02:00
parent 46e339fdc4
commit f0213fe4bc
5 changed files with 273 additions and 27 deletions
+17 -8
View File
@@ -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 *Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in
diese Kette gehörte. diese Kette gehörte.
**Die Stelle in der Kette bestimmt, was ein Eintrag kostet.** Das unterste Glied ist der CC, der **Die Stelle in der Kette bestimmt, was ein Eintrag kostet**, und die Rechnung ist schlicht:
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 > 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. 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 Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und
kostet wieder ein CC-Paket. kostet wieder ein CC-Paket.
Eine Ausnahme gibt es: **Hat die Kette eine Herkunft außerhalb**, ist auch ihr unterstes Glied Daraus folgt auch der Fall mit dem fremden Gruppen-CC: Wer aus einem herausredesignt, dessen
kein Kauf. Wer aus einem fremden Gruppen-CC herausredesignt, beginnt zwar eine eigene Kette - eigener Strang hat einen Vorgänger - den Gruppen-CC. Also ist sein erstes Glied kein Kauf,
bezahlt hat er sie trotzdem mit einem Redesign. Sonst stünde derselbe Platz zweimal im Konto: sondern ein Redesign. Bezahlt wurde der Platz beim Beitritt, und zwar einmal.
einmal für den Beitritt, einmal für den eigenen CC.
Umgebucht wird über eine Gegenbuchung, nicht durch Überschreiben - in der Historie bleibt 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. 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. muss er gedeckt sein wie jeder andere.
Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen - Ü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 bezahlt wurde - aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz; umgebucht wird über
eine Gegenbuchung, in der Historie bleibt beides sichtbar. eine Gegenbuchung, in der Historie bleibt beides sichtbar.
+10
View File
@@ -26,6 +26,7 @@ import {
detachFromChain, detachFromChain,
changeFunding, changeFunding,
addMemberToChain, addMemberToChain,
rebuildFunding,
} from '../services/characters.js'; } from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js'; import { parseThreadId } from '../lib/forum.js';
import { linkThreadToCharacter } from '../services/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)); 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 --- // --- Ketten ---
/** /**
+71 -19
View File
@@ -942,33 +942,16 @@ async function verketteNeu(
const kettenBesitzer = glieder[0]?.playerId ?? null; const kettenBesitzer = glieder[0]?.playerId ?? null;
const inDieserLinie = new Set(glieder.map((g) => g.id)); 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; let vorheriges: number | null = null;
for (const [index, glied] of glieder.entries()) { for (const [index, glied] of glieder.entries()) {
const letztes = index === glieder.length - 1; const letztes = index === glieder.length - 1;
const erstes = index === 0; const erstes = index === 0;
const daten: Prisma.CharacterUpdateInput = {}; 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) { if (erstes) {
// Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser // 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)) { if (glied.predecessorId !== null && inDieserLinie.has(glied.predecessorId)) {
daten.predecessor = { disconnect: true }; daten.predecessor = { disconnect: true };
} }
@@ -976,6 +959,20 @@ async function verketteNeu(
daten.predecessor = { connect: { id: vorheriges as number } }; 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 // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine
// Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein // Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein
// und auch nicht "geputzt": es ist redesignt, Punkt. // 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. * Verschiebt einen CC innerhalb seiner Kette oder in eine andere.
* *
+130
View File
@@ -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');
});
});
+45
View File
@@ -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. * Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
* *
@@ -345,6 +381,15 @@ export default function PlayerDetail() {
> >
Neuer CC Neuer CC
</button> </button>
<button
type="button"
className="btn"
disabled={busy}
title="Jede Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns - danach die Buchungen richten"
onClick={() => void buchungenNachrechnen()}
>
Buchungen nachrechnen
</button>
{can('EDITOR') && detail.player.steamId && ( {can('EDITOR') && detail.player.steamId && (
<button <button
type="button" type="button"