mivita/app/Repositories/InvoiceRepository.php
Kevin 1e7ae92d0c Stornorechnung: Fix überdeckte Artikelzeile + Regenerate-Command
Der ACHTUNG-Hinweiskasten hat wegen fixer 15mm-Höhe des Titel-Blocks
den Tabellenkopf und die erste Artikelzeile überdeckt (z.B. Artikel
5010400 auf den Stornos zu Bestellung 56965/56966). Titel-Block wächst
jetzt mit dem Inhalt.

Neuer Command sales:regenerate-cancellation-pdf erzeugt die PDFs
bestehender Stornorechnungen neu (gleiche Nummer/Pfade, keine
Punktebuchung, keine Mails) – per Rechnungs-ID oder mit --order per
Bestell-ID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:19:28 +00:00

513 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Repositories;
use App\Libraries\InvoicePDF;
use App\Libraries\MyPDFMerger;
use App\Models\ShoppingOrder;
use App\Models\UserInvoice;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\Incentive\IncentiveTracker;
use App\Services\Invoice;
use App\Services\UserService;
use App\Services\Util;
use Storage;
class InvoiceRepository extends BaseRepository
{
/** @var ShoppingOrder */
protected $model;
private $invoice_date;
private $invoice_number;
private $filename;
private $dir;
private $user_sales_volume;
private $delivery_dir;
private $delivery_filename;
public function __construct(ShoppingOrder $model)
{
$this->model = $model;
}
public function create($request = [])
{
// Wrap entire invoice creation in transaction to ensure atomicity
return \DB::transaction(function () use ($request) {
// Get and increment invoice number atomically (includes its own lock)
$number = Invoice::makeNextInvoiceNumber();
if ($payt = $this->model->getLastShoppingPaymentTransaction()) {
$invoice_date = $payt->created_at->format('d.m.Y');
}
$this->invoice_date = isset($request['invoice_date']) ? $request['invoice_date'] : $invoice_date;
$invoice_send_mail = isset($request['invoice_send_mail']) && $request['invoice_send_mail'] ? true : false;
$this->invoice_number = Invoice::createInvoiceNumber($number, $this->invoice_date);
$this->dir = Invoice::getInvoiceStorageDir($this->invoice_date);
$this->filename = Invoice::makeInvoiceFilename($this->invoice_number);
$this->delivery_dir = Invoice::getDeliveryStorageDir($this->invoice_date);
$this->delivery_filename = Invoice::makeDeliveryFilename($this->invoice_number);
$this->makePDF();
$user_invoice = UserInvoice::create([
'shopping_order_id' => $this->model->id,
'year' => \Carbon::parse($this->invoice_date)->format('Y'),
'month' => \Carbon::parse($this->invoice_date)->format('m'),
'date' => $this->invoice_date,
'full_number' => $this->invoice_number,
'number' => $number,
'filename' => $this->filename,
'dir' => $this->dir,
'delivery_filename' => $this->delivery_filename,
'delivery_dir' => $this->delivery_dir,
'disk' => 'public',
'status' => $this->model->getStatusByOrder(),
]);
if ($invoice_send_mail) {
Invoice::sendInvoiceMail($this->model, $user_invoice);
}
return $user_invoice;
});
}
public function update($request = [])
{
if ($user_invoice = $this->model->user_invoice) {
$number = $user_invoice->number;
$this->invoice_date = isset($request['invoice_date']) ? $request['invoice_date'] : $user_invoice->date;
$invoice_send_mail = isset($request['invoice_send_mail']) ? false : true;
$this->invoice_number = Invoice::createInvoiceNumber($number, $this->invoice_date);
$this->dir = Invoice::getInvoiceStorageDir($this->invoice_date);
$this->filename = Invoice::makeInvoiceFilename($this->invoice_number);
$this->delivery_dir = Invoice::getDeliveryStorageDir($this->invoice_date);
$this->delivery_filename = Invoice::makeDeliveryFilename($this->invoice_number);
$this->user_sales_volume = UserSalesVolume::where('user_invoice_id', $this->model->user_invoice->id)->first();
$this->makePDF();
$user_invoice->fill([
'shopping_order_id' => $this->model->id,
'year' => \Carbon::parse($this->invoice_date)->format('Y'),
'month' => \Carbon::parse($this->invoice_date)->format('m'),
'date' => $this->invoice_date,
'full_number' => $this->invoice_number,
'number' => $number,
'filename' => $this->filename,
'dir' => $this->dir,
'delivery_filename' => $this->delivery_filename,
'delivery_dir' => $this->delivery_dir,
'disk' => 'public',
])->save();
if ($invoice_send_mail) {
Invoice::sendInvoiceMail($this->model, $user_invoice);
}
return $user_invoice;
}
return null;
}
/**
* Erzeugt eine reine VORSCHAU der Rechnungs- und Lieferschein-PDFs – ohne
* `UserInvoice` anzulegen, ohne eine Rechnungsnummer zu verbrauchen und ohne
* Umsatz-/Incentive-Tracking. Dient der visuellen Prüfung des Layouts (z. B. auf
* Testsystemen, wo die reguläre Erzeugung in {@see createAndSalesVolume()}
* bewusst deaktiviert ist).
*
* @return array<string, string> Storage-Pfade (disk "public") der erzeugten PDFs
*/
public function makePreview(): array
{
$invoiceDate = now()->format('d.m.Y');
if ($payt = $this->model->getLastShoppingPaymentTransaction()) {
$invoiceDate = $payt->created_at->format('d.m.Y');
}
$this->invoice_date = $invoiceDate;
$this->invoice_number = 'VORSCHAU-'.$this->model->id;
$this->dir = 'invoice/preview/';
$this->delivery_dir = 'delivery/preview/';
$this->filename = Invoice::makeInvoiceFilename($this->invoice_number);
$this->delivery_filename = Invoice::makeDeliveryFilename($this->invoice_number);
$this->user_sales_volume = null; // Vorschau: kein Umsatz-Tracking, Punkte-Block entfällt
$this->makePDF();
$paths = ['invoice' => $this->dir.Invoice::makeInvoiceFilenameLocale($this->invoice_number, 'de')];
if (! $this->model->shopping_collect_order) {
$paths['delivery'] = $this->delivery_dir.Invoice::makeDeliveryFilenameLocale($this->invoice_number, 'de');
}
return $paths;
}
/**
* Erstellt die PDFs für Rechnung und Lieferschein.
* Das deutsche Original wird immer erstellt (Finanzamt-Anforderung).
* Bei anderer Kundensprache wird zusätzlich eine Kopie in der Kundensprache erstellt.
*/
private function makePDF()
{
$data = [
'shopping_order' => $this->model,
'invoice_date' => $this->invoice_date,
'invoice_number' => $this->invoice_number,
'user_sales_volume' => $this->user_sales_volume,
];
if ($this->model->auth_user_id) {
UserService::checkUserTaxShippingCountry($this->model->auth_user, $this->model->country_id);
$data = array_merge($data, UserService::getYardInfo());
}
if (! Storage::disk('public')->exists($this->dir)) {
Storage::disk('public')->makeDirectory($this->dir);
}
if (! Storage::disk('public')->exists($this->delivery_dir)) {
Storage::disk('public')->makeDirectory($this->delivery_dir);
}
// Kundensprache ermitteln
$customerLocale = $this->model->shopping_user ? $this->model->shopping_user->getLocale() : 'de';
$originalLocale = \App::getLocale();
// 1. IMMER deutsches Original erstellen (Finanzamt-Anforderung)
\App::setLocale('de');
$this->createPDFFiles($data, 'de');
// 2. Wenn Kundensprache != DE, Kopie in Kundensprache erstellen
if ($customerLocale && $customerLocale !== 'de') {
\App::setLocale($customerLocale);
$this->createPDFFiles($data, $customerLocale);
}
// Locale zurücksetzen
\App::setLocale($originalLocale);
}
/**
* Erstellt die PDF-Dateien für eine bestimmte Sprache.
*/
private function createPDFFiles(array $data, string $locale)
{
$path = Storage::disk('public')->path('');
// Dateinamen für diese Sprache
$invoiceFilename = Invoice::makeInvoiceFilenameLocale($this->invoice_number, $locale);
$deliveryFilename = Invoice::makeDeliveryFilenameLocale($this->invoice_number, $locale);
// Kopie-Flag: true wenn nicht Deutsch (das Original)
$data['is_copy'] = ($locale !== 'de');
// Template basierend auf Locale
$template = $this->getTemplateForLocale($locale);
// Rechnung erstellen
$pdf_file = new InvoicePDF('pdf.invoice');
$pdf_file->create($data, $invoiceFilename, 'save', $path.$this->dir);
$pdfMerger = new MyPDFMerger;
$pdfMerger->addPDF($path.$this->dir.$invoiceFilename);
$file = $pdfMerger->myMerge('string', $invoiceFilename, $template);
Storage::disk('public')->put($this->dir.$invoiceFilename, $file);
// Lieferschein erstellen (außer bei Sammelbestellung)
if (! $this->model->shopping_collect_order) {
$pdf_file = new InvoicePDF('pdf.delivery');
$pdf_file->create($data, $deliveryFilename, 'save', $path.$this->delivery_dir);
$pdfMerger = new MyPDFMerger;
$pdfMerger->addPDF($path.$this->delivery_dir.$deliveryFilename);
$file = $pdfMerger->myMerge('string', $deliveryFilename, $template);
Storage::disk('public')->put($this->delivery_dir.$deliveryFilename, $file);
}
}
/**
* Gibt das PDF-Template für die angegebene Locale zurück.
* Verfügbare Templates werden aus config/localization.php geladen.
*/
private function getTemplateForLocale(string $locale): string
{
$availableTemplates = config('localization.availableTemplates', ['de']);
if (in_array($locale, $availableTemplates)) {
return 'template_invoice_'.$locale;
}
return 'template_invoice_de';
}
public function userSalesVolume() {}
public function createAndSalesVolume($request = [])
{
$this->user_sales_volume = SalesPointsVolume::User($this->model);
if (! Util::isTestSystem(true)) { // rechnung erstellen nur in production
$user_invoice = $this->create($request);
$this->user_sales_volume->user_invoice_id = $user_invoice->id;
$this->user_sales_volume->save();
}
// Incentive: Track sales volume points
IncentiveTracker::trackSalesVolume($this->user_sales_volume);
}
/**
* Erstellt eine Stornorechnung mit Punktekorrektur
*
* @param array $request
* @return UserInvoice
*/
public function createCancellation($request = [])
{
return \DB::transaction(function () use ($request) {
$original_invoice = $this->model->user_invoice;
if (! $original_invoice) {
throw new \Exception('Keine Originalrechnung gefunden.');
}
// Nächste Rechnungsnummer für Storno holen
$number = Invoice::makeNextInvoiceNumber();
// Stornodatum
$cancellation_date = isset($request['cancellation_date'])
? $request['cancellation_date']
: now()->format('d.m.Y');
$cancellation_send_mail = isset($request['cancellation_send_mail']) && $request['cancellation_send_mail'] ? true : false;
// Rechnungsnummer erstellen
$cancellation_number = Invoice::createInvoiceNumber($number, $cancellation_date);
$cancellation_dir = Invoice::getInvoiceStorageDir($cancellation_date);
$cancellation_filename = Invoice::makeCancellationFilename($cancellation_number);
$cancellation_delivery_dir = Invoice::getDeliveryStorageDir($cancellation_date);
$cancellation_delivery_filename = Invoice::makeCancellationDeliveryFilename($cancellation_number);
// Stornorechnung PDF erstellen
$this->makeCancellationPDF(
$cancellation_date,
$cancellation_number,
$cancellation_dir,
$cancellation_filename,
$cancellation_delivery_dir,
$cancellation_delivery_filename,
$original_invoice
);
// Stornorechnung in DB speichern
$cancellation_invoice = UserInvoice::create([
'shopping_order_id' => $this->model->id,
'year' => \Carbon::parse($cancellation_date)->format('Y'),
'month' => \Carbon::parse($cancellation_date)->format('m'),
'date' => $cancellation_date,
'full_number' => $cancellation_number,
'number' => $number,
'filename' => $cancellation_filename,
'dir' => $cancellation_dir,
'delivery_filename' => $cancellation_delivery_filename,
'delivery_dir' => $cancellation_delivery_dir,
'disk' => 'public',
'cancellation' => true,
'status' => $original_invoice->status === 1 ? 11 : 12, // 11 = storniert B., 12 = storniert Shop
]);
// Original-Rechnung als storniert markieren
$original_invoice->cancellation = true;
$original_invoice->cancellation_id = $cancellation_invoice->id;
$original_invoice->cancellation_date = $cancellation_date;
$original_invoice->save();
// Bestellstatus auf "storniert" setzen
$this->model->txaction = 'cancelled';
// Versandstatus auf "storniert" (10) setzen, wenn noch nicht versendet
if (in_array($this->model->shipped, [0, 1])) {
$this->model->shipped = 10;
}
$this->model->save();
\Log::info('Bestellstatus aktualisiert nach Storno', [
'order_id' => $this->model->id,
'txaction' => $this->model->txaction,
'shipped' => $this->model->shipped,
]);
// Punktekorrektur durchführen (nach Erstellung der Stornorechnung)
$this->correctPointsForCancellation($original_invoice, $cancellation_invoice);
// Optional: E-Mail versenden
if ($cancellation_send_mail) {
Invoice::sendInvoiceMail($this->model, $cancellation_invoice);
}
return $cancellation_invoice;
});
}
/**
* Erzeugt die PDF-Dateien einer bestehenden Stornorechnung neu (z. B. nach Template-Fixes).
*
* Verwendet Nummer, Datum und Dateipfade der bestehenden Stornorechnung und überschreibt
* die vorhandenen Dateien – es wird keine neue Rechnungsnummer vergeben und es werden
* keine Punkte gebucht.
*/
public function regenerateCancellationPDF(UserInvoice $cancellation_invoice): void
{
$original_invoice = UserInvoice::where('cancellation_id', $cancellation_invoice->id)->first();
if (! $original_invoice) {
throw new \Exception('Keine Originalrechnung zur Stornorechnung #'.$cancellation_invoice->id.' gefunden.');
}
$this->makeCancellationPDF(
$cancellation_invoice->date,
$cancellation_invoice->full_number,
$cancellation_invoice->dir,
$cancellation_invoice->filename,
$cancellation_invoice->delivery_dir,
$cancellation_invoice->delivery_filename,
$original_invoice
);
}
/**
* Erstellt die Storno-PDFs (Rechnung und Lieferschein)
*/
private function makeCancellationPDF(
$cancellation_date,
$cancellation_number,
$cancellation_dir,
$cancellation_filename,
$cancellation_delivery_dir,
$cancellation_delivery_filename,
$original_invoice
) {
$data = [
'shopping_order' => $this->model,
'invoice_date' => $cancellation_date,
'invoice_number' => $cancellation_number,
'original_invoice' => $original_invoice,
'is_cancellation' => true,
];
if ($this->model->auth_user_id) {
UserService::checkUserTaxShippingCountry($this->model->auth_user, $this->model->country_id);
$data = array_merge($data, UserService::getYardInfo());
}
// Verzeichnisse erstellen
if (! Storage::disk('public')->exists($cancellation_dir)) {
Storage::disk('public')->makeDirectory($cancellation_dir);
}
if (! Storage::disk('public')->exists($cancellation_delivery_dir)) {
Storage::disk('public')->makeDirectory($cancellation_delivery_dir);
}
// Kundensprache ermitteln
$customerLocale = $this->model->shopping_user ? $this->model->shopping_user->getLocale() : 'de';
$originalLocale = \App::getLocale();
// Deutsches Original (Finanzamt-Anforderung)
\App::setLocale('de');
$this->createCancellationPDFFiles(
$data,
'de',
$cancellation_number,
$cancellation_dir,
$cancellation_filename,
$cancellation_delivery_dir,
$cancellation_delivery_filename
);
// Lokalisierte Version wenn gewünscht
if ($customerLocale && $customerLocale !== 'de') {
\App::setLocale($customerLocale);
$data['is_copy'] = true;
$localizedFilename = str_replace('.pdf', '-'.$customerLocale.'.pdf', $cancellation_filename);
$localizedDeliveryFilename = str_replace('.pdf', '-'.$customerLocale.'.pdf', $cancellation_delivery_filename);
$this->createCancellationPDFFiles(
$data,
$customerLocale,
$cancellation_number,
$cancellation_dir,
$localizedFilename,
$cancellation_delivery_dir,
$localizedDeliveryFilename
);
}
\App::setLocale($originalLocale);
}
/**
* Erstellt die PDF-Dateien für eine Stornorechnung in einer bestimmten Sprache
*/
private function createCancellationPDFFiles(
array $data,
string $locale,
string $cancellation_number,
string $cancellation_dir,
string $cancellation_filename,
string $cancellation_delivery_dir,
string $cancellation_delivery_filename
) {
$path = Storage::disk('public')->path('');
$template = $this->getTemplateForLocale($locale);
// Stornorechnung erstellen
$pdf_file = new InvoicePDF('pdf.cancellation');
$pdf_file->create($data, $cancellation_filename, 'save', $path.$cancellation_dir);
$pdfMerger = new MyPDFMerger;
$pdfMerger->addPDF($path.$cancellation_dir.$cancellation_filename);
$file = $pdfMerger->myMerge('string', $cancellation_filename, $template);
Storage::disk('public')->put($cancellation_dir.$cancellation_filename, $file);
// Storno-Lieferschein erstellen (außer bei Sammelbestellung)
if (! $this->model->shopping_collect_order) {
$pdf_file = new InvoicePDF('pdf.cancellation_delivery');
$pdf_file->create($data, $cancellation_delivery_filename, 'save', $path.$cancellation_delivery_dir);
$pdfMerger = new MyPDFMerger;
$pdfMerger->addPDF($path.$cancellation_delivery_dir.$cancellation_delivery_filename);
$file = $pdfMerger->myMerge('string', $cancellation_delivery_filename, $template);
Storage::disk('public')->put($cancellation_delivery_dir.$cancellation_delivery_filename, $file);
}
}
/**
* Korrigiert die Punkte nach Stornierung einer Rechnung
* Nutzt den SalesPointsVolume Service für konsistente Berechnung
*
* @param UserInvoice $original_invoice Die ursprüngliche Rechnung
* @param UserInvoice $cancellation_invoice Die Stornorechnung
*/
private function correctPointsForCancellation($original_invoice, $cancellation_invoice)
{
// Original UserSalesVolume finden
$original_sales_volume = UserSalesVolume::where('user_invoice_id', $original_invoice->id)->first();
if (! $original_sales_volume) {
\Log::warning('Keine UserSalesVolume gefunden für Rechnung', [
'invoice_id' => $original_invoice->id,
'order_id' => $this->model->id,
]);
return;
}
// Service-Methode verwenden für konsistente Punktekorrektur
SalesPointsVolume::cancelSalesPointsVolume($original_sales_volume, $cancellation_invoice->id);
}
}