Files
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

167 lines
5.4 KiB
PHP

<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Purchase extends Model
{
use BelongsToTenant, SoftDeletes;
public const STATUSES = [
'draft' => 'Ciornă',
'ordered' => 'Comandată',
'partial' => 'Parțial recepționată',
'received' => 'Recepționată',
'cancelled' => 'Anulată',
];
protected $fillable = [
'company_id', 'number', 'supplier_id', 'warehouse_id',
'order_date', 'expected_at', 'received_at', 'paid_at',
'status', 'total', 'notes',
];
protected $casts = [
'order_date' => 'date',
'expected_at' => 'date',
'received_at' => 'date',
'paid_at' => 'date',
'total' => 'decimal:2',
];
public function supplier(): BelongsTo
{
return $this->belongsTo(Supplier::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
public function items(): HasMany
{
return $this->hasMany(PurchaseItem::class);
}
public function recalcTotal(): void
{
$this->total = (float) $this->items()->sum('total');
$this->save();
}
public static function generateNumber(int $companyId): string
{
$year = date('y');
$count = static::withoutGlobalScopes()
->where('company_id', $companyId)
->whereYear('created_at', date('Y'))
->count();
return sprintf('P-%s-%04d', $year, $count + 1);
}
/**
* 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 receiveItem(PurchaseItem $item, float $qty, ?Warehouse $warehouse = null): void
{
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->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();
}
}