Invoice PAIN & CSV can be uploaded

This commit is contained in:
2026-02-13 22:37:27 +01:00
parent cd526231ed
commit 4f4dff2edd
29 changed files with 1635 additions and 193 deletions

View File

@@ -0,0 +1,110 @@
<?php
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\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;
use Illuminate\Support\Facades\Storage;
class ExportController extends CommonController {
public function __invoke(int $costUnitId) {
$costUnit = $this->costUnits->getById($costUnitId);
$invoicesForExport = $this->invoices->getByStatus($costUnit, InvoiceStatus::INVOICE_STATUS_APPROVED, false);
$webdavProvider = new WebDavProvider(WebDavProvider::INVOICE_PREFIX . $this->tenant->url . '/' . $costUnit->name);
$painFileData = $this->painData($invoicesForExport);
$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
];
foreach ($invoicesForExport as $invoice) {
$changeStatusRequest = new ChangeStatusRequest($invoice, InvoiceStatus::INVOICE_STATUS_EXPORTED);
$changeStatusCommand = new ChangeStatusCommand($changeStatusRequest);
$changeStatusCommand->execute();
if ($this->tenant->download_exports) {
$createInvoiceReceiptRequest = new CreateInvoiceReceiptRequest($invoice);
$createInvoiceReceiptCommand = new CreateInvoiceReceiptCommand($createInvoiceReceiptRequest);
$response = $createInvoiceReceiptCommand->execute();
$downloadZipArchiveFiles[] = $response->fileName;
}
}
if ($this->tenant->download_exports) {
$zipFile = Tenant::getTempDirectory() . 'Abrechnungen-' . $costUnit->name . '.zip';
$zipFileProvider = new ZipArchiveFileProvider($zipFile);
foreach ($downloadZipArchiveFiles as $file) {
$zipFileProvider->addFile($file);
}
$zipFileProvider->create();
foreach ($downloadZipArchiveFiles as $file) {
Storage::delete($file);
}
Storage::delete($painFileWriteProvider->fileName);
Storage::delete($csvFileWriteProvider->fileName);
return response()->download(
storage_path('app/private/' . $zipFile),
basename($zipFile),
['Content-Type' => 'application/zip']
);
}
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.'
]);
}
private function painData(array $invoices) : string {
$invoicesForPainFile = [];
foreach ($invoices as $invoice) {
if ($invoice->contact_bank_owner !== null && $invoice->contact_bank_iban !== '' && !$invoice->donation) {
$invoicesForPainFile[] = $invoice;
}
}
$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 {
$csvDateProvider = new InvoiceCsvFileProvider($invoices);
return $csvDateProvider->createCsvFileContent();
}
}

View File

@@ -3,6 +3,7 @@ use App\Domains\CostUnit\Controllers\ChangeStateController;
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\ListController;
use App\Domains\CostUnit\Controllers\OpenController;
use App\Domains\CostUnit\Controllers\TreasurersEditController;
@@ -37,6 +38,7 @@ Route::prefix('api/v1')
Route::get('/treasurers', TreasurersEditController::class);
Route::post('/treasurers', [TreasurersEditController::class, 'update']);
Route::get('/export-payouts', ExportController::class);
});

View File

@@ -1,19 +1,11 @@
<script setup>
import {createApp, ref} from 'vue'
/*import {
_mareike_download_as_zip,
_mareike_use_webdav
} from "../../../assets/javascripts/library";*/
//import LoadingModal from "../../../assets/components/LoadingModal.vue";
//import Invoices from '../invoices/index.vue'
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import CostUnitDetails from "./CostUnitDetails.vue";
import {toast} from "vue3-toastify";
import Treasurers from "./Treasurers.vue";
//import CostUnitDetails from "./CostUnitDetails.vue";
const props = defineProps({
data: {
type: [Array, Object],
@@ -42,13 +34,8 @@ const show_cost_unit = ref(false)
const showTreasurers = ref(false)
const costUnit = ref(null)
const { data, loading, error, request, download } = useAjax()
if (props.deep_jump_id > 0) {
// open_invoice_list(props.deep_jump_id, 'new', props.deep_jump_id_sub)
}
async function costUnitDetails(costUnitId) {
const data = await request('/api/v1/cost-unit/' + costUnitId + '/details', {
method: "GET",
@@ -114,45 +101,50 @@ async function changeCostUnitState(costUnitId, endPoint) {
}
async function export_payouts(cost_unit_id) {
showLoading.value = true;
async function exportPayouts(costUnitId) {
showLoading.value = true;
try {
if (_mareike_download_as_zip()) {
const response = await fetch("/wp-json/mareike/costunits/export-payouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mareike_nonce: _mareike_nonce(),
costunit: cost_unit_id,
}),
});
if (!response.ok) throw new Error('Fehler beim Export (ZIP)');
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
a.download = `payouts-${cost_unit_id}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(downloadUrl);
} else {
await request("/wp-json/mareike/costunits/export-payouts", {
method: "POST",
body: {
mareike_nonce: _mareike_nonce(),
costunit: cost_unit_id,
const response = await fetch('/api/v1/core/retrieve-global-data');
const data = await response.json();
const exportUrl = '/api/v1/cost-unit/' + costUnitId + '/export-payouts';
try {
if (data.tenant.download_exports) {
const response = await fetch(exportUrl, {
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error('Fehler beim Export (ZIP)');
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
console.log(response.headers.get("content-type"));
const a = document.createElement("a");
a.style.display = "none";
a.href = downloadUrl;
a.download = "Abrechnungen-Sippenstunden.zip";
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a);
}, 100);
} else {
const response = await request(exportUrl, {
method: "GET",
});
toast.success(response.message);
}
});
}
showLoading.value = false;
toast.success('Die Abrechnungen wurden exportiert.');
} catch (err) {
showLoading.value = false;
toast.error('Beim Export der Abrechnungen ist ein Fehler aufgetreten.');
}
showLoading.value = false;
} catch (err) {
showLoading.value = false;
toast.error('Beim Export der Abrechnungen ist ein Fehler aufgetreten.');
}
}
</script>
@@ -176,7 +168,7 @@ async function export_payouts(cost_unit_id) {
<input v-if="!costUnit.archived" type="button" value="Abrechnungen bearbeiten" @click="loadInvoices(costUnit.id)" />
<input v-else type="button" value="Abrechnungen einsehen" />
<br />
<input v-if="!costUnit.archived" type="button" value="Genehmigte Abrechnungen exportieren" style="margin-top: 10px;" />
<input v-if="!costUnit.archived" type="button" @click="exportPayouts(costUnit.id)" value="Genehmigte Abrechnungen exportieren" style="margin-top: 10px;" />
</td>
</tr>
@@ -217,17 +209,10 @@ async function export_payouts(cost_unit_id) {
<Treasurers :data="costUnit" :showTreasurers="showTreasurers" v-if="showTreasurers" @closeTreasurers="showTreasurers = false" />
</div>
<div v-else-if="showInvoiceList">
<div v-else-if="showInvoiceList">
<invoices :data="invoices" :load_invoice_id="props.deep_jump_id_sub" :cost_unit_id="current_cost_unit" />
<LoadingModal :show="showLoading" v-if="_mareike_use_webdav()" message="Die PDF-Dateien werden asynchron erzeugt, diese sollten in 10 Minuten auf dem Webdav-Server liegen', 'mareike')" />
<LoadingModal :show="showLoading" v-else message='Die Abrechnungen werden exportiert, bitte warten.' />
</div>
<div v-else>
@@ -236,7 +221,7 @@ async function export_payouts(cost_unit_id) {
</strong>
</div>
<LoadingModal :show="showLoading" />
</template>
<style scoped>

View File

@@ -39,6 +39,10 @@ class ChangeStatusCommand {
case InvoiceStatus::INVOICE_STATUS_DELETED:
$this->request->invoice->status = InvoiceStatus::INVOICE_STATUS_DELETED;
break;
case InvoiceStatus::INVOICE_STATUS_EXPORTED:
//$this->request->invoice->status = InvoiceStatus::INVOICE_STATUS_EXPORTED;
$this->request->invoice->upload_required = true;
break;
}
if ($this->request->invoice->save()) {

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Invoice\Actions\ChangeStatus;
class ChangeStatusResponse {
public bool $success;
public string $invoiceReceipt;
function __construct() {
$this->success = false;

View File

@@ -0,0 +1,204 @@
<?php
namespace App\Domains\Invoice\Actions\CreateInvoiceReceipt;
use App\Enumerations\InvoiceType;
use App\Models\PageText;
use App\Models\Tenant;
use App\Providers\PdfMergeProvider;
use App\Resources\InvoiceResource;
use Dompdf\Dompdf;
use Illuminate\Support\Facades\Storage;
use ZipArchive;
class CreateInvoiceReceiptCommand {
private CreateInvoiceReceiptRequest $request;
private string $tempDirectory;
public function __construct(CreateInvoiceReceiptRequest $request) {
$this->request = $request;
$this->tempDirectory = Tenant::getTempDirectory();
}
public function execute() : CreateInvoiceReceiptResponse {
$response = new CreateInvoiceReceiptResponse();
$filename = $this->tempDirectory . $this->request->invoice->invoice_number . '.pdf';
if (!Storage::exists($this->tempDirectory)) {
Storage::makeDirectory(Tenant::getTempDirectory() . '/' . $this->tempDirectory);
}
$receipt = $this->request->invoice->document_filename;
if ($receipt === null) {
Storage::put(
$filename,
$this->createPdf( $this->createHtml(), 'portrait', $filename, false )
);
$response->fileName = $filename;
return $response;
}
$token = (string)rand(1000000000, 9999999999);
$pdf_data = $this->createPdf( $this->createHtml(), 'portrait', $filename, false );
$tmpFileName = $this->tempDirectory . 'tmp-' . $token . '.pdf';
Storage::put($tmpFileName, $pdf_data);
try {
$merger = new PdfMergeProvider();
$merger
->add( storage_path('app/private/' . $tmpFileName ))
->add(storage_path('app/private/' . $receipt))
->merge(storage_path('app/private/' .$filename) );
$response->fileName = $filename;
} catch ( \Exception $e ) {
$zip = new ZipArchive();
$zip->open(
storage_path('app/private/' .$filename) . '.zip',
ZipArchive::CREATE | ZipArchive::OVERWRITE
);
$zip->addFile(storage_path('app/private/' . $tmpFileName ), 'antrag.pdf');
$zip->addFile(storage_path('app/private/' . $receipt), 'beleg.pdf');
$zip->close();
$response->fileName = $filename . '.zip';
} finally {
Storage::delete($tmpFileName);
}
return $response;
}
private function createHtml() : string {
$invoiceReadable = new InvoiceResource($this->request->invoice)->toArray();
$travelPartTemplate = <<<HTML
<tr><td>Reiseweg:</td><td>%1\$s</td></tr>
<tr><td>Gesamtlänge der Strecke:</td><td>%2\$s km x %3\$s / km</td></tr>
<tr><td>Materialtransport:</td><td>%4\$s</td></tr>
<tr><td>Mitfahrende im PKW:</td><td>%5\$s</td></tr>
HTML;
$flatTravelPart = sprintf(
$travelPartTemplate,
$invoiceReadable['travelDirection'] ,
$invoiceReadable['distance'],
$invoiceReadable['distanceAllowance'],
$invoiceReadable['transportation'],
$invoiceReadable['passengers']
);
$invoiceTravelPart = '<tr><td>Kosten für ÖPNV:</td><td>' . $invoiceReadable['amount'] . '</td></tr>';;
$expensePart = '<tr><td>Auslagenerstattung:</td><td>' . $invoiceReadable['amount'] . '</td></tr>';
$content = <<<HTML
<html>
<body style="margin-left: 20mm; margin-top: 17mm">
<h3>Abrechnungstyp %1\$s</h3><br /><br />
<table style="width: 100%%;">
<tr><td>Abrechnungsnummer:</td><td>%2\$s</td></tr>
<tr><td>Name:</td><td>%3\$s</td></tr>
<tr><td>E-Mail:</td><td>%4\$s</td></tr>
<tr><td>Telefon:</td><td>%5\$s</td></tr>
<tr><td>Name der Kostenstelle:</td><td>%6\$s</td></tr>
<tr><td>Zahlungsgrund:</td><td>%7\$s</td></tr>
<tr><td>Wird der Betrag gespendet:</td><td>%8\$s</td></tr>
%9\$s
<tr style="font-weight: bold;">
<td style="border-bottom-width: 1px; border-bottom-style: double;">
Gesamtbetrag:
</td>
<td style="border-bottom-width: 1px; border-bottom-style: double;">
%10\$s
</td>
</tr>
<tr><td colspan="2"><br /><br /></td></tr>
%11\$s
<tr><td>Beleg digital eingereicht am:</td><td>%12\$s</td></tr>
<tr><td>Beleg akzeptiert am:</td><td>%13\$s</td></tr>
<tr><td>Beleg akzeptiert von:</td><td>%14\$s</td></tr>
</table>
%15\$s
</body>
</html>
HTML;
switch ($this->request->invoice->type) {
case InvoiceType::INVOICE_TYPE_TRAVELLING:
$paymentType = $this->request->invoice->distance !== null ? $flatTravelPart : $invoiceTravelPart;
break;
default:
$paymentType = $expensePart;
}
if ($this->request->invoice->donation) {
$paymentInformation = '<tr><td colspan="2">' . PageText::where('name', 'CONFIRMATION_DONATE')->first()->content . '</td></tr>';
} else {
if ($this->request->invoice->contact_bank_iban === null) {
$paymentInformation = '';
} else {
$paymentInformationTemplate = <<<HTML
<tr><td colspan="2">%1\$s</td></tr>
<tr><td>Kontoinhaber*in:</td><td>%2\$s</td></tr>
<tr><td>INAN:</td><td>%3\$s</td></tr>
<tr><td colspan="2"><br /><br /></td></tr>
HTML;
$paymentInformation = sprintf(
$paymentInformationTemplate,
PageText::where('name', 'CONFIRMATION_PAYMENT')->first()->content,
$invoiceReadable['accountOwner'],
$invoiceReadable['accountIban']
);
}
}
$changes = $this->request->invoice->changes !== null ? '<p>' . $this->request->invoice->changes . '</p>' : '';
return sprintf(
$content,
$invoiceReadable['invoiceTypeShort'],
$invoiceReadable['invoiceNumber'],
$invoiceReadable['contactName'],
$invoiceReadable['contactEmail'],
$invoiceReadable['contactPhone'],
$invoiceReadable['costUnitName'],
$invoiceReadable['invoiceType'],
$invoiceReadable['donationText'],
$paymentType,
$invoiceReadable['amount'],
$paymentInformation,
$invoiceReadable['createdAt'],
$invoiceReadable['approvedAt'],
$invoiceReadable['approvedBy'],
$changes
);
}
private function createPdf( string $htmlfile, string $orientation, string $filename, bool $download = true ) {
$dompdf = new Dompdf();
$dompdf->loadHtml( $htmlfile, 'UTF-8' );
$dompdf->setPaper( 'A4', $orientation );
$dompdf->render();
if ( ! $download ) {
return $dompdf->output();
}
$dompdf->stream( $filename );
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Invoice\Actions\CreateInvoiceReceipt;
use App\Models\Invoice;
class CreateInvoiceReceiptRequest {
public Invoice $invoice;
public function __construct(Invoice $invoice) {
$this->invoice = $invoice;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Invoice\Actions\CreateInvoiceReceipt;
class CreateInvoiceReceiptResponse {
public string $fileName;
public function __construct() {
$this->fileName = '';
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Invoice\Actions\UpdateInvoice;
use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusCommand;
use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusRequest;
use App\Enumerations\InvoiceStatus;
use App\ValueObjects\Amount;
class UpdateInvoiceCommand {
private UpdateInvoiceRequest $request;
@@ -15,11 +16,26 @@ class UpdateInvoiceCommand {
public function execute() : UpdateInvoiceResponse {
$response = new UpdateInvoiceResponse();
$changes = $this->request->invoice->changes ?? '';
if ($this->request->invoice->amount !== $this->request->amount->getAmount()) {
$changes .= 'Betrag geändert von ' . Amount::fromString($this->request->invoice->amount)->toString() . ' auf ' . Amount::fromString($this->request->amount->getAmount())->toString() . '.<br />';
$this->request->invoice->amount = $this->request->amount->getAmount();
}
if ($this->request->invoice->invoiceType()->slug !== $this->request->invoiceType->slug) {
$changes .= 'Abrechnungstyp geändert von ' . $this->request->invoice->invoiceType()->name . ' auf ' . $this->request->invoiceType->name . '.<br />';
$this->request->invoice->type = $this->request->invoiceType->slug;
}
if ($this->request->invoice->costUnit()->first()->id !== $this->request->costUnit->id) {
$changes .= 'Kostenstelle geändert von ' . $this->request->invoice->costUnit()->first()->name . ' auf ' . $this->request->costUnit->name . '.<br />';
$this->request->invoice->cost_unit_id = $this->request->costUnit->id;
}
$this->request->invoice->amount = $this->request->amount->getAmount();
$this->request->invoice->cost_unit_id = $this->request->costUnit->id;
$this->request->invoice->type = $this->request->invoiceType;
$this->request->invoice->comment = $this->request->comment;
$this->request->invoice->changes = $changes;
$this->request->invoice->save();
@@ -29,5 +45,4 @@ class UpdateInvoiceCommand {
return $response;
}
}

View File

@@ -9,12 +9,12 @@ use App\ValueObjects\Amount;
class UpdateInvoiceRequest {
public string $comment;
public string $invoiceType;
public InvoiceType $invoiceType;
public CostUnit $costUnit;
public Invoice $invoice;
public Amount $amount;
public function __construct(Invoice $invoice, string $comment, string $invoiceType, CostUnit $costUnit, Amount $amount) {
public function __construct(Invoice $invoice, string $comment, InvoiceType $invoiceType, CostUnit $costUnit, Amount $amount) {
$this->comment = $comment;
$this->invoiceType = $invoiceType;
$this->costUnit = $costUnit;

View File

@@ -10,6 +10,7 @@ use App\Domains\Invoice\Actions\UpdateInvoice\UpdateInvoiceCommand;
use App\Domains\Invoice\Actions\UpdateInvoice\UpdateInvoiceRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Resources\InvoiceResource;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
@@ -80,10 +81,13 @@ class EditController extends CommonController{
$amountLeft->subtractAmount($newAmount);
$newCostUnit = $this->costUnits->getById($modifyData['cost_unit'],true);
$invoiceType = InvoiceType::where('slug', $modifyData['type_internal'])->first();
$updateInvoiceRequest = new UpdateInvoiceRequest(
$invoice,
$modifyData['reason_of_correction'] ?? 'Abrechnungskorrektur',
$modifyData['type_internal'],
$invoiceType,
$newCostUnit,
$newAmount
);