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 $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); }); test('F-16: statusservice prueft die vorbedingung innerhalb der sperre erneut', function () { $order = ofMakeOrder([], ['shipped' => 0]); expect(fn () => app(OrderStatusService::class)->setShipped( $order->id, 2, null, true, fn (ShoppingOrder $o) => __('abgelehnt') ))->toThrow(ValidationException::class); expect((int) $order->fresh()->shipped)->toBe(0) ->and($order->fresh()->shipped_status_locked)->toBeFalsy(); // Ohne Fehler aus der Vorbedingung läuft der Wechsel wie gehabt. app(OrderStatusService::class)->setShipped($order->id, 1, null, true, fn (ShoppingOrder $o) => null); expect((int) $order->fresh()->shipped)->toBe(1); });