Dev 4.8.0 #15
+224
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
|
||||
|
||||
use App\Enumerations\ParticipationType;
|
||||
use App\Models\CostUnit;
|
||||
use App\Models\Event;
|
||||
use App\Providers\PdfGenerateAndDownloadProvider;
|
||||
use App\Repositories\CostUnitRepository;
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Erzeugt die Einnahmen-Überschuss-Rechnung einer Veranstaltung als PDF.
|
||||
*
|
||||
* Gezeigt wird ausschließlich Geld, das geflossen ist: gezahlte Beiträge, weitere Einnahmen, was von
|
||||
* Abmeldungen einbehalten wurde, Fördermittel -- und auf der anderen Seite die erfassten Belege. Was nur
|
||||
* erwartet (offene Beiträge) oder geplant (Budgetwerte) ist, gehört in eine Einnahmen-Überschuss-Rechnung
|
||||
* nicht hinein.
|
||||
*
|
||||
* Es wird nichts gespeichert: Alle Zahlen leiten sich aus dem aktuellen Stand ab, ein erneuter Abruf
|
||||
* liefert den dann gültigen Stand.
|
||||
*/
|
||||
class CreateIncomeSurplusStatementCommand
|
||||
{
|
||||
private Event $event;
|
||||
|
||||
private CostUnitRepository $costUnits;
|
||||
|
||||
public function __construct(private readonly CreateIncomeSurplusStatementRequest $request)
|
||||
{
|
||||
$this->event = $request->event;
|
||||
$this->costUnits = new CostUnitRepository();
|
||||
}
|
||||
|
||||
public function execute(): CreateIncomeSurplusStatementResponse
|
||||
{
|
||||
$response = new CreateIncomeSurplusStatementResponse();
|
||||
|
||||
$costUnit = $this->event->costUnit()->first();
|
||||
|
||||
if (!$costUnit instanceof CostUnit) {
|
||||
$response->message = 'Der Veranstaltung ist keine Kostenstelle zugeordnet.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Der Pauschalbetrag wird vor dem Resource-Aufruf gelesen: `EventResource::calculateSupportPerPerson()`
|
||||
// multipliziert das Amount-Objekt von `support_per_person` in place. Auf `support_flat` wirkt das
|
||||
// zwar nicht, aber der gesamte Zugriff auf Beträge des Models ist danach nicht mehr vertrauenswürdig.
|
||||
$otherIncome = $this->event->support_flat->getAmount();
|
||||
|
||||
$eventData = $this->event->toResource()->toArray(new Request());
|
||||
|
||||
$income = $this->buildIncome($eventData, $otherIncome);
|
||||
$expenses = $this->buildExpenses($costUnit);
|
||||
|
||||
$result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro');
|
||||
|
||||
$html = view('pdfs.income-surplus-statement', [
|
||||
'event' => $this->event,
|
||||
'createdAt' => new \DateTime()->format('d.m.Y'),
|
||||
'income' => $income,
|
||||
'expenses' => $expenses,
|
||||
'result' => $result,
|
||||
'money' => self::money(...),
|
||||
])->render();
|
||||
|
||||
$response->success = true;
|
||||
$response->filename = 'EUER-' . $this->event->identifier . '.pdf';
|
||||
$response->income = $income;
|
||||
$response->expenses = $expenses;
|
||||
$response->result = $result;
|
||||
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Einnahmenseite in zwei Ober-Kategorien.
|
||||
*
|
||||
* Alle Zahlen stammen aus {@see \App\Resources\EventResource} -- derselben Quelle wie die
|
||||
* Veranstaltungsübersicht am Bildschirm. Eine eigene Rechnung daneben würde über kurz oder lang von
|
||||
* der Übersicht abweichen, und dann glaubt niemand mehr einer der beiden Zahlen.
|
||||
*
|
||||
* @param array<string, mixed> $eventData
|
||||
*
|
||||
* @return array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}
|
||||
*/
|
||||
private function buildIncome(array $eventData, float $otherIncome): array
|
||||
{
|
||||
// Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen
|
||||
// hereingekommen ist, nicht von wem.
|
||||
$participationFees = new Amount(0, 'Euro');
|
||||
foreach ([
|
||||
ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||
ParticipationType::PARTICIPATION_TYPE_TEAM,
|
||||
ParticipationType::PARTICIPATION_TYPE_VOLUNTEER,
|
||||
ParticipationType::PARTICIPATION_TYPE_OTHER,
|
||||
] as $participationType) {
|
||||
$participationFees->addAmount(
|
||||
new Amount((float) $eventData['participants'][$participationType]['amount']['paid']['value'], 'Euro')
|
||||
);
|
||||
}
|
||||
|
||||
$ownFunds = [
|
||||
['name' => 'Teilnahmebeiträge', 'amount' => $participationFees],
|
||||
['name' => 'Weitere Einnahmen', 'amount' => new Amount($otherIncome, 'Euro')],
|
||||
[
|
||||
'name' => 'Einbehaltene Einnahmen aus Abmeldungen',
|
||||
'amount' => new Amount((float) $eventData['retainedFromUnregistered']['value'], 'Euro'),
|
||||
],
|
||||
];
|
||||
|
||||
$supportRate = new Amount((float) $eventData['supportPersonValue'], 'Euro');
|
||||
$funding = [
|
||||
[
|
||||
'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)',
|
||||
'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'),
|
||||
],
|
||||
];
|
||||
|
||||
$categories = [
|
||||
['name' => 'Eigenmittel', 'entries' => $ownFunds, 'total' => self::sum($ownFunds)],
|
||||
['name' => 'Förderungen', 'entries' => $funding, 'total' => self::sum($funding)],
|
||||
];
|
||||
|
||||
return [
|
||||
'categories' => $categories,
|
||||
'total' => self::sum($categories),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage.
|
||||
*
|
||||
* @return array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array{number: string, date: string, purpose: string, amount: Amount}>}>, total: Amount}
|
||||
*/
|
||||
private function buildExpenses(CostUnit $costUnit): array
|
||||
{
|
||||
$groups = [];
|
||||
$total = new Amount(0, 'Euro');
|
||||
|
||||
foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) {
|
||||
$rows = [];
|
||||
|
||||
foreach ($group['invoices'] as $invoice) {
|
||||
$rows[] = [
|
||||
'number' => (string) $invoice->invoice_number,
|
||||
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
|
||||
'purpose' => $this->purpose($invoice->type_other, $invoice->comment),
|
||||
'amount' => Amount::fromString($invoice->amount),
|
||||
];
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
'name' => $group['type']->name,
|
||||
'sum' => $group['sum'],
|
||||
'rows' => $rows,
|
||||
];
|
||||
|
||||
$total->addAmount($group['sum']);
|
||||
}
|
||||
|
||||
return ['groups' => $groups, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wofür der Beleg steht.
|
||||
*
|
||||
* `type_other` trägt seit der Pflichtangabe "Was wurde eingekauft" zu jeder Abrechnung den Zweck,
|
||||
* nicht mehr nur bei "Sonstige Kosten". Ältere Belege haben das Feld leer -- dann bleibt die
|
||||
* Anmerkung, und fehlt auch die, bleibt die Zelle leer. Ein Platzhalter wie "--" würde in der
|
||||
* Belegliste nur Platz kosten.
|
||||
*/
|
||||
private function purpose(?string $typeOther, ?string $comment): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ([$typeOther, $comment] as $part) {
|
||||
if (trim((string) $part) !== '') {
|
||||
$parts[] = trim((string) $part);
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' — ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
|
||||
*
|
||||
* Bewusst nicht {@see Amount::getFormattedAmount()}: Die Methode ersetzt nach `number_format` jeden
|
||||
* Punkt durch ein Komma und macht aus 1.487,50 damit "1,487,50". Auf einer Aufstellung, in der
|
||||
* vierstellige Beträge die Regel sind, wäre das nicht lesbar. Der Fehler steckt im Value Object und
|
||||
* wirkt überall, wo Beträge angezeigt werden -- ihn dort zu beheben ist eine eigene Änderung.
|
||||
*
|
||||
* Öffentlich, weil die Vorlage sie als Callable bekommt und weil sie für sich prüfbar sein soll.
|
||||
*/
|
||||
public static function money(Amount $amount): string
|
||||
{
|
||||
return number_format(round($amount->getAmount(), 2), 2, ',', '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Summiert Zeilen, die je ein `amount` oder `total` tragen.
|
||||
*
|
||||
* Über ein frisches Amount-Objekt, weil `Amount::addAmount()` den Empfänger verändert -- die
|
||||
* Einzelbeträge sollen unangetastet bleiben, sie werden anschließend gedruckt.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
private static function sum(array $rows): Amount
|
||||
{
|
||||
$sum = new Amount(0, 'Euro');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var Amount $amount */
|
||||
$amount = $row['amount'] ?? $row['total'];
|
||||
$sum->addAmount($amount);
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class CreateIncomeSurplusStatementRequest
|
||||
{
|
||||
public function __construct(public readonly Event $event)
|
||||
{
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
|
||||
|
||||
use App\ValueObjects\Amount;
|
||||
|
||||
class CreateIncomeSurplusStatementResponse
|
||||
{
|
||||
public bool $success = false;
|
||||
|
||||
public string $filename = '';
|
||||
|
||||
public string $pdfContent = '';
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
/**
|
||||
* Die Zahlen, aus denen das PDF entsteht -- Einnahmen-Kategorien, Ausgaben-Gruppen und das Ergebnis.
|
||||
*
|
||||
* Sie stehen hier, weil sie das eigentliche Ergebnis der Action sind; das PDF ist nur ihre Darstellung.
|
||||
* So lässt sich die Rechnung prüfen, ohne ein PDF zerlegen zu müssen.
|
||||
*
|
||||
* @var array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}|array{}
|
||||
*/
|
||||
public array $income = [];
|
||||
|
||||
/**
|
||||
* @var array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array<string, mixed>>}>, total: Amount}|array{}
|
||||
*/
|
||||
public array $expenses = [];
|
||||
|
||||
public ?Amount $result = null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Controllers;
|
||||
|
||||
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementCommand;
|
||||
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementRequest;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class IncomeSurplusStatementController extends CommonController
|
||||
{
|
||||
public function __invoke(string $eventId): Response
|
||||
{
|
||||
$event = $this->events->getByIdentifier($eventId);
|
||||
|
||||
if ($event === null) {
|
||||
abort(403, 'Zugriff verweigert.');
|
||||
}
|
||||
|
||||
$statementRequest = new CreateIncomeSurplusStatementRequest($event);
|
||||
$statementCommand = new CreateIncomeSurplusStatementCommand($statementRequest);
|
||||
$statementResponse = $statementCommand->execute();
|
||||
|
||||
if (!$statementResponse->success) {
|
||||
abort(422, $statementResponse->message ?? 'Die EÜR konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
return response($statementResponse->pdfContent, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="' . $statementResponse->filename . '"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use App\Domains\Event\Controllers\ArchivedEventsController;
|
||||
use App\Domains\Event\Controllers\AvailableEventsController;
|
||||
use App\Domains\Event\Controllers\CreateController;
|
||||
use App\Domains\Event\Controllers\DetailsController;
|
||||
use App\Domains\Event\Controllers\IncomeSurplusStatementController;
|
||||
use App\Domains\Event\Controllers\SignupController;
|
||||
use App\Middleware\IdentifyTenant;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -18,6 +19,10 @@ Route::middleware(IdentifyTenant::class)->group(function () {
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/details/{eventId}', DetailsController::class);
|
||||
|
||||
// Vor der Wildcard darunter: Sonst greift `downloadPdfList()` und sucht ein Blade namens
|
||||
// `income-surplus-statement` mit Teilnehmendendaten, die die EÜR gar nicht braucht.
|
||||
Route::get('/details/{eventId}/pdf/income-surplus-statement', IncomeSurplusStatementController::class);
|
||||
Route::get('/details/{eventId}/pdf/{listType}', [DetailsController::class, 'downloadPdfList']);
|
||||
Route::get('/details/{eventId}/csv/{listType}', [DetailsController::class, 'downloadCsvList']);
|
||||
});
|
||||
|
||||
@@ -106,6 +106,10 @@ async function showEventAddons() {
|
||||
<input type="button" value="Beitragsliste (PDF)" />
|
||||
</a><br/>
|
||||
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/income-surplus-statement'">
|
||||
<input type="button" value="EüR (PDF)" />
|
||||
</a><br/>
|
||||
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
|
||||
<input type="button" value="Getränkeliste (PDF)" />
|
||||
</a><br/>
|
||||
@@ -143,6 +147,10 @@ async function showEventAddons() {
|
||||
<input type="button" value="Beitragsliste (PDF)" />
|
||||
</a><br/>
|
||||
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/income-surplus-statement'">
|
||||
<input type="button" value="EüR (PDF)" />
|
||||
</a><br/>
|
||||
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
|
||||
<input type="button" value="Getränkeliste (PDF)" />
|
||||
</a><br/>
|
||||
|
||||
Reference in New Issue
Block a user