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:
2026-05-27 19:37:12 +00:00
parent 426156fe45
commit a2026f640a
14 changed files with 676 additions and 28 deletions
+5
View File
@@ -54,6 +54,11 @@ class Part extends Model
return $this->hasMany(WarehouseEvent::class);
}
public function priceHistory(): HasMany
{
return $this->hasMany(SupplierPartPrice::class);
}
/** Live total across all batches of all warehouses (source of truth). */
public function qtyOnHand(?int $warehouseId = null): float
{
+97 -14
View File
@@ -15,12 +15,13 @@ class Purchase extends Model
public const STATUSES = [
'draft' => 'Ciornă',
'ordered' => 'Comandată',
'partial' => 'Parțial recepționată',
'received' => 'Recepționată',
'cancelled' => 'Anulată',
];
protected $fillable = [
'company_id', 'number', 'supplier_id',
'company_id', 'number', 'supplier_id', 'warehouse_id',
'order_date', 'expected_at', 'received_at', 'paid_at',
'status', 'total', 'notes',
];
@@ -38,6 +39,11 @@ class Purchase extends Model
return $this->belongsTo(Supplier::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function items(): HasMany
{
return $this->hasMany(PurchaseItem::class);
@@ -60,24 +66,101 @@ class Purchase extends Model
}
/**
* Mark all items received and increment Part.qty for linked items.
* Receive a specific item qty of buy_price unit cost into target warehouse.
* Routes through WarehouseService so a batch is created + receipt event written.
* Also records the supplier price for analytics.
*/
public function markReceived(): void
public function receiveItem(PurchaseItem $item, float $qty, ?Warehouse $warehouse = null): void
{
\Illuminate\Support\Facades\DB::transaction(function () {
foreach ($this->items as $item) {
if (! $item->received) {
if ($item->part_id) {
$part = Part::find($item->part_id);
$part?->adjustStock((float) $item->qty);
if ($qty <= 0) {
throw new \InvalidArgumentException('Cantitatea de recepție trebuie să fie pozitivă.');
}
$outstanding = (float) $item->qty - (float) $item->qty_received;
if ($qty > $outstanding + 0.001) {
throw new \InvalidArgumentException(sprintf(
'Cantitate prea mare: cerut %.2f, restanță %.2f',
$qty, $outstanding
));
}
\Illuminate\Support\Facades\DB::transaction(function () use ($item, $qty, $warehouse) {
$warehouse ??= $this->warehouse;
if (! $warehouse) {
$warehouse = app(\App\Services\Warehouse\WarehouseService::class)
->defaultWarehouse($this->company_id);
}
if ($item->part_id) {
$part = Part::find($item->part_id);
if ($part) {
app(\App\Services\Warehouse\WarehouseService::class)->receive(
part: $part,
qty: $qty,
buyPrice: (float) $item->buy_price,
warehouse: $warehouse,
supplier: $this->supplier,
batchRef: $this->number,
ref: $this,
notes: "PO #{$this->number}",
);
if ($this->supplier_id) {
SupplierPartPrice::create([
'supplier_id' => $this->supplier_id,
'part_id' => $part->id,
'purchase_id' => $this->id,
'price' => (float) $item->buy_price,
'currency' => 'MDL',
'observed_at' => now(),
]);
}
$item->received = true;
$item->save();
}
}
$this->status = 'received';
$this->received_at = now();
$this->save();
$item->qty_received = (float) $item->qty_received + $qty;
if ((float) $item->qty_received >= (float) $item->qty) {
$item->received = true;
}
$item->save();
$this->recomputeStatus();
});
}
/** Convenience: receive every outstanding item in full. */
public function receiveAllRemaining(?Warehouse $warehouse = null): void
{
foreach ($this->items()->get() as $item) {
$outstanding = (float) $item->qty - (float) $item->qty_received;
if ($outstanding > 0) {
$this->receiveItem($item, $outstanding, $warehouse);
}
}
}
/** Recalculate status based on item qty_received vs qty. */
public function recomputeStatus(): void
{
if ($this->status === 'cancelled' || $this->status === 'draft') {
return;
}
$items = $this->items()->get();
if ($items->isEmpty()) return;
$totals = $items->reduce(function ($carry, $i) {
$carry['ordered'] += (float) $i->qty;
$carry['received'] += (float) $i->qty_received;
return $carry;
}, ['ordered' => 0.0, 'received' => 0.0]);
if ($totals['received'] <= 0) {
$this->status = 'ordered';
} elseif ($totals['received'] + 0.001 < $totals['ordered']) {
$this->status = 'partial';
} else {
$this->status = 'received';
if (! $this->received_at) $this->received_at = now();
}
$this->save();
}
}
+12 -1
View File
@@ -12,16 +12,27 @@ class PurchaseItem extends Model
protected $fillable = [
'company_id', 'purchase_id', 'part_id',
'name', 'article', 'qty', 'unit', 'buy_price', 'total', 'received',
'name', 'article', 'qty', 'qty_received', 'unit', 'buy_price', 'total', 'received',
];
protected $casts = [
'qty' => 'decimal:2',
'qty_received' => 'decimal:2',
'buy_price' => 'decimal:2',
'total' => 'decimal:2',
'received' => 'boolean',
];
public function isFullyReceived(): bool
{
return (float) $this->qty_received + 0.001 >= (float) $this->qty;
}
public function outstanding(): float
{
return max(0.0, (float) $this->qty - (float) $this->qty_received);
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class);
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupplierPartPrice extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'supplier_id', 'part_id', 'purchase_id',
'price', 'currency', 'observed_at',
];
protected $casts = [
'price' => 'decimal:2',
'observed_at' => 'datetime',
];
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
public function part(): BelongsTo
{
return $this->belongsTo(Part::class);
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class);
}
}