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>
167 lines
5.4 KiB
PHP
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();
|
|
}
|
|
}
|