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:
2026-05-28 05:27:51 +00:00
parent c413004930
commit 954ba8f059
24 changed files with 1390 additions and 1 deletions
+138
View File
@@ -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');
}
}