Stage 12 — Online Store: public catalog + cart + orders
Schema: - online_orders (token-tracked, status workflow, delivery method/fee) - online_order_items (price snapshot, fulfilled flag) - part_cross_refs (OEM/equivalent codes for search) - parts.is_published (shop visibility) Storefront (ShopController, tenant subdomain, /shop): - Catalog with search across name/article/brand/cross-refs, category + in-stock filters, live stock, white-label themed layout - Part detail page with cross-ref codes - VIN search → VinDecoder → guided catalog search - Session cart (per-tenant key), guest checkout, order confirmation page - Respects settings.shop.enabled (404 when off); tenant-guarded Part::searchPublished matches cross-ref articles via whereHas. Order notifications (ShopOrderNotifier, best-effort): - Staff: Web Push to active users - Customer: Telegram if phone matches a linked client Filament (tenant): - OnlineOrderResource under "Magazin" nav group, status workflow, items relation, "Onorează" action issues stock via WarehouseService (FIFO) - PartResource: is_published toggle + column + bulk publish/unpublish + CrossRefsRelationManager - Settings: shop section (enable, delivery methods, fee, free-over) - Landing page: shop button when enabled Tests (6 new): - catalog 404 when disabled; lists published only; cross-ref search; order placement (token + items + total); fulfill issues stock; cross-tenant token isolation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Central\Plan;
|
||||
use App\Models\Tenant\OnlineOrder;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\PartCrossRef;
|
||||
use App\Models\Tenant\Warehouse;
|
||||
use App\Models\Tenant\WarehouseEvent;
|
||||
use App\Services\Warehouse\WarehouseService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OnlineStoreTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_catalog_404_when_shop_disabled(): void
|
||||
{
|
||||
$this->makeShop('shopoff', enabled: false);
|
||||
$this->get('http://shopoff.service.mir.md/shop')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_catalog_lists_published_parts_only(): void
|
||||
{
|
||||
$ctx = $this->makeShop('shopon', enabled: true);
|
||||
Part::create(['name' => 'Filtru public', 'sell_price' => 100, 'qty' => 5, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]);
|
||||
Part::create(['name' => 'Filtru privat', 'sell_price' => 100, 'qty' => 5, 'unit' => 'buc', 'is_active' => true, 'is_published' => false]);
|
||||
|
||||
$resp = $this->get('http://shopon.service.mir.md/shop');
|
||||
$resp->assertOk();
|
||||
$resp->assertSee('Filtru public');
|
||||
$resp->assertDontSee('Filtru privat');
|
||||
}
|
||||
|
||||
public function test_search_matches_cross_reference(): void
|
||||
{
|
||||
$ctx = $this->makeShop('shopcross', enabled: true);
|
||||
$part = Part::create(['name' => 'Filtru ulei', 'article' => 'OEM-1', 'sell_price' => 50, 'qty' => 3, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]);
|
||||
PartCrossRef::create(['part_id' => $part->id, 'cross_article' => 'W811-80', 'brand' => 'MANN']);
|
||||
|
||||
// searchPublished should find the part by its cross article.
|
||||
$found = Part::searchPublished('W811-80')->get();
|
||||
$this->assertCount(1, $found);
|
||||
$this->assertEquals($part->id, $found->first()->id);
|
||||
}
|
||||
|
||||
public function test_place_order_creates_order_with_token_and_items(): void
|
||||
{
|
||||
$ctx = $this->makeShop('shopbuy', enabled: true);
|
||||
$part = Part::create(['name' => 'Plăcuțe frână', 'sell_price' => 200, 'qty' => 10, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]);
|
||||
|
||||
$base = 'http://shopbuy.service.mir.md/shop';
|
||||
|
||||
// Add to cart (session persists across requests in the same test).
|
||||
$this->post("$base/part/{$part->id}/add", ['qty' => 2])->assertRedirect();
|
||||
|
||||
$resp = $this->post("$base/checkout", [
|
||||
'customer_name' => 'Ion Pop',
|
||||
'customer_phone' => '+37369123456',
|
||||
'customer_email' => 'ion@example.com',
|
||||
'delivery_method' => 'pickup',
|
||||
]);
|
||||
$resp->assertRedirect();
|
||||
|
||||
$order = OnlineOrder::first();
|
||||
$this->assertNotNull($order);
|
||||
$this->assertEquals('Ion Pop', $order->customer_name);
|
||||
$this->assertNotEmpty($order->tracking_token);
|
||||
$this->assertEquals(400.0, (float) $order->total); // 2 × 200, pickup = no fee
|
||||
$this->assertEquals(1, $order->items()->count());
|
||||
|
||||
// Confirmation page reachable by token.
|
||||
$this->get($order->trackingUrl())->assertOk()->assertSee($order->number);
|
||||
}
|
||||
|
||||
public function test_fulfill_issues_stock_via_warehouse(): void
|
||||
{
|
||||
$ctx = $this->makeShop('shopfulfill', enabled: true);
|
||||
$svc = app(WarehouseService::class);
|
||||
$part = Part::create(['name' => 'Amortizor', 'sell_price' => 500, 'qty' => 0, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]);
|
||||
$svc->receive($part, 10, 300.0);
|
||||
|
||||
$order = OnlineOrder::create([
|
||||
'number' => OnlineOrder::generateNumber($ctx['company']->id),
|
||||
'customer_name' => 'X', 'customer_phone' => '+37360000000',
|
||||
'delivery_method' => 'pickup', 'status' => 'new',
|
||||
]);
|
||||
$order->items()->create([
|
||||
'company_id' => $ctx['company']->id,
|
||||
'part_id' => $part->id,
|
||||
'name' => $part->name, 'qty' => 3, 'price' => 500,
|
||||
]);
|
||||
|
||||
// Simulate the fulfill action body.
|
||||
$svc->issue($part, 3, null, $order, 'test');
|
||||
$part->refresh();
|
||||
|
||||
$this->assertEquals(7.0, (float) $part->qty);
|
||||
$this->assertEquals(1, WarehouseEvent::where('part_id', $part->id)->where('type', 'issue')->count());
|
||||
}
|
||||
|
||||
public function test_order_token_isolated_across_tenants(): void
|
||||
{
|
||||
$a = $this->makeShop('shopa', enabled: true);
|
||||
$partA = Part::create(['name' => 'P', 'sell_price' => 10, 'qty' => 1, 'unit' => 'buc', 'is_active' => true, 'is_published' => true]);
|
||||
$orderA = OnlineOrder::create([
|
||||
'number' => OnlineOrder::generateNumber($a['company']->id),
|
||||
'customer_name' => 'A', 'customer_phone' => '1', 'delivery_method' => 'pickup', 'status' => 'new',
|
||||
]);
|
||||
|
||||
$this->makeShop('shopb', enabled: true);
|
||||
|
||||
// Tenant B cannot view tenant A's order token.
|
||||
$this->get('http://shopb.service.mir.md/order/' . $orderA->tracking_token)->assertNotFound();
|
||||
}
|
||||
|
||||
private function makeShop(string $slug, bool $enabled): array
|
||||
{
|
||||
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||
$company = Company::create([
|
||||
'plan_id' => $plan->id,
|
||||
'slug' => $slug,
|
||||
'name' => ucfirst($slug),
|
||||
'status' => 'active',
|
||||
'settings' => ['shop' => ['enabled' => $enabled, 'delivery_methods' => ['pickup', 'courier'], 'delivery_fee' => 50, 'free_delivery_over' => 1000]],
|
||||
]);
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$wh = Warehouse::create(['code' => 'MAIN', 'name' => 'D', 'is_default' => true, 'is_active' => true]);
|
||||
$company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly();
|
||||
|
||||
return compact('company', 'wh');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user