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>
This commit is contained in:
parent
8e09aaefe8
commit
1e7ae92d0c
4 changed files with 271 additions and 2 deletions
117
app/Console/Commands/RegenerateCancellationInvoicePdf.php
Normal file
117
app/Console/Commands/RegenerateCancellationInvoicePdf.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class RegenerateCancellationInvoicePdf extends Command
|
||||
{
|
||||
/**
|
||||
* Erzeugt die PDF-Dateien bestehender Stornorechnungen neu (z. B. nach Template-Fixes).
|
||||
* Nummer, Datum und Dateipfade der Stornorechnung bleiben unverändert – es wird keine
|
||||
* neue Rechnungsnummer vergeben und es werden keine Punkte gebucht.
|
||||
*/
|
||||
protected $signature = 'sales:regenerate-cancellation-pdf
|
||||
{invoices* : IDs der Stornorechnungen (user_invoices.id)}
|
||||
{--order : Die übergebenen IDs sind ShoppingOrder-IDs statt Rechnungs-IDs}
|
||||
{--force : Ohne Rückfrage neu erzeugen}';
|
||||
|
||||
protected $description = 'Storno-PDFs (Rechnung + Lieferschein) bestehender Stornorechnungen neu erzeugen';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$invoices = $this->resolveInvoices();
|
||||
|
||||
if ($invoices === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Storno-Rechnung', 'Storno-Nr.', 'Bestellung', 'Datum', 'Datei'],
|
||||
$invoices->map(fn (UserInvoice $invoice) => [
|
||||
$invoice->id,
|
||||
$invoice->full_number,
|
||||
$invoice->shopping_order_id,
|
||||
$invoice->date,
|
||||
$invoice->dir.$invoice->filename,
|
||||
])
|
||||
);
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm("Wirklich {$invoices->count()} Storno-PDF(s) neu erzeugen? Die bestehenden Dateien werden überschrieben.")) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$errors = 0;
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
try {
|
||||
(new InvoiceRepository($invoice->shopping_order))->regenerateCancellationPDF($invoice);
|
||||
$this->info("Stornorechnung #{$invoice->id} ({$invoice->full_number}) neu erzeugt: ".Storage::disk('public')->path($invoice->dir.$invoice->filename));
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
$this->error("Stornorechnung #{$invoice->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst die übergebenen IDs zu gültigen Stornorechnungen auf.
|
||||
*
|
||||
* @return Collection<int, UserInvoice>|null null bei Validierungsfehlern
|
||||
*/
|
||||
private function resolveInvoices(): ?Collection
|
||||
{
|
||||
$invoices = collect();
|
||||
|
||||
foreach ($this->argument('invoices') as $id) {
|
||||
if ($this->option('order')) {
|
||||
$order = ShoppingOrder::find((int) $id);
|
||||
|
||||
if (! $order) {
|
||||
$this->error("ShoppingOrder {$id} nicht gefunden.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$invoice = $order->user_cancellation_invoice;
|
||||
|
||||
if (! $invoice) {
|
||||
$this->error("Bestellung {$id} hat keine Stornorechnung.");
|
||||
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
$invoice = UserInvoice::find((int) $id);
|
||||
|
||||
if (! $invoice) {
|
||||
$this->error("Stornorechnung {$id} nicht gefunden.");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $invoice->cancellation || $invoice->cancellation_id) {
|
||||
$this->error("Rechnung #{$invoice->id} ({$invoice->full_number}) ist keine Stornorechnung.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $invoice->shopping_order) {
|
||||
$this->error("Stornorechnung #{$invoice->id} hat keine zugehörige Bestellung.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$invoices->push($invoice);
|
||||
}
|
||||
|
||||
return $invoices;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ use Storage;
|
|||
|
||||
class InvoiceRepository extends BaseRepository
|
||||
{
|
||||
/** @var \App\Models\ShoppingOrder */
|
||||
/** @var ShoppingOrder */
|
||||
protected $model;
|
||||
|
||||
private $invoice_date;
|
||||
|
|
@ -356,6 +356,32 @@ class InvoiceRepository extends BaseRepository
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@
|
|||
position: relative;
|
||||
padding-left: 15mm;
|
||||
width: 180mm;
|
||||
height: 15mm;
|
||||
margin-bottom: 5mm;
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
}
|
||||
|
|
|
|||
126
tests/Feature/RegenerateCancellationInvoicePdfCommandTest.php
Normal file
126
tests/Feature/RegenerateCancellationInvoicePdfCommandTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserInvoice;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(fn () => makeShopEnv());
|
||||
|
||||
/**
|
||||
* @return array{0: ShoppingOrder, 1: UserInvoice, 2: UserInvoice} Bestellung, Originalrechnung, Stornorechnung
|
||||
*/
|
||||
function makeCancelledOrderWithStornoInvoice(): array
|
||||
{
|
||||
$shoppingUser = ShoppingUser::forceCreate([
|
||||
'auth_user_id' => 0,
|
||||
'billing_firstname' => 'Max',
|
||||
'billing_lastname' => 'Mustermann',
|
||||
'billing_email' => 'storno@example.com',
|
||||
'billing_country_id' => 1,
|
||||
'shipping_country_id' => 1,
|
||||
'wp_order_number' => 0,
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => 0,
|
||||
'country_id' => 1,
|
||||
'user_shop_id' => 0,
|
||||
'payment_for' => 6,
|
||||
'shipping' => 0,
|
||||
'shipping_net' => 0,
|
||||
'subtotal_ws' => 50,
|
||||
'subtotal' => 50,
|
||||
'tax' => 0,
|
||||
'total_shipping' => 50,
|
||||
'points' => 0,
|
||||
'txaction' => 'cancelled',
|
||||
]);
|
||||
|
||||
$product = makeProduct(['12'], 50, 0);
|
||||
$order->shopping_order_items()->create([
|
||||
'product_id' => $product->id, 'is_abo_addon' => false, 'qty' => 1,
|
||||
'price' => 50, 'price_net' => 50, 'tax_rate' => 0, 'tax' => 0, 'price_vk_net' => 50, 'discount' => 0, 'points' => 0,
|
||||
]);
|
||||
|
||||
$original = UserInvoice::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'year' => 2026, 'month' => 7, 'date' => '2026-07-02',
|
||||
'full_number' => '202600001', 'number' => 1,
|
||||
'filename' => '202600001-MIVITA-Rechnung.pdf', 'dir' => 'invoice/2026/07/',
|
||||
'delivery_filename' => '202600001-MIVITA-Lieferschein.pdf', 'delivery_dir' => 'delivery/2026/07/',
|
||||
'disk' => 'public', 'status' => 1,
|
||||
]);
|
||||
|
||||
$storno = UserInvoice::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'year' => 2026, 'month' => 7, 'date' => '2026-07-13',
|
||||
'full_number' => '202600002', 'number' => 2,
|
||||
'filename' => '202600002-MIVITA-Stornorechnung.pdf', 'dir' => 'invoice/2026/07/',
|
||||
'delivery_filename' => '202600002-MIVITA-Storno-Lieferschein.pdf', 'delivery_dir' => 'delivery/2026/07/',
|
||||
'disk' => 'public', 'cancellation' => true, 'status' => 11,
|
||||
]);
|
||||
|
||||
$original->update([
|
||||
'cancellation' => true,
|
||||
'cancellation_id' => $storno->id,
|
||||
'cancellation_date' => '2026-07-13',
|
||||
]);
|
||||
|
||||
return [$order, $original, $storno];
|
||||
}
|
||||
|
||||
it('lehnt eine unbekannte Stornorechnung ab', function () {
|
||||
$this->artisan('sales:regenerate-cancellation-pdf', ['invoices' => [999999], '--force' => true])
|
||||
->expectsOutputToContain('nicht gefunden')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
it('lehnt eine Originalrechnung ab, die keine Stornorechnung ist', function () {
|
||||
[, $original] = makeCancelledOrderWithStornoInvoice();
|
||||
|
||||
$this->artisan('sales:regenerate-cancellation-pdf', ['invoices' => [$original->id], '--force' => true])
|
||||
->expectsOutputToContain('keine Stornorechnung')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
it('lehnt eine Bestellung ohne Stornorechnung ab', function () {
|
||||
[$order, $original, $storno] = makeCancelledOrderWithStornoInvoice();
|
||||
$original->update(['cancellation' => false, 'cancellation_id' => null]);
|
||||
$storno->delete();
|
||||
|
||||
$this->artisan('sales:regenerate-cancellation-pdf', ['invoices' => [$order->id], '--order' => true, '--force' => true])
|
||||
->expectsOutputToContain('hat keine Stornorechnung')
|
||||
->assertExitCode(1);
|
||||
});
|
||||
|
||||
it('erzeugt die Storno-PDFs unter den bestehenden Pfaden neu, ohne neue Rechnungen anzulegen', function () {
|
||||
Storage::fake('public');
|
||||
[, , $storno] = makeCancelledOrderWithStornoInvoice();
|
||||
|
||||
$this->artisan('sales:regenerate-cancellation-pdf', ['invoices' => [$storno->id], '--force' => true])
|
||||
->assertExitCode(0);
|
||||
|
||||
// Keine neuen Rechnungen, keine Nummern verbraucht.
|
||||
expect(UserInvoice::count())->toBe(2);
|
||||
expect($storno->fresh()->full_number)->toBe('202600002');
|
||||
|
||||
Storage::disk('public')->assertExists('invoice/2026/07/202600002-MIVITA-Stornorechnung.pdf');
|
||||
Storage::disk('public')->assertExists('delivery/2026/07/202600002-MIVITA-Storno-Lieferschein.pdf');
|
||||
});
|
||||
|
||||
it('findet die Stornorechnung auch über die Bestell-ID', function () {
|
||||
Storage::fake('public');
|
||||
[$order] = makeCancelledOrderWithStornoInvoice();
|
||||
|
||||
$this->artisan('sales:regenerate-cancellation-pdf', ['invoices' => [$order->id], '--order' => true, '--force' => true])
|
||||
->assertExitCode(0);
|
||||
|
||||
Storage::disk('public')->assertExists('invoice/2026/07/202600002-MIVITA-Stornorechnung.pdf');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue