gruene-seele/tests/Feature/OrderFulfillmentTest.php
Kevin Adametz f93b16c8f7 Warenwirtschaft: F-10 — Versandabschluss nur mit Zahlungsfreigabe
Board-Statuswechsel auf „versendet“ prüft serverseitig isReadyToShip()
(Rechnung/API weiterhin erlaubt); Button im Board ohne Freigabe deaktiviert.
Konfektionieren bleibt vor Zahlungseingang möglich.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:04:07 +00:00

457 lines
16 KiB
PHP

<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\ProductStockMovement;
use App\Models\Setting;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\Services\Invoice;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ofMakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('of_', true).'@test.example',
'password' => bcrypt('password'),
]);
$account = UserAccount::query()->create([
'm_first_name' => 'Versand',
'm_last_name' => 'Mitarbeiter',
'country_id' => ofCountry()->id,
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
'account_id' => $account->id,
])->save();
return $user->fresh();
}
function ofCountry(): Country
{
return Country::query()->where('code', 'DE')->first() ?? Country::query()->forceCreate([
'code' => 'DE',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Germanija',
]);
}
/**
* @param array<int, array{product: Product, qty: int}> $items
*/
function ofMakeOrder(array $items = [], array $overrides = [], ?string $clearingtype = null): ShoppingOrder
{
$country = ofCountry();
$shipping = Shipping::query()->first() ?? Shipping::query()->forceCreate(['name' => 'Standard']);
$shippingCountry = ShippingCountry::query()->first() ?? ShippingCountry::query()->forceCreate([
'shipping_id' => $shipping->id,
'country_id' => $country->id,
]);
$shoppingUser = ShoppingUser::query()->forceCreate([
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_firstname' => 'Otto',
'billing_lastname' => 'Abwickler',
'billing_address' => 'Teststraße 1',
'billing_zipcode' => '12345',
'billing_city' => 'Teststadt',
]);
$order = ShoppingOrder::query()->forceCreate(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => ofMakeUser(0)->id,
'country_id' => $shippingCountry->id,
'shipped' => 0,
'txaction' => 'paid',
'total' => 50,
'subtotal' => 42.02,
'tax' => 7.98,
'total_shipping' => 55,
'weight' => 500,
], $overrides));
foreach ($items as $item) {
ShoppingOrderItem::query()->forceCreate([
'shopping_order_id' => $order->id,
'product_id' => $item['product']->id,
'qty' => $item['qty'],
'price' => 10,
'price_net' => 8.4,
'tax_rate' => 19,
]);
}
if ($clearingtype !== null) {
ShoppingPayment::query()->forceCreate([
'shopping_order_id' => $order->id,
'clearingtype' => $clearingtype,
'reference' => substr(uniqid('of'), 0, 16),
'amount' => 5000,
'currency' => 'EUR',
]);
}
return $order->fresh();
}
function ofMakeProduct(string $name): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => true,
'is_set' => false,
'price' => '10,00',
'tax' => '19',
]);
}
test('board rendert fuer versand-mitarbeiter mit kacheln und packliste', function () {
$product = ofMakeProduct('OF Creme');
ofMakeOrder([['product' => $product, 'qty' => 3]]);
$this->actingAs(ofMakeUser(1))
->get(route('admin.inventory.orders.index'))
->assertOk()
->assertSee('Bestell-Abwicklung')
->assertSee('Zu konfektionieren')
->assertSee('OF Creme')
->assertSee('versandfertig');
});
test('versand-mitarbeiter sehen bestellnummer ohne verkaeufe-link', function () {
// Review 20.07.2026: admin_sales_detail verlangt admin >= 7 — für copyreader
// wäre der Link ein stiller Redirect nach /home
$order = ofMakeOrder();
$this->actingAs(ofMakeUser(1))
->get(route('admin.inventory.orders.index'))
->assertOk()
->assertSee('#'.$order->id)
->assertDontSee(route('admin_sales_detail', $order->id), false);
});
test('admins sehen den verkaeufe-link zur bestellung', function () {
$order = ofMakeOrder();
$this->actingAs(ofMakeUser(7))
->get(route('admin.inventory.orders.index'))
->assertOk()
->assertSee(route('admin_sales_detail', $order->id), false);
});
test('ohne copyreader-rechte kein zugriff', function () {
$this->actingAs(ofMakeUser(0))
->get(route('admin.inventory.orders.index'))
->assertRedirect();
});
test('versandfertig-regel: rechnung sofort, vorkasse erst bei zahlungseingang', function () {
$rechnung = ofMakeOrder([], ['txaction' => 'open'], 'fnc');
$vorkasseOffen = ofMakeOrder([], ['txaction' => 'open'], 'vor');
$vorkasseBezahlt = ofMakeOrder([], ['txaction' => 'paid'], 'vor');
$api = ofMakeOrder([], ['txaction' => 'extern', 'paid' => true]);
expect($rechnung->isReadyToShip())->toBeTrue()
->and($vorkasseOffen->isReadyToShip())->toBeFalse()
->and($vorkasseBezahlt->isReadyToShip())->toBeTrue()
->and($api->isReadyToShip())->toBeTrue();
$response = $this->actingAs(ofMakeUser(1))->get(route('admin.inventory.orders.index'));
$response->assertOk()->assertSee('wartet auf Zahlung');
});
test('konfektioniert setzt status in bearbeitung und sperrt den auto-wechsel', function () {
$order = ofMakeOrder();
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 1])
->assertRedirect(route('admin.inventory.orders.index'));
$order->refresh();
expect((int) $order->shipped)->toBe(1)
->and($order->shipped_status_locked)->toBeTrue();
});
test('versendet bucht den bestandsabzug', function () {
$product = ofMakeProduct('OF Versand');
$order = ofMakeOrder([['product' => $product, 'qty' => 2]]);
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 2])
->assertRedirect();
$order->refresh();
expect((int) $order->shipped)->toBe(2)
->and($order->shipped_at)->not->toBeNull()
->and($order->shipped_status_locked)->toBeTrue();
$movement = ProductStockMovement::query()->where('product_id', $product->id)->sole();
expect($movement->direction)->toBe('out')
->and((int) $movement->quantity)->toBe(2)
->and($movement->source)->toBe('sale');
});
test('board erlaubt nur vorwaerts-status (kein storno, kein zuruecksetzen)', function () {
$order = ofMakeOrder();
$staff = ofMakeUser(1);
$this->actingAs($staff)
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 10])
->assertSessionHasErrors('shipped');
$this->actingAs($staff)
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 0])
->assertSessionHasErrors('shipped');
expect((int) $order->fresh()->shipped)->toBe(0);
});
test('bereits versendete bestellung wird vom board nicht mehr angefasst', function () {
$order = ofMakeOrder([], ['shipped' => 3, 'shipped_at' => now()]);
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 2])
->assertRedirect(route('admin.inventory.orders.index'));
expect((int) $order->fresh()->shipped)->toBe(3)
->and(session('alert-error'))->toContain('nicht mehr in der Abwicklung');
});
test('gesperrter status wird beim oeffnen der detailseite nicht veraendert', function () {
$locked = ofMakeOrder([], ['shipped' => 0, 'shipped_status_locked' => true]);
$unlocked = ofMakeOrder([], ['shipped' => 0]);
$admin = ofMakeUser(7);
$this->actingAs($admin)->get(route('admin_sales_detail', $locked->id))->assertOk();
$this->actingAs($admin)->get(route('admin_sales_detail', $unlocked->id))->assertOk();
expect((int) $locked->fresh()->shipped)->toBe(0)
->and((int) $unlocked->fresh()->shipped)->toBe(1);
});
test('dokumente erstellen legt rechnung und lieferschein an (ohne mail)', function () {
Mail::fake();
Storage::fake('public');
Setting::setContentBySlug('invoice-number', 1000, 'int');
$product = ofMakeProduct('OF Doku');
$order = ofMakeOrder([['product' => $product, 'qty' => 1]]);
// Die PDF-Templates werden relativ zum public/-Verzeichnis geladen (Web-Kontext)
$cwd = getcwd();
chdir(public_path());
try {
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents', $order->id))
->assertRedirect();
} finally {
chdir($cwd);
}
$order->refresh();
expect($order->invoice['filename'] ?? null)->not->toBeNull()
->and($order->delivery['filename'] ?? null)->not->toBeNull();
Mail::assertNothingSent();
});
test('sammel-druck erzeugt fehlende dokumente und liefert ein pdf', function () {
Mail::fake();
Storage::fake('public');
Setting::setContentBySlug('invoice-number', 2000, 'int');
$product = ofMakeProduct('OF Batch');
$orderA = ofMakeOrder([['product' => $product, 'qty' => 1]]);
$orderB = ofMakeOrder([['product' => $product, 'qty' => 2]]);
$cwd = getcwd();
chdir(public_path());
try {
$response = $this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents.batch'), [
'order_ids' => [$orderA->id, $orderB->id],
'type' => 'combined',
]);
} finally {
chdir($cwd);
}
$response->assertOk();
expect($response->headers->get('Content-Type'))->toBe('application/pdf')
->and($orderA->fresh()->invoice['filename'] ?? null)->not->toBeNull()
->and($orderB->fresh()->delivery['filename'] ?? null)->not->toBeNull();
Mail::assertNothingSent();
});
test('kombi-download regeneriert fehlende altdateien statt fehler', function () {
// Review 20.07.2026: der combined-Zweig lief bei fehlender PDF-Datei ungeprüft
// in eine PDFMerger-Exception (HTTP 500) — jetzt zentrale Altdaten-Regeneration
Mail::fake();
Storage::fake('public');
Setting::setContentBySlug('invoice-number', 3000, 'int');
$product = ofMakeProduct('OF Altdaten');
$order = ofMakeOrder([['product' => $product, 'qty' => 1]]);
$user = ofMakeUser(1);
$cwd = getcwd();
chdir(public_path());
try {
$this->actingAs($user)
->post(route('admin.inventory.orders.documents', $order->id))
->assertRedirect();
// Altdaten-Fall: Rechnungs-PDF von der Platte gelöscht, Metadaten vorhanden
$order->refresh();
Storage::disk('public')->delete(Invoice::getDownloadPath($order));
$this->actingAs($user)
->get(route('admin.inventory.orders.document', [$order->id, 'combined']))
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
} finally {
chdir($cwd);
}
Storage::disk('public')->assertExists(Invoice::getDownloadPath($order));
});
test('sammel-druck regeneriert fehlende rechnungs-pdf auch bei vorhandenem lieferschein', function () {
// Review 20.07.2026: vorher prüfte der Sammel-Druck nur den Lieferschein —
// eine fehlende Rechnungs-PDF ließ den ganzen Batch mit 500 platzen
Mail::fake();
Storage::fake('public');
Setting::setContentBySlug('invoice-number', 4000, 'int');
$product = ofMakeProduct('OF Batch-Altdaten');
$order = ofMakeOrder([['product' => $product, 'qty' => 1]]);
$user = ofMakeUser(1);
$cwd = getcwd();
chdir(public_path());
try {
$this->actingAs($user)
->post(route('admin.inventory.orders.documents', $order->id))
->assertRedirect();
$order->refresh();
Storage::disk('public')->delete(Invoice::getDownloadPath($order));
Storage::disk('public')->assertExists(Invoice::getDownloadPathDelivery($order));
$this->actingAs($user)
->post(route('admin.inventory.orders.documents.batch'), [
'order_ids' => [$order->id],
'type' => 'combined',
])
->assertOk()
->assertHeader('Content-Type', 'application/pdf');
} finally {
chdir($cwd);
}
Storage::disk('public')->assertExists(Invoice::getDownloadPath($order));
});
test('sammel-druck nur mit api-bestellungen wird abgelehnt', function () {
$order = ofMakeOrder([], ['txaction' => 'extern', 'wp_invoice_path' => 'https://shop.example/invoice/2.pdf']);
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents.batch'), [
'order_ids' => [$order->id],
'type' => 'delivery',
])
->assertRedirect(route('admin.inventory.orders.index'));
expect(session('alert-error'))->toContain('API-Bestellungen')
->and($order->fresh()->invoice)->toBeNull();
});
test('sammel-druck ueberspringt api-bestellungen in gemischter auswahl', function () {
Mail::fake();
Storage::fake('public');
Setting::setContentBySlug('invoice-number', 3000, 'int');
$product = ofMakeProduct('OF Mix');
$normal = ofMakeOrder([['product' => $product, 'qty' => 1]]);
$api = ofMakeOrder([], ['txaction' => 'extern', 'wp_invoice_path' => 'https://shop.example/invoice/3.pdf']);
$cwd = getcwd();
chdir(public_path());
try {
$response = $this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents.batch'), [
'order_ids' => [$normal->id, $api->id],
'type' => 'delivery',
]);
} finally {
chdir($cwd);
}
$response->assertOk();
expect($normal->fresh()->invoice['filename'] ?? null)->not->toBeNull()
->and($api->fresh()->invoice)->toBeNull();
});
test('sammel-druck validiert die auswahl', function () {
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents.batch'), ['order_ids' => [], 'type' => 'delivery'])
->assertSessionHasErrors('order_ids');
});
test('api-bestellung bekommt keine rechnung im system', function () {
$order = ofMakeOrder([], ['txaction' => 'extern', 'wp_invoice_path' => 'https://shop.example/invoice/1.pdf']);
$this->actingAs(ofMakeUser(1))
->post(route('admin.inventory.orders.documents', $order->id))
->assertRedirect();
expect($order->fresh()->invoice)->toBeNull()
->and(session('alert-error'))->toContain('API-Bestellung');
});
test('F-10: ohne zahlungsfreigabe kein versandabschluss ueber das board, konfektionieren bleibt moeglich', function () {
$product = ofMakeProduct('OF F10');
$order = ofMakeOrder([['product' => $product, 'qty' => 1]], ['txaction' => 'failed', 'paid' => false], 'vor');
expect($order->isReadyToShip())->toBeFalse();
$staff = ofMakeUser(1);
$this->actingAs($staff)
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 2])
->assertRedirect(route('admin.inventory.orders.index'));
expect((int) $order->fresh()->shipped)->toBe(0)
->and(session('alert-error'))->toContain('wartet auf Zahlung')
->and(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(0);
// Board zeigt den Versand-Button nur deaktiviert.
$this->actingAs($staff)->get(route('admin.inventory.orders.index'))
->assertOk()->assertSee('Wartet auf Zahlung — Versand erst nach Zahlungseingang');
$this->actingAs($staff)
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 1])
->assertRedirect();
expect((int) $order->fresh()->shipped)->toBe(1);
// Nach Zahlungseingang geht der Versand.
$order->forceFill(['txaction' => 'paid', 'paid' => true])->save();
$this->actingAs($staff)
->post(route('admin.inventory.orders.status', $order->id), ['shipped' => 2])
->assertRedirect();
expect((int) $order->fresh()->shipped)->toBe(2)
->and(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(1);
});