gruene-seele/tests/Feature/OrderFulfillmentTest.php
Kevin Adametz 1252c30820 Warenwirtschaft: AP-33b Sammel-Dokumente — Lieferscheine/Rechnungen der Auswahl als ein PDF
Checkbox-Auswahl im Abwicklungs-Board (Alle-auswählen, Zähler in den Buttons)
+ zwei Sammel-Aktionen: „Lieferscheine drucken" und „+ Rechnungen" — je ein
zusammengeführtes PDF (PDFMerger/FPDI) im neuen Tab zum Stapeldruck. Fehlende
Dokumente werden dabei ohne Mailversand erzeugt, auf der Platte fehlende
Altdateien mit bestehender Nummer regeneriert. API-Bestellungen sind von der
Auswahl ausgenommen (deaktivierte Checkbox, Rechnung liegt im externen Shop)
und werden in gemischter Auswahl übersprungen; max. 100 je Druck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:00:10 +00:00

336 lines
12 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\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('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('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');
});