Files
autocrm/tests/Feature/PurchaseReceiptTest.php
T
Vasyka a2026f640a Stage 6 — Purchase System: partial receipt + supplier analytics
Schema:
- purchase_items.qty_received (backfilled from `received` boolean)
- purchases.warehouse_id (target warehouse FK)
- supplier_part_prices (price history per supplier/part with purchase ref)
- New status `partial` between ordered and received

Purchase ↔ Warehouse integration:
- Purchase::receiveItem(item, qty, warehouse?) — routes through
  WarehouseService::receive: creates batch + receipt event + supplier price row
- Purchase::receiveAllRemaining(warehouse?) — receives all outstanding lines
- Purchase::recomputeStatus() — auto: ordered → partial → received

Old flat markReceived() removed — every receipt now writes batches + ledger.

Filament:
- Purchase list: progress %, partial badge, warehouse picker on form
- ItemsRelationManager: per-line "Recepționează" with qty + warehouse modal,
  qty_received shown as "X.XX / Y.YY" with colour
- PartResource: new PriceHistoryRelationManager (supplier price history)
- SupplierResource: derived columns onTimeRate / avgDeliveryDays / spend(90d)
  + "Rerating" action

Analytics:
- App\Services\Warehouse\SupplierAnalytics (onTimeRate, avgDeliveryDays,
  spend, count, computedRating)
- `suppliers:rate` artisan command + weekly schedule (Mon 04:00)
- Computed rating: 70% on-time + 20% volume + 10% speed bonus

Tests (6 new, all pass):
- Partial receipt of 3/10 → status=partial + 1 batch + 1 price row
- receiveAllRemaining → status=received with received_at set
- Over-receive throws InvalidArgumentException
- Two partial receipts (4+6) → 2 batches FIFO + status=received
- onTimeRate 50% with 1 on-time + 1 late
- computedRating null when <2 deliveries

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:37:12 +00:00

170 lines
5.9 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Part;
use App\Models\Tenant\PartBatch;
use App\Models\Tenant\Purchase;
use App\Models\Tenant\PurchaseItem;
use App\Models\Tenant\Supplier;
use App\Models\Tenant\SupplierPartPrice;
use App\Models\Tenant\Warehouse;
use App\Models\Tenant\WarehouseEvent;
use App\Services\Warehouse\SupplierAnalytics;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PurchaseReceiptTest extends TestCase
{
use RefreshDatabase;
public function test_partial_receipt_sets_status_partial_and_creates_batch(): void
{
$ctx = $this->makeContext();
['purchase' => $po, 'item' => $item, 'part' => $part] = $ctx;
$po->receiveItem($item, 3, $ctx['warehouse']);
$item->refresh(); $po->refresh(); $part->refresh();
$this->assertEquals(3.0, (float) $item->qty_received);
$this->assertEquals('partial', $po->status);
$this->assertFalse($item->isFullyReceived());
$this->assertEquals(3.0, (float) $part->qty);
$this->assertEquals(1, PartBatch::where('part_id', $part->id)->count());
$this->assertEquals(1, SupplierPartPrice::where('part_id', $part->id)->count());
$this->assertEquals(1, WarehouseEvent::where('part_id', $part->id)->where('type', 'receipt')->count());
}
public function test_full_receipt_via_helper_sets_status_received(): void
{
$ctx = $this->makeContext();
$ctx['purchase']->receiveAllRemaining();
$ctx['purchase']->refresh(); $ctx['part']->refresh();
$this->assertEquals('received', $ctx['purchase']->status);
$this->assertNotNull($ctx['purchase']->received_at);
$this->assertTrue($ctx['item']->fresh()->isFullyReceived());
$this->assertEquals(10.0, (float) $ctx['part']->qty);
}
public function test_receipt_over_outstanding_throws(): void
{
$ctx = $this->makeContext();
$this->expectException(\InvalidArgumentException::class);
$ctx['purchase']->receiveItem($ctx['item'], 11);
}
public function test_two_partial_receipts_complete_purchase(): void
{
$ctx = $this->makeContext();
$ctx['purchase']->receiveItem($ctx['item'], 4);
$ctx['purchase']->refresh();
$this->assertEquals('partial', $ctx['purchase']->status);
$ctx['purchase']->receiveItem($ctx['item']->fresh(), 6);
$ctx['purchase']->refresh();
$this->assertEquals('received', $ctx['purchase']->status);
// Two separate batches → FIFO will consume the first one first.
$this->assertEquals(2, PartBatch::where('part_id', $ctx['part']->id)->count());
}
public function test_supplier_analytics_on_time_rate(): void
{
$ctx = $this->makeContext();
$analytics = app(SupplierAnalytics::class);
// No purchases received yet → null
$this->assertNull($analytics->onTimeRate($ctx['supplier']));
// One on-time delivery
$ctx['purchase']->update(['expected_at' => now()->addDays(5), 'received_at' => now()->addDays(3)]);
$ctx['purchase']->refresh();
$ctx['purchase']->update(['status' => 'received']);
// Second one late
$po2 = Purchase::create([
'number' => Purchase::generateNumber($ctx['supplier']->company_id),
'supplier_id' => $ctx['supplier']->id,
'warehouse_id' => $ctx['warehouse']->id,
'order_date' => now()->subDays(10),
'expected_at' => now()->subDays(2),
'received_at' => now(),
'status' => 'received',
]);
$po2->refresh();
$rate = $analytics->onTimeRate($ctx['supplier']);
$this->assertEquals(50.0, $rate, '1 on-time + 1 late = 50%');
}
public function test_computed_rating_returns_null_with_insufficient_data(): void
{
$ctx = $this->makeContext();
$analytics = app(SupplierAnalytics::class);
$this->assertNull($analytics->computedRating($ctx['supplier']));
}
private function makeContext(): array
{
$plan = Plan::firstOrCreate(['slug' => 'test'], [
'name' => 'Test', 'price' => 0, 'features' => [],
]);
$company = Company::create([
'plan_id' => $plan->id,
'slug' => 'pr-' . uniqid(),
'name' => 'PR Service',
'status' => 'active',
]);
app(TenantManager::class)->setCurrent($company);
$warehouse = Warehouse::create([
'code' => 'MAIN', 'name' => 'Depozit',
'is_default' => true, 'is_active' => true,
]);
$company->forceFill(['default_warehouse_id' => $warehouse->id])->saveQuietly();
$supplier = Supplier::create([
'name' => 'Acme Parts',
'is_active' => true,
'rating' => 3,
'delivery_days' => 0,
'discount_pct' => 0,
]);
$part = Part::create([
'name' => 'Filtru aer',
'article' => 'FA-001',
'unit' => 'buc',
'qty' => 0,
'buy_price' => 0,
'sell_price' => 0,
'is_active' => true,
]);
$purchase = Purchase::create([
'number' => Purchase::generateNumber($company->id),
'supplier_id' => $supplier->id,
'warehouse_id' => $warehouse->id,
'order_date' => now(),
'expected_at' => now()->addDays(3),
'status' => 'ordered',
]);
$item = PurchaseItem::create([
'purchase_id' => $purchase->id,
'part_id' => $part->id,
'name' => $part->name,
'article' => $part->article,
'qty' => 10,
'unit' => 'buc',
'buy_price' => 50,
]);
return compact('company', 'warehouse', 'supplier', 'part', 'purchase', 'item');
}
}