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>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user