a2026f640a
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>
170 lines
5.9 KiB
PHP
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');
|
|
}
|
|
}
|