Rechnung/Lieferschein: - PDF-Gruppierung Abo- vs. Einmal-Artikel (is_abo_addon), rueckwaertskompatibel - Vorschau-Command sales:preview-invoice (umgeht Test-System-Gate, ohne Rechnungsnummer) Reminder-Mail (vorbereitet, Cron inaktiv): - MailAboExecutionReminder + Template, abo:send-execution-reminder, Spalte onetime_reminder_sent_for - Berater -> Sales Center, Kunde -> Portal; Vorschau abo:preview-reminder - Datum-Fix: formatDateDB() statt formatDate() (ICU-Format) Sperrfristen konfigurierbar (Admin-Settings): - abo-lock-days-change (Liefertag + Abo-Produkte), abo-lock-days-pause-cancel - Meldungen mit :lock-Platzhalter; Pausieren/Kuendigen vom Change-Lock entkoppelt - Abo-Produkt-Aenderungen im Lock-Fenster server- + clientseitig eingefroren - Selbstbedienung: Add-only/Lock nach $view (kein Admin-Bypass), Stale-Cache-Fix beim Mengen-Update Dashboard: - Einmalprodukte-Card + Team-Abo-KPI-Card, News-Card responsiv (col-xl-6) Lokalisierung: - Sprachdateien en/es/fr vollstaendig mit de synchronisiert (inkl. marketingplan/portal) - LocalizationSyncTest sichert Key-Gleichheit ab Tests: AboOneTimeInvoiceGrouping, AboExecutionReminder, AboLockDaysSetting, AboOnetimeDashboardAndMail, PreviewInvoiceCommand, TeamAboOverview + erweiterte View-Tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
487 lines
19 KiB
PHP
487 lines
19 KiB
PHP
<?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 \App\Models\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;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|