diff --git a/app/Domains/CostUnit/Controllers/ExportController.php b/app/Domains/CostUnit/Controllers/ExportController.php index a473033..5d14f4c 100644 --- a/app/Domains/CostUnit/Controllers/ExportController.php +++ b/app/Domains/CostUnit/Controllers/ExportController.php @@ -4,14 +4,13 @@ namespace App\Domains\CostUnit\Controllers; use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusCommand; use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusRequest; -use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest; use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptCommand; use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptRequest; use App\Enumerations\InvoiceStatus; +use App\Models\SepaPaymentElement; use App\Models\Tenant; use App\Providers\FileWriteProvider; use App\Providers\InvoiceCsvFileProvider; -use App\Providers\PainFileProvider; use App\Providers\WebDavProvider; use App\Providers\ZipArchiveFileProvider; use App\Scopes\CommonController; @@ -25,24 +24,19 @@ class ExportController extends CommonController { $webdavProvider = new WebDavProvider(WebDavProvider::INVOICE_PREFIX . $this->tenant->url . '/' . $costUnit->name); - $painFileData = $this->painData($invoicesForExport); + $this->createSepaPaymentElements($invoicesForExport, $costUnit); $csvData = $this->csvData($invoicesForExport); $filePrefix = Tenant::getTempDirectory(); - $painFileWriteProvider = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '-sepa.xml', $painFileData); - $painFileWriteProvider->writeToFile(); - $csvFileWriteProvider = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '.csv', $csvData); $csvFileWriteProvider->writeToFile(); if ($this->tenant->upload_exports) { - $webdavProvider->uploadFile($painFileWriteProvider->fileName); $webdavProvider->uploadFile($csvFileWriteProvider->fileName); } $downloadZipArchiveFiles = [ - $painFileWriteProvider->fileName, $csvFileWriteProvider->fileName ]; @@ -72,7 +66,6 @@ class ExportController extends CommonController { Storage::delete($file); } - Storage::delete($painFileWriteProvider->fileName); Storage::delete($csvFileWriteProvider->fileName); return response()->download( @@ -82,24 +75,28 @@ class ExportController extends CommonController { ); } - Storage::delete($painFileWriteProvider->fileName); Storage::delete($csvFileWriteProvider->fileName); return response()->json([ - 'message' => 'Die Abrechnungen wurden exportiert.' . PHP_EOL .'Die Belege werden asynchron auf dem Webdav-Server hinterlegt.' . PHP_EOL . PHP_EOL . 'Sollten diese in 15 Minuten nicht vollständig sein, kontaktiere den Adminbistrator.' + 'message' => 'Die Abrechnungen wurden exportiert.' . PHP_EOL . 'Die SEPA-Überweisungsdatei kann über den Tab "Globale Aktionen" in der Kostenstellenübersicht erzeugt werden.' . PHP_EOL . PHP_EOL . 'Die Belege werden asynchron auf dem Webdav-Server hinterlegt.' . PHP_EOL . 'Sollten diese in 15 Minuten nicht vollständig sein, kontaktiere den Administrator.' ]); } - private function painData(array $invoices) : string { - $invoicesForPainFile = []; + private function createSepaPaymentElements(array $invoices, $costUnit): void + { foreach ($invoices as $invoice) { if ($invoice->contact_bank_owner !== null && $invoice->contact_bank_iban !== '' && !$invoice->donation) { - $invoicesForPainFile[] = $invoice; + SepaPaymentElement::create([ + 'tenant' => $this->tenant->slug, + 'invoice_id' => $invoice->id, + 'cost_unit_id' => $costUnit->id, + 'amount' => $invoice->amount, + 'recipient_name' => $invoice->contact_bank_owner, + 'recipient_iban' => $invoice->contact_bank_iban, + 'payment_purpose' => $invoice->payment_purpose ?? 'Auslagenerstattung Rechnungsnummer ' . $invoice->invoice_number, + ]); } } - - $painFileProvider = new PainFileProvider($this->tenant->account_iban, $this->tenant->account_name, $this->tenant->account_bic, $invoicesForPainFile); - return $painFileProvider->createPainFileContent(); } public function csvData(array $invoices) : string { @@ -107,4 +104,3 @@ class ExportController extends CommonController { return $csvDateProvider->createCsvFileContent(); } } - diff --git a/app/Domains/CostUnit/Controllers/GlobalSepaExportController.php b/app/Domains/CostUnit/Controllers/GlobalSepaExportController.php new file mode 100644 index 0000000..5fe3ff3 --- /dev/null +++ b/app/Domains/CostUnit/Controllers/GlobalSepaExportController.php @@ -0,0 +1,82 @@ +getUserRole(); + if (!in_array($role, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true)) { + abort(403); + } + } + + public function getGlobalActions() + { + $this->checkAuthorization(); + + $pendingElements = SepaPaymentElement::where('exported', false)->get(); + $pendingCount = $pendingElements->count(); + $pendingAmount = number_format($pendingElements->sum('amount'), 2, ',', '.'); + + return response()->json([ + 'pending_count' => $pendingCount, + 'pending_amount' => $pendingAmount, + ]); + } + + public function exportSepaFile() + { + $this->checkAuthorization(); + + return DB::transaction(function () { + $elements = SepaPaymentElement::where('exported', false)->lockForUpdate()->get(); + + if ($elements->isEmpty()) { + return response()->json([ + 'message' => 'Es gibt keine ausstehenden SEPA-Überweisungen.' + ], 404); + } + + $painFileProvider = new PainFileProvider( + $this->tenant->account_iban, + $this->tenant->account_name, + $this->tenant->account_bic, + $elements->all() + ); + + $painContent = $painFileProvider->createPainFileContent(); + + $filePrefix = Tenant::getTempDirectory(); + $fileName = $filePrefix . 'sepa-pain-' . date('Y-m-d_H-i') . '.xml'; + + $fileWriteProvider = new FileWriteProvider($fileName, $painContent); + $fileWriteProvider->writeToFile(); + + $elements->each(function (SepaPaymentElement $element) { + $element->update([ + 'exported' => true, + 'exported_at' => now(), + ]); + }); + + $filePath = storage_path('app/private/' . $fileName); + + return response()->download($filePath, basename($fileName), [ + 'Content-Type' => 'application/xml', + ])->deleteFileAfterSend(true); + }); + } +} diff --git a/app/Domains/CostUnit/Routes/api.php b/app/Domains/CostUnit/Routes/api.php index 76781e1..26bf25c 100644 --- a/app/Domains/CostUnit/Routes/api.php +++ b/app/Domains/CostUnit/Routes/api.php @@ -4,6 +4,7 @@ use App\Domains\CostUnit\Controllers\CreateController; use App\Domains\CostUnit\Controllers\DistanceAllowanceController; use App\Domains\CostUnit\Controllers\EditController; use App\Domains\CostUnit\Controllers\ExportController; +use App\Domains\CostUnit\Controllers\GlobalSepaExportController; use App\Domains\CostUnit\Controllers\ListController; use App\Domains\CostUnit\Controllers\OpenController; use App\Domains\CostUnit\Controllers\TreasurersEditController; @@ -43,6 +44,9 @@ Route::prefix('api/v1') + Route::get('/global-actions', [GlobalSepaExportController::class, 'getGlobalActions']); + Route::get('/export-sepa-file', [GlobalSepaExportController::class, 'exportSepaFile']); + Route::prefix('open')->group(function () { Route::get('/current-events', [ListController::class, 'listCurrentEvents']); Route::get('/current-running-jobs', [ListController::class, 'listCurrentRunningJobs']); diff --git a/app/Domains/CostUnit/Views/List.vue b/app/Domains/CostUnit/Views/List.vue index b842a97..b0b95d5 100644 --- a/app/Domains/CostUnit/Views/List.vue +++ b/app/Domains/CostUnit/Views/List.vue @@ -7,6 +7,7 @@ import TabbedPage from "../../../Views/Components/TabbedPage.vue"; import {toast} from "vue3-toastify"; import ListCostUnits from "./Partials/ListCostUnits.vue"; +import GlobalActions from "./Partials/GlobalActions.vue"; const props = defineProps({ message: String, @@ -63,6 +64,13 @@ const tabs = [ deep_jump_id: initialCostUnitId, deep_jump_id_sub: initialInvoiceId, }, + { + title: 'Globale Aktionen', + component: GlobalActions, + endpoint: "/api/v1/cost-unit/global-actions", + deep_jump_id: 0, + deep_jump_id_sub: 0, + }, ] onMounted(() => { diff --git a/app/Domains/CostUnit/Views/Partials/GlobalActions.vue b/app/Domains/CostUnit/Views/Partials/GlobalActions.vue new file mode 100644 index 0000000..89235a2 --- /dev/null +++ b/app/Domains/CostUnit/Views/Partials/GlobalActions.vue @@ -0,0 +1,112 @@ + + + + + Globale Aktionen + + + + Es gibt {{ props.data.pending_count }} ausstehende SEPA-Überweisungen + (Gesamtbetrag: {{ props.data.pending_amount }} Euro). + + + Keine ausstehenden SEPA-Überweisungen vorhanden. + + + + + Erzeuge SEPA-File + + + + + + + diff --git a/app/Models/SepaPaymentElement.php b/app/Models/SepaPaymentElement.php new file mode 100644 index 0000000..e8761fe --- /dev/null +++ b/app/Models/SepaPaymentElement.php @@ -0,0 +1,54 @@ + 'boolean', + 'exported_at' => 'datetime', + ]; + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function costUnit(): BelongsTo + { + return $this->belongsTo(CostUnit::class); + } +} diff --git a/app/Providers/PainFileProvider.php b/app/Providers/PainFileProvider.php index 567b978..32b51e2 100644 --- a/app/Providers/PainFileProvider.php +++ b/app/Providers/PainFileProvider.php @@ -2,25 +2,23 @@ namespace App\Providers; -use App\Models\Invoice; -use App\Resources\InvoiceResource; +use App\Models\SepaPaymentElement; use DOMDocument; use Exception; -use Illuminate\Http\Request; class PainFileProvider { public string $senderIban; public string $senderName; public string $senderBic; - /* @var Invoice[] */ - public array $invoices; + /** @var SepaPaymentElement[] */ + public array $elements; - public function __construct(string $senderIban, string $senderName, string $senderBic, array $invoices) { + public function __construct(string $senderIban, string $senderName, string $senderBic, array $elements) { $this->senderIban = $senderIban; $this->senderName = $senderName; $this->senderBic = $senderBic; - $this->invoices = $invoices; + $this->elements = $elements; } public function createPainFileContent() : string { @@ -46,9 +44,9 @@ class PainFileProvider { $grp_hdr->appendChild($doc->createElement('MsgId', uniqid('MSG'))); $grp_hdr->appendChild($doc->createElement('CreDtTm', date('c'))); - $grp_hdr->appendChild($doc->createElement('NbOfTxs', count($this->invoices))); + $grp_hdr->appendChild($doc->createElement('NbOfTxs', count($this->elements))); - $totalAmount = array_sum(array_column($this->invoices, 'amount')); + $totalAmount = array_sum(array_map(fn(SepaPaymentElement $e) => $e->amount, $this->elements)); $grp_hdr->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', ''))); $initg_pty = $doc->createElement('InitgPty'); @@ -62,7 +60,7 @@ class PainFileProvider { $pmt_inf->appendChild($doc->createElement('PmtInfId', uniqid('PMT'))); $pmt_inf->appendChild($doc->createElement('PmtMtd', 'TRF')); $pmt_inf->appendChild($doc->createElement('BtchBookg', 'false')); - $pmt_inf->appendChild($doc->createElement('NbOfTxs', count($this->invoices))); + $pmt_inf->appendChild($doc->createElement('NbOfTxs', count($this->elements))); $pmt_inf->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', ''))); $pmt_tp_inf = $doc->createElement('PmtTpInf'); @@ -90,9 +88,7 @@ class PainFileProvider { $dbtr_agt->appendChild($id); $pmt_inf->appendChild($dbtr_agt); - foreach ($this->invoices as $index => $invoice) { - $invoiceResource = new InvoiceResource($invoice)->toArray(new Request()); - + foreach ($this->elements as $index => $element) { $cdt_trf_tx_inf = $doc->createElement('CdtTrfTxInf'); $pmt_id = $doc->createElement('PmtId'); @@ -100,23 +96,23 @@ class PainFileProvider { $cdt_trf_tx_inf->appendChild($pmt_id); $amt = $doc->createElement('Amt'); - $instd_amt = $doc->createElement('InstdAmt', number_format($invoice['amount'], 2, '.', '')); + $instd_amt = $doc->createElement('InstdAmt', number_format($element->amount, 2, '.', '')); $instd_amt->setAttribute('Ccy', 'EUR'); $amt->appendChild($instd_amt); $cdt_trf_tx_inf->appendChild($amt); $cdtr = $doc->createElement('Cdtr'); - $cdtr->appendChild($doc->createElement('Nm', $invoice['contact_bank_owner'])); + $cdtr->appendChild($doc->createElement('Nm', $element->recipient_name)); $cdt_trf_tx_inf->appendChild($cdtr); $cdtr_acct = $doc->createElement('CdtrAcct'); $cdtr_id = $doc->createElement('Id'); - $cdtr_id->appendChild($doc->createElement('IBAN', str_replace(' ', '', $invoice['contact_bank_iban']))); + $cdtr_id->appendChild($doc->createElement('IBAN', str_replace(' ', '', $element->recipient_iban))); $cdtr_acct->appendChild($cdtr_id); $cdt_trf_tx_inf->appendChild($cdtr_acct); $rmt_inf = $doc->createElement('RmtInf'); - $rmt_inf->appendChild($doc->createElement('Ustrd', $invoiceResource['paymentPurpose'])); + $rmt_inf->appendChild($doc->createElement('Ustrd', $element->payment_purpose)); $cdt_trf_tx_inf->appendChild($rmt_inf); $pmt_inf->appendChild($cdt_trf_tx_inf); diff --git a/database/migrations/2026_06_21_140010_create_sepa_payment_elements.php b/database/migrations/2026_06_21_140010_create_sepa_payment_elements.php new file mode 100644 index 0000000..4bb8f52 --- /dev/null +++ b/database/migrations/2026_06_21_140010_create_sepa_payment_elements.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('tenant'); + $table->foreignId('invoice_id')->constrained('invoices', 'id')->restrictOnDelete()->cascadeOnUpdate(); + $table->foreignId('cost_unit_id')->constrained('cost_units', 'id')->restrictOnDelete()->cascadeOnUpdate(); + $table->float('amount', 2); + $table->string('recipient_name'); + $table->string('recipient_iban'); + $table->string('payment_purpose'); + $table->boolean('exported')->default(false); + $table->dateTime('exported_at')->nullable(); + + $table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate(); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sepa_payment_elements'); + } +};
+ Es gibt {{ props.data.pending_count }} ausstehende SEPA-Überweisungen + (Gesamtbetrag: {{ props.data.pending_amount }} Euro). +
+ Keine ausstehenden SEPA-Überweisungen vorhanden. +