From a2026f640a113fa087a0859ba39c40f3af7059c0 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 27 May 2026 19:37:12 +0000 Subject: [PATCH] =?UTF-8?q?Stage=206=20=E2=80=94=20Purchase=20System:=20pa?= =?UTF-8?q?rtial=20receipt=20+=20supplier=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Console/Commands/RateSuppliersCommand.php | 51 ++++++ .../Tenant/Resources/PartResource.php | 1 + .../PriceHistoryRelationManager.php | 35 ++++ .../Tenant/Resources/PurchaseResource.php | 42 ++++- .../RelationManagers/ItemsRelationManager.php | 49 ++++- .../Tenant/Resources/SupplierResource.php | 45 ++++- app/Models/Tenant/Part.php | 5 + app/Models/Tenant/Purchase.php | 111 ++++++++++-- app/Models/Tenant/PurchaseItem.php | 13 +- app/Models/Tenant/SupplierPartPrice.php | 37 ++++ app/Services/Warehouse/SupplierAnalytics.php | 89 +++++++++ ...0_extend_purchases_for_partial_receipt.php | 51 ++++++ routes/console.php | 6 + tests/Feature/PurchaseReceiptTest.php | 169 ++++++++++++++++++ 14 files changed, 676 insertions(+), 28 deletions(-) create mode 100644 app/Console/Commands/RateSuppliersCommand.php create mode 100644 app/Filament/Tenant/Resources/PartResource/RelationManagers/PriceHistoryRelationManager.php create mode 100644 app/Models/Tenant/SupplierPartPrice.php create mode 100644 app/Services/Warehouse/SupplierAnalytics.php create mode 100644 database/migrations/2026_05_27_160000_extend_purchases_for_partial_receipt.php create mode 100644 tests/Feature/PurchaseReceiptTest.php diff --git a/app/Console/Commands/RateSuppliersCommand.php b/app/Console/Commands/RateSuppliersCommand.php new file mode 100644 index 0000000..f80597a --- /dev/null +++ b/app/Console/Commands/RateSuppliersCommand.php @@ -0,0 +1,51 @@ +where('status', '!=', 'archived'); + if ($slug = $this->option('slug')) { + $query->where('slug', $slug); + } + $companies = $query->get(); + $days = (int) $this->option('days'); + + $totalUpdated = 0; + + foreach ($companies as $company) { + app(TenantManager::class)->setCurrent($company); + + $suppliers = Supplier::where('is_active', true)->get(); + $changed = 0; + foreach ($suppliers as $supplier) { + $score = $analytics->computedRating($supplier, $days); + if ($score !== null && $score !== (int) $supplier->rating) { + $supplier->rating = $score; + $supplier->saveQuietly(); + $changed++; + } + } + + $this->info(sprintf('[%s] suppliers rated, %d updated', $company->slug, $changed)); + $totalUpdated += $changed; + } + + $this->info("Total suppliers updated: {$totalUpdated}"); + return self::SUCCESS; + } +} diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index 4ac14f0..2036ea5 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -184,6 +184,7 @@ class PartResource extends Resource { return [ RelationManagers\BatchesRelationManager::class, + RelationManagers\PriceHistoryRelationManager::class, ]; } diff --git a/app/Filament/Tenant/Resources/PartResource/RelationManagers/PriceHistoryRelationManager.php b/app/Filament/Tenant/Resources/PartResource/RelationManagers/PriceHistoryRelationManager.php new file mode 100644 index 0000000..7bb697a --- /dev/null +++ b/app/Filament/Tenant/Resources/PartResource/RelationManagers/PriceHistoryRelationManager.php @@ -0,0 +1,35 @@ +columns([ + Tables\Columns\TextColumn::make('observed_at') + ->label('Data') + ->dateTime('d.m.Y H:i') + ->sortable(), + Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->searchable(), + Tables\Columns\TextColumn::make('purchase.number')->label('PO')->placeholder('—'), + Tables\Columns\TextColumn::make('price') + ->money('MDL') + ->alignRight() + ->sortable(), + Tables\Columns\TextColumn::make('currency')->label('Val.'), + ]) + ->defaultSort('observed_at', 'desc') + ->emptyStateHeading('Niciun preț înregistrat') + ->emptyStateDescription('Prețurile se înregistrează automat la fiecare recepție de PO.'); + } +} diff --git a/app/Filament/Tenant/Resources/PurchaseResource.php b/app/Filament/Tenant/Resources/PurchaseResource.php index 05a7ab4..8a542a3 100644 --- a/app/Filament/Tenant/Resources/PurchaseResource.php +++ b/app/Filament/Tenant/Resources/PurchaseResource.php @@ -6,6 +6,7 @@ use App\Filament\Tenant\Resources\PurchaseResource\Pages; use App\Filament\Tenant\Resources\PurchaseResource\RelationManagers; use App\Models\Tenant\Purchase; use App\Models\Tenant\Supplier; +use App\Models\Tenant\Warehouse; use Filament\Actions; use Filament\Forms; use Filament\Notifications\Notification; @@ -43,6 +44,11 @@ class PurchaseResource extends Resource ->options(fn () => Supplier::where('is_active', true)->pluck('name', 'id')) ->searchable() ->required(), + Forms\Components\Select::make('warehouse_id') + ->label('Depozit țintă') + ->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id')) + ->default(fn () => Warehouse::where('is_default', true)->value('id')) + ->required(), Forms\Components\Select::make('status') ->options(Purchase::STATUSES) ->default('draft') @@ -71,9 +77,19 @@ class PurchaseResource extends Resource ->colors([ 'gray' => ['draft'], 'warning' => ['ordered'], + 'info' => ['partial'], 'success' => ['received'], 'danger' => ['cancelled'], ]), + Tables\Columns\TextColumn::make('received_progress') + ->label('Progres') + ->state(function (Purchase $r) { + $items = $r->items; + $ord = (float) $items->sum('qty'); + $rec = (float) $items->sum('qty_received'); + return $ord > 0 ? sprintf('%d%%', (int) round($rec / $ord * 100)) : '—'; + }) + ->alignRight(), Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(), ]) ->filters([ @@ -83,19 +99,27 @@ class PurchaseResource extends Resource ->options(fn () => Supplier::pluck('name', 'id')), ]) ->actions([ - Actions\Action::make('receive') - ->label('Recepționează') + Actions\Action::make('receive_all') + ->label('Recepție totală') ->icon('heroicon-m-check-circle') ->color('success') - ->visible(fn (Purchase $r) => $r->status !== 'received' && $r->status !== 'cancelled') + ->visible(fn (Purchase $r) => ! in_array($r->status, ['received', 'cancelled', 'draft'], true)) ->requiresConfirmation() - ->modalDescription('Se va incrementa stocul pieselor legate.') + ->modalDescription('Se vor crea batch-uri pentru toate restanțele rămase în depozitul țintă.') ->action(function (Purchase $r) { - $r->markReceived(); - Notification::make() - ->title('Recepționat — stoc actualizat') - ->success() - ->send(); + try { + $r->receiveAllRemaining(); + Notification::make() + ->title('Recepție completă — batch-uri create') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title('Eroare') + ->body($e->getMessage()) + ->danger() + ->send(); + } }), Actions\EditAction::make(), Actions\DeleteAction::make(), diff --git a/app/Filament/Tenant/Resources/PurchaseResource/RelationManagers/ItemsRelationManager.php b/app/Filament/Tenant/Resources/PurchaseResource/RelationManagers/ItemsRelationManager.php index ad4e52e..6edebae 100644 --- a/app/Filament/Tenant/Resources/PurchaseResource/RelationManagers/ItemsRelationManager.php +++ b/app/Filament/Tenant/Resources/PurchaseResource/RelationManagers/ItemsRelationManager.php @@ -3,8 +3,11 @@ namespace App\Filament\Tenant\Resources\PurchaseResource\RelationManagers; use App\Models\Tenant\Part; +use App\Models\Tenant\PurchaseItem; +use App\Models\Tenant\Warehouse; use Filament\Actions; use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; @@ -52,16 +55,58 @@ class ItemsRelationManager extends RelationManager ->columns([ Tables\Columns\TextColumn::make('name')->wrap(), Tables\Columns\TextColumn::make('article')->placeholder('—'), - Tables\Columns\TextColumn::make('qty')->alignRight(), + Tables\Columns\TextColumn::make('qty')->label('Comandat')->alignRight(), + Tables\Columns\TextColumn::make('qty_received') + ->label('Recepționat') + ->alignRight() + ->color(fn ($state, $record) => $record->isFullyReceived() ? 'success' : ((float) $state > 0 ? 'warning' : 'gray')) + ->formatStateUsing(fn ($state, $record) => sprintf('%.2f / %.2f', (float) $state, (float) $record->qty)), Tables\Columns\TextColumn::make('unit')->label('UM'), Tables\Columns\TextColumn::make('buy_price')->money('MDL')->alignRight(), Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(), - Tables\Columns\IconColumn::make('received')->boolean()->label('Recepț.'), ]) ->headerActions([ Actions\CreateAction::make(), ]) ->actions([ + Actions\Action::make('receive_item') + ->label('Recepționează') + ->icon('heroicon-m-arrow-down-tray') + ->color('success') + ->visible(fn (PurchaseItem $r) => ! $r->isFullyReceived()) + ->schema([ + Forms\Components\Placeholder::make('outstanding') + ->label('Restanță') + ->content(fn (PurchaseItem $r) => sprintf('%.2f %s', $r->outstanding(), $r->unit ?? 'buc')), + Forms\Components\TextInput::make('qty') + ->label('Cantitate recepționată') + ->numeric() + ->required() + ->minValue(0.001) + ->default(fn (PurchaseItem $r) => $r->outstanding()), + Forms\Components\Select::make('warehouse_id') + ->label('Depozit țintă') + ->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id')) + ->default(fn (PurchaseItem $r) => $r->purchase?->warehouse_id + ?? Warehouse::where('is_default', true)->value('id')) + ->required(), + ]) + ->action(function (PurchaseItem $r, array $data) { + $wh = $data['warehouse_id'] ? Warehouse::find($data['warehouse_id']) : null; + try { + $r->purchase->receiveItem($r, (float) $data['qty'], $wh); + Notification::make() + ->title('Recepționat — batch creat') + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title('Eroare la recepție') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), Actions\EditAction::make(), Actions\DeleteAction::make(), ]); diff --git a/app/Filament/Tenant/Resources/SupplierResource.php b/app/Filament/Tenant/Resources/SupplierResource.php index d1f957e..482b5b3 100644 --- a/app/Filament/Tenant/Resources/SupplierResource.php +++ b/app/Filament/Tenant/Resources/SupplierResource.php @@ -68,15 +68,56 @@ class SupplierResource extends Resource Tables\Columns\TextColumn::make('rating') ->label('Rating') ->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)), - Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight(), + Tables\Columns\TextColumn::make('on_time_pct') + ->label('La timp 90d') + ->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->onTimeRate($r)) + ->formatStateUsing(fn ($s) => $s === null ? '—' : "{$s}%") + ->color(fn ($s) => $s === null ? 'gray' : ($s >= 90 ? 'success' : ($s >= 70 ? 'warning' : 'danger'))) + ->alignRight() + ->toggleable(), + Tables\Columns\TextColumn::make('avg_delivery_days') + ->label('Avg zile') + ->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->avgDeliveryDays($r)) + ->formatStateUsing(fn ($s) => $s === null ? '—' : (string) $s) + ->alignRight() + ->toggleable(), + Tables\Columns\TextColumn::make('spend_90d') + ->label('Cheltuit 90d') + ->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->spend($r)) + ->money('MDL') + ->alignRight() + ->toggleable(), + Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight()->toggleable(), Tables\Columns\TextColumn::make('discount_pct')->label('Discount') - ->formatStateUsing(fn ($s) => $s . '%')->alignRight(), + ->formatStateUsing(fn ($s) => $s . '%')->alignRight()->toggleable(), Tables\Columns\IconColumn::make('is_active')->boolean(), ]) ->filters([ Tables\Filters\TernaryFilter::make('is_active')->label('Activi'), ]) ->actions([ + Actions\Action::make('rate') + ->label('Rerating') + ->icon('heroicon-m-arrow-path') + ->color('gray') + ->action(function (Supplier $r) { + $score = app(\App\Services\Warehouse\SupplierAnalytics::class) + ->computedRating($r); + if ($score === null) { + \Filament\Notifications\Notification::make() + ->title('Date insuficiente') + ->body('Necesită cel puțin 2 recepții complete cu data așteptată setată.') + ->warning() + ->send(); + return; + } + $r->rating = $score; + $r->saveQuietly(); + \Filament\Notifications\Notification::make() + ->title("Rating actualizat → {$score}★") + ->success() + ->send(); + }), Actions\EditAction::make(), Actions\DeleteAction::make(), ]) diff --git a/app/Models/Tenant/Part.php b/app/Models/Tenant/Part.php index 4ad4f38..6ff0c4e 100644 --- a/app/Models/Tenant/Part.php +++ b/app/Models/Tenant/Part.php @@ -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 { diff --git a/app/Models/Tenant/Purchase.php b/app/Models/Tenant/Purchase.php index cc4ef96..651dd87 100644 --- a/app/Models/Tenant/Purchase.php +++ b/app/Models/Tenant/Purchase.php @@ -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(); + } } diff --git a/app/Models/Tenant/PurchaseItem.php b/app/Models/Tenant/PurchaseItem.php index 2d06c89..9065128 100644 --- a/app/Models/Tenant/PurchaseItem.php +++ b/app/Models/Tenant/PurchaseItem.php @@ -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); diff --git a/app/Models/Tenant/SupplierPartPrice.php b/app/Models/Tenant/SupplierPartPrice.php new file mode 100644 index 0000000..1345a94 --- /dev/null +++ b/app/Models/Tenant/SupplierPartPrice.php @@ -0,0 +1,37 @@ + '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); + } +} diff --git a/app/Services/Warehouse/SupplierAnalytics.php b/app/Services/Warehouse/SupplierAnalytics.php new file mode 100644 index 0000000..6aa9a1f --- /dev/null +++ b/app/Services/Warehouse/SupplierAnalytics.php @@ -0,0 +1,89 @@ +subDays($days); + $rows = Purchase::where('supplier_id', $supplier->id) + ->where('status', 'received') + ->whereNotNull('expected_at') + ->whereNotNull('received_at') + ->where('received_at', '>=', $since) + ->get(['expected_at', 'received_at']); + + if ($rows->isEmpty()) return null; + + $onTime = $rows->filter(fn ($r) => $r->received_at->lte($r->expected_at))->count(); + return round($onTime / $rows->count() * 100, 1); + } + + /** Average days between order_date and received_at. */ + public function avgDeliveryDays(Supplier $supplier, int $days = 90): ?float + { + $since = Carbon::now()->subDays($days); + $rows = Purchase::where('supplier_id', $supplier->id) + ->where('status', 'received') + ->whereNotNull('order_date') + ->whereNotNull('received_at') + ->where('received_at', '>=', $since) + ->get(['order_date', 'received_at']); + + if ($rows->isEmpty()) return null; + + $total = $rows->sum(fn ($r) => $r->order_date->diffInDays($r->received_at)); + return round($total / $rows->count(), 1); + } + + /** Total spend (sum of purchase totals) over a window. */ + public function spend(Supplier $supplier, int $days = 90): float + { + $since = Carbon::now()->subDays($days); + return (float) Purchase::where('supplier_id', $supplier->id) + ->where('status', 'received') + ->where('received_at', '>=', $since) + ->sum('total'); + } + + /** Count of received purchases in window. */ + public function count(Supplier $supplier, int $days = 90): int + { + $since = Carbon::now()->subDays($days); + return (int) Purchase::where('supplier_id', $supplier->id) + ->where('status', 'received') + ->where('received_at', '>=', $since) + ->count(); + } + + /** + * Compose a 1-5 score from on-time, delivery speed and spend volume. + * Returns null when not enough signal — caller may keep manual rating. + */ + public function computedRating(Supplier $supplier, int $days = 90): ?int + { + $onTime = $this->onTimeRate($supplier, $days); + $count = $this->count($supplier, $days); + + if ($onTime === null || $count < 2) return null; // need at least 2 deliveries to rate + + // On-time is the dominant factor (0..70 points). + $score = $onTime * 0.7; + // Bonus for higher volume (0..20 points capped at 10 purchases). + $score += min(20, $count * 2); + // Speed bonus: faster than 7 days avg → +10. + $avg = $this->avgDeliveryDays($supplier, $days); + if ($avg !== null && $avg < 7) $score += 10; + + return (int) max(1, min(5, round($score / 20))); + } +} diff --git a/database/migrations/2026_05_27_160000_extend_purchases_for_partial_receipt.php b/database/migrations/2026_05_27_160000_extend_purchases_for_partial_receipt.php new file mode 100644 index 0000000..47d62ea --- /dev/null +++ b/database/migrations/2026_05_27_160000_extend_purchases_for_partial_receipt.php @@ -0,0 +1,51 @@ +foreignId('warehouse_id')->nullable()->after('supplier_id') + ->constrained('warehouses')->nullOnDelete(); + }); + + Schema::table('purchase_items', function (Blueprint $t) { + $t->decimal('qty_received', 10, 2)->default(0)->after('qty'); + }); + + // Backfill: items previously marked `received=true` were fully received. + DB::statement('UPDATE purchase_items SET qty_received = qty WHERE received = 1'); + + Schema::create('supplier_part_prices', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('supplier_id')->constrained()->cascadeOnDelete(); + $t->foreignId('part_id')->constrained()->cascadeOnDelete(); + $t->foreignId('purchase_id')->nullable()->constrained()->nullOnDelete(); + $t->decimal('price', 12, 2); + $t->string('currency', 6)->default('MDL'); + $t->dateTime('observed_at'); + $t->timestamps(); + + $t->index(['company_id', 'supplier_id', 'part_id', 'observed_at']); + $t->index(['company_id', 'part_id', 'observed_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplier_part_prices'); + Schema::table('purchase_items', function (Blueprint $t) { + $t->dropColumn('qty_received'); + }); + Schema::table('purchases', function (Blueprint $t) { + $t->dropForeign(['warehouse_id']); + $t->dropColumn('warehouse_id'); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index 3e6941e..88425c2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -18,3 +18,9 @@ ScheduleFacade::command('backup:tenants --keep=14') // AI chat cleanup — keep tokens spend in check. ScheduleFacade::command('queue:prune-batches --hours=48')->daily(); ScheduleFacade::command('queue:prune-failed --hours=72')->daily(); + +// Weekly supplier rating recomputation — Monday 04:00. +ScheduleFacade::command('suppliers:rate --days=90') + ->weeklyOn(1, '04:00') + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Feature/PurchaseReceiptTest.php b/tests/Feature/PurchaseReceiptTest.php new file mode 100644 index 0000000..1e2a851 --- /dev/null +++ b/tests/Feature/PurchaseReceiptTest.php @@ -0,0 +1,169 @@ +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'); + } +}