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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user