mivita/app/Repositories/InvoiceRepository.php
Kevin 65b867f8ee Abo Einmalprodukte: Phasen 5-7, konfigurierbare Sperrfristen, Lokalisierung & Dashboard
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>
2026-06-23 13:38:11 +00:00

487 lines
19 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 \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);
}
}