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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user