- Statusservice: Vorbedingung (Ausgangsstatus, Zahlungsfreigabe) wird nach lockForUpdate erneut geprüft (Board übergibt sie als Closure). - Wareneingang: Transaktion + Sperre, „offen“ wird innerhalb der Sperre geprüft. - Ausschuss: betroffene Wareneingänge sperren und Restbestand in der Transaktion erneut prüfen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
474 lines
17 KiB
PHP
474 lines
17 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\Services\OrderStatusService;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Validation\ValidationException;
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|