diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php index c2b2bbf..4ac14f0 100644 --- a/app/Filament/Tenant/Resources/PartResource.php +++ b/app/Filament/Tenant/Resources/PartResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Tenant\Resources; use App\Filament\Tenant\Resources\PartResource\Pages; +use App\Filament\Tenant\Resources\PartResource\RelationManagers; use App\Models\Tenant\Part; use App\Models\Tenant\Supplier; use Filament\Actions; @@ -112,6 +113,12 @@ class PartResource extends Resource ->alignRight() ->color(fn ($state, $record) => $record->qty <= 0 ? 'danger' : ($record->qty <= $record->min_qty ? 'warning' : null)) ->weight(fn ($state, $record) => $record->qty <= $record->min_qty ? 'bold' : null), + Tables\Columns\TextColumn::make('qty_reserved') + ->label('Rezervat') + ->numeric(decimalPlaces: 2) + ->alignRight() + ->color(fn ($state) => (float) $state > 0 ? 'info' : null) + ->toggleable(), Tables\Columns\TextColumn::make('unit')->label('UM'), Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'), Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(), @@ -128,6 +135,42 @@ class PartResource extends Resource ->query(fn ($q) => $q->where('qty', '<=', 0)), ]) ->actions([ + Actions\Action::make('receive') + ->label('Recepție') + ->icon('heroicon-m-arrow-down-tray') + ->color('success') + ->schema([ + Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->required()->minValue(0.001), + Forms\Components\TextInput::make('buy_price')->label('Preț unitar')->numeric()->required(), + Forms\Components\Select::make('supplier_id') + ->label('Furnizor') + ->options(fn () => \App\Models\Tenant\Supplier::pluck('name', 'id')), + Forms\Components\Select::make('warehouse_id') + ->label('Depozit') + ->options(fn () => \App\Models\Tenant\Warehouse::where('is_active', true)->pluck('name', 'id')) + ->default(fn () => \App\Models\Tenant\Warehouse::where('is_default', true)->value('id')), + Forms\Components\TextInput::make('batch_ref')->label('Ref. lot/factură')->maxLength(64), + ]) + ->action(function (Part $record, array $data) { + $warehouse = $data['warehouse_id'] + ? \App\Models\Tenant\Warehouse::find($data['warehouse_id']) + : null; + $supplier = $data['supplier_id'] + ? \App\Models\Tenant\Supplier::find($data['supplier_id']) + : null; + app(\App\Services\Warehouse\WarehouseService::class)->receive( + part: $record, + qty: (float) $data['qty'], + buyPrice: (float) $data['buy_price'], + warehouse: $warehouse, + supplier: $supplier, + batchRef: $data['batch_ref'] ?? null, + ); + \Filament\Notifications\Notification::make() + ->title('Stoc adăugat') + ->success() + ->send(); + }), Actions\EditAction::make(), Actions\DeleteAction::make(), ]) @@ -137,6 +180,13 @@ class PartResource extends Resource ->defaultSort('name'); } + public static function getRelations(): array + { + return [ + RelationManagers\BatchesRelationManager::class, + ]; + } + public static function getPages(): array { return [ diff --git a/app/Filament/Tenant/Resources/PartResource/RelationManagers/BatchesRelationManager.php b/app/Filament/Tenant/Resources/PartResource/RelationManagers/BatchesRelationManager.php new file mode 100644 index 0000000..1dfa811 --- /dev/null +++ b/app/Filament/Tenant/Resources/PartResource/RelationManagers/BatchesRelationManager.php @@ -0,0 +1,45 @@ +columns([ + Tables\Columns\TextColumn::make('received_at') + ->label('Recepție') + ->dateTime('d.m.Y H:i') + ->sortable(), + Tables\Columns\TextColumn::make('warehouse.code')->label('Depozit')->placeholder('—'), + Tables\Columns\TextColumn::make('batch_ref')->label('Ref.')->placeholder('—'), + Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->placeholder('—'), + Tables\Columns\TextColumn::make('qty_in') + ->label('Intrat') + ->numeric(decimalPlaces: 2) + ->alignRight(), + Tables\Columns\TextColumn::make('qty_remaining') + ->label('Rămas') + ->numeric(decimalPlaces: 2) + ->alignRight() + ->weight('bold') + ->color(fn ($state) => (float) $state <= 0 ? 'gray' : 'success'), + Tables\Columns\TextColumn::make('buy_price') + ->label('Preț unit.') + ->money('MDL') + ->alignRight(), + ]) + ->defaultSort('received_at') + ->emptyStateHeading('Niciun lot înregistrat') + ->emptyStateDescription('Apasă „Recepție" pe lista de piese pentru a înregistra prima intrare în depozit.'); + } +} diff --git a/app/Filament/Tenant/Resources/WarehouseResource.php b/app/Filament/Tenant/Resources/WarehouseResource.php new file mode 100644 index 0000000..461f026 --- /dev/null +++ b/app/Filament/Tenant/Resources/WarehouseResource.php @@ -0,0 +1,76 @@ +components([ + Schemas\Components\Section::make()->columns(2)->schema([ + Forms\Components\TextInput::make('code')->label('Cod')->required()->maxLength(32), + Forms\Components\TextInput::make('name')->label('Denumire')->required()->maxLength(120), + Forms\Components\TextInput::make('address')->label('Adresă')->columnSpanFull()->maxLength(200), + Forms\Components\Toggle::make('is_default')->label('Depozit implicit'), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('code')->searchable()->sortable(), + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('address')->placeholder('—')->toggleable(), + Tables\Columns\IconColumn::make('is_default')->label('Implicit')->boolean(), + Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(), + Tables\Columns\TextColumn::make('batches_count') + ->counts('batches') + ->label('Loturi') + ->alignRight(), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Niciun depozit') + ->emptyStateDescription('Un depozit implicit a fost creat la migrare. Adaugă altele dacă ai locații fizice separate (sucursală, hală, mobil).') + ->emptyStateIcon('heroicon-o-building-storefront') + ->defaultSort('code'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWarehouses::route('/'), + 'create' => Pages\CreateWarehouse::route('/create'), + 'edit' => Pages\EditWarehouse::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/WarehouseResource/Pages/CreateWarehouse.php b/app/Filament/Tenant/Resources/WarehouseResource/Pages/CreateWarehouse.php new file mode 100644 index 0000000..e5b5beb --- /dev/null +++ b/app/Filament/Tenant/Resources/WarehouseResource/Pages/CreateWarehouse.php @@ -0,0 +1,11 @@ + 'decimal:2', + 'qty_reserved' => 'decimal:3', 'min_qty' => 'decimal:2', 'buy_price' => 'decimal:2', 'sell_price' => 'decimal:2', @@ -37,6 +39,35 @@ class Part extends Model return $this->belongsTo(Supplier::class, 'preferred_supplier_id'); } + public function batches(): HasMany + { + return $this->hasMany(PartBatch::class); + } + + public function reservations(): HasMany + { + return $this->hasMany(PartReservation::class); + } + + public function events(): HasMany + { + return $this->hasMany(WarehouseEvent::class); + } + + /** Live total across all batches of all warehouses (source of truth). */ + public function qtyOnHand(?int $warehouseId = null): float + { + $q = $this->batches()->newQuery()->where('part_id', $this->id); + if ($warehouseId) $q->where('warehouse_id', $warehouseId); + return (float) $q->sum('qty_remaining'); + } + + /** Available for new reservations = on hand − already reserved. */ + public function qtyAvailable(?int $warehouseId = null): float + { + return max(0.0, $this->qtyOnHand($warehouseId) - (float) $this->qty_reserved); + } + public function isLow(): bool { return (float) $this->qty <= (float) $this->min_qty; @@ -47,6 +78,11 @@ class Part extends Model return (float) $this->qty <= 0; } + /** + * Legacy direct-stock adjustment. + * NOTE: this only moves the cached `qty` column. Real stock changes + * should go through WarehouseService so batches + events stay in sync. + */ public function adjustStock(float $delta, ?string $reason = null): void { $this->qty = max(0, (float) $this->qty + $delta); diff --git a/app/Models/Tenant/PartBatch.php b/app/Models/Tenant/PartBatch.php new file mode 100644 index 0000000..8924378 --- /dev/null +++ b/app/Models/Tenant/PartBatch.php @@ -0,0 +1,51 @@ + 'decimal:3', + 'qty_remaining' => 'decimal:3', + 'buy_price' => 'decimal:2', + 'received_at' => 'datetime', + ]; + + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function reservations(): HasMany + { + return $this->hasMany(PartReservation::class, 'batch_id'); + } + + public function isDepleted(): bool + { + return (float) $this->qty_remaining <= 0; + } +} diff --git a/app/Models/Tenant/PartReservation.php b/app/Models/Tenant/PartReservation.php new file mode 100644 index 0000000..9d4f5fc --- /dev/null +++ b/app/Models/Tenant/PartReservation.php @@ -0,0 +1,53 @@ + 'decimal:3', + 'reserved_at' => 'datetime', + 'consumed_at' => 'datetime', + ]; + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + public function workOrderPart(): BelongsTo + { + return $this->belongsTo(WorkOrderPart::class); + } + + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } + + public function batch(): BelongsTo + { + return $this->belongsTo(PartBatch::class, 'batch_id'); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } +} diff --git a/app/Models/Tenant/Warehouse.php b/app/Models/Tenant/Warehouse.php new file mode 100644 index 0000000..4e8b900 --- /dev/null +++ b/app/Models/Tenant/Warehouse.php @@ -0,0 +1,32 @@ + 'boolean', + 'is_active' => 'boolean', + ]; + + public function batches(): HasMany + { + return $this->hasMany(PartBatch::class); + } + + public function events(): HasMany + { + return $this->hasMany(WarehouseEvent::class); + } +} diff --git a/app/Models/Tenant/WarehouseEvent.php b/app/Models/Tenant/WarehouseEvent.php new file mode 100644 index 0000000..502816b --- /dev/null +++ b/app/Models/Tenant/WarehouseEvent.php @@ -0,0 +1,63 @@ + 'Stoc inițial', + 'receipt' => 'Recepție', + 'issue' => 'Consum', + 'transfer_out' => 'Transfer (ieșire)', + 'transfer_in' => 'Transfer (intrare)', + 'adjustment' => 'Ajustare', + 'write_off' => 'Casare', + 'return' => 'Retur', + ]; + + protected $fillable = [ + 'company_id', 'part_id', 'batch_id', 'warehouse_id', + 'type', 'qty_delta', 'unit_cost', + 'ref_type', 'ref_id', 'user_id', + 'occurred_at', 'notes', + ]; + + protected $casts = [ + 'qty_delta' => 'decimal:3', + 'unit_cost' => 'decimal:2', + 'occurred_at' => 'datetime', + ]; + + public $timestamps = true; + + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } + + public function batch(): BelongsTo + { + return $this->belongsTo(PartBatch::class, 'batch_id'); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function ref(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index e8d9fe1..3d842db 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -141,6 +141,20 @@ class WorkOrder extends Model implements HasMedia app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo); } + // Warehouse lifecycle: status=done → consume reservations into issues; + // status=cancelled → release reservations. + if ($wo->wasChanged('status')) { + $svc = app(\App\Services\Warehouse\WarehouseService::class); + if ($wo->status === 'done' && $wo->getOriginal('status') !== 'done') { + $svc->consume($wo); + } + if ($wo->status === 'cancelled' && $wo->getOriginal('status') !== 'cancelled') { + foreach ($wo->parts as $wop) { + $svc->release($wop); + } + } + } + // Broadcast real-time update on any field change (skip if broadcasting=log). if (config('broadcasting.default') !== 'log') { try { diff --git a/app/Models/Tenant/WorkOrderPart.php b/app/Models/Tenant/WorkOrderPart.php index a3a756b..0e3ea98 100644 --- a/app/Models/Tenant/WorkOrderPart.php +++ b/app/Models/Tenant/WorkOrderPart.php @@ -52,21 +52,37 @@ class WorkOrderPart extends Model $row->total = round($sub * (1 - $disc / 100), 2); }); - // When a part is marked installed, decrement catalog stock once. - static::updating(function (self $row) { - $wasInstalled = $row->getOriginal('status') === 'installed'; - $isInstalled = $row->status === 'installed'; - if (! $wasInstalled && $isInstalled && $row->part_id) { - $part = Part::find($row->part_id); - $part?->adjustStock(-(float) $row->qty); + // Reserve batches as soon as a catalog-linked part line is created. + // Reservations don't reduce on-hand qty, only block other reservations. + static::created(function (self $row) { + if ($row->part_id) { + try { + app(\App\Services\Warehouse\WarehouseService::class)->reserve($row); + } catch (\App\Services\Warehouse\InsufficientStockException $e) { + \Illuminate\Support\Facades\Log::warning('WO part reservation skipped: ' . $e->getMessage()); + } } - // If reverting from installed → restore stock - if ($wasInstalled && ! $isInstalled && $row->part_id) { - $part = Part::find($row->part_id); - $part?->adjustStock((float) $row->qty); + }); + + // If qty / part link changes, release old reservation and re-reserve. + static::updated(function (self $row) { + if ($row->wasChanged(['qty', 'part_id'])) { + $svc = app(\App\Services\Warehouse\WarehouseService::class); + $svc->release($row); + if ($row->part_id) { + try { + $svc->reserve($row); + } catch (\App\Services\Warehouse\InsufficientStockException $e) { + \Illuminate\Support\Facades\Log::warning('WO part re-reservation skipped: ' . $e->getMessage()); + } + } } }); + static::deleted(function (self $row) { + app(\App\Services\Warehouse\WarehouseService::class)->release($row); + }); + static::saved(fn (self $row) => $row->workOrder?->recalcTotal()); static::deleted(fn (self $row) => $row->workOrder?->recalcTotal()); } diff --git a/app/Services/Warehouse/InsufficientStockException.php b/app/Services/Warehouse/InsufficientStockException.php new file mode 100644 index 0000000..dcb9e15 --- /dev/null +++ b/app/Services/Warehouse/InsufficientStockException.php @@ -0,0 +1,17 @@ +findOrFail($companyId); + if ($company->default_warehouse_id) { + return Warehouse::findOrFail($company->default_warehouse_id); + } + // Lazy-create a default warehouse if missing (e.g. tenant created + // before warehouse migration). This makes the service self-healing. + $wh = Warehouse::create([ + 'company_id' => $companyId, + 'code' => 'MAIN', + 'name' => 'Depozit principal', + 'is_default' => true, + ]); + $company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly(); + return $wh; + } + + /** + * Receive new stock — creates a batch + receipt event + updates cached qty. + */ + public function receive( + Part $part, + float $qty, + float $buyPrice, + ?Warehouse $warehouse = null, + ?Supplier $supplier = null, + ?string $batchRef = null, + ?Model $ref = null, + ?string $notes = null, + ?Carbon $occurredAt = null, + ): PartBatch { + if ($qty <= 0) { + throw new \InvalidArgumentException('Cantitatea de recepție trebuie să fie pozitivă.'); + } + + $warehouse ??= $this->defaultWarehouse($part->company_id); + $occurredAt ??= now(); + + return DB::transaction(function () use ($part, $qty, $buyPrice, $warehouse, $supplier, $batchRef, $ref, $notes, $occurredAt) { + $batch = PartBatch::create([ + 'company_id' => $part->company_id, + 'part_id' => $part->id, + 'warehouse_id' => $warehouse->id, + 'supplier_id' => $supplier?->id, + 'batch_ref' => $batchRef, + 'qty_in' => $qty, + 'qty_remaining' => $qty, + 'buy_price' => $buyPrice, + 'received_at' => $occurredAt, + 'notes' => $notes, + ]); + + $this->logEvent($part, $batch, $warehouse, 'receipt', $qty, $buyPrice, $ref, $notes, $occurredAt); + + $this->syncPartCachedQty($part); + + return $batch; + }); + } + + /** + * Consume stock FIFO across batches. Writes one event per consumed batch. + * Throws InsufficientStockException if total available < qty. + */ + public function issue( + Part $part, + float $qty, + ?Warehouse $warehouse = null, + ?Model $ref = null, + ?string $notes = null, + ): array { + if ($qty <= 0) { + throw new \InvalidArgumentException('Cantitatea de consum trebuie să fie pozitivă.'); + } + + $warehouse ??= $this->defaultWarehouse($part->company_id); + + return DB::transaction(function () use ($part, $qty, $warehouse, $ref, $notes) { + $available = $this->availableForIssue($part, $warehouse); + if ($available < $qty) { + throw new InsufficientStockException($part->id, $qty, $available); + } + + $remaining = $qty; + $events = []; + + $batches = $this->fifoBatches($part, $warehouse)->lockForUpdate()->get(); + + foreach ($batches as $batch) { + if ($remaining <= 0) break; + + $take = min($remaining, (float) $batch->qty_remaining); + if ($take <= 0) continue; + + $batch->qty_remaining = (float) $batch->qty_remaining - $take; + $batch->save(); + + $events[] = $this->logEvent( + $part, $batch, $warehouse, 'issue', + -$take, (float) $batch->buy_price, $ref, $notes + ); + + $remaining -= $take; + } + + $this->syncPartCachedQty($part); + + return $events; + }); + } + + /** + * Reserve qty from FIFO batches against a WorkOrderPart. Returns reservation rows. + */ + public function reserve(WorkOrderPart $wop): array + { + return DB::transaction(function () use ($wop) { + $part = $wop->part; + if (! $part) return []; // free-text part with no catalog link — skip + + $warehouse = $this->defaultWarehouse($part->company_id); + $qty = (float) $wop->qty; + + $available = $this->availableForReservation($part, $warehouse); + if ($available < $qty) { + throw new InsufficientStockException($part->id, $qty, $available); + } + + $remaining = $qty; + $reservations = []; + $batches = $this->fifoBatchesAvailable($part, $warehouse)->lockForUpdate()->get(); + + foreach ($batches as $batch) { + if ($remaining <= 0) break; + + $reservedOnBatch = (float) PartReservation::where('batch_id', $batch->id) + ->where('status', PartReservation::STATUS_ACTIVE) + ->sum('qty'); + $free = (float) $batch->qty_remaining - $reservedOnBatch; + if ($free <= 0) continue; + + $take = min($remaining, $free); + + $reservations[] = PartReservation::create([ + 'company_id' => $part->company_id, + 'work_order_id' => $wop->work_order_id, + 'work_order_part_id' => $wop->id, + 'part_id' => $part->id, + 'batch_id' => $batch->id, + 'qty' => $take, + 'status' => PartReservation::STATUS_ACTIVE, + 'reserved_at' => now(), + ]); + + $remaining -= $take; + } + + $part->qty_reserved = (float) $part->qty_reserved + ($qty - $remaining); + $part->saveQuietly(); + + return $reservations; + }); + } + + /** + * Release active reservations on a WorkOrderPart (e.g. WO cancelled / line removed). + */ + public function release(WorkOrderPart $wop): int + { + return DB::transaction(function () use ($wop) { + $active = PartReservation::where('work_order_part_id', $wop->id) + ->where('status', PartReservation::STATUS_ACTIVE) + ->lockForUpdate() + ->get(); + + $totalReleased = 0.0; + foreach ($active as $r) { + $r->status = PartReservation::STATUS_RELEASED; + $r->save(); + $totalReleased += (float) $r->qty; + } + + if ($wop->part_id && $totalReleased > 0) { + $part = Part::find($wop->part_id); + if ($part) { + $part->qty_reserved = max(0.0, (float) $part->qty_reserved - $totalReleased); + $part->saveQuietly(); + } + } + + return $active->count(); + }); + } + + /** + * Consume reservations against a closed WO — converts each active reservation + * into an issue event and decrements batch qty_remaining. + */ + public function consume(WorkOrder $wo): int + { + return DB::transaction(function () use ($wo) { + $active = PartReservation::with(['batch', 'part']) + ->where('work_order_id', $wo->id) + ->where('status', PartReservation::STATUS_ACTIVE) + ->lockForUpdate() + ->get(); + + foreach ($active as $r) { + $batch = $r->batch; + if (! $batch) continue; + $take = min((float) $r->qty, (float) $batch->qty_remaining); + if ($take <= 0) continue; + + $batch->qty_remaining = (float) $batch->qty_remaining - $take; + $batch->save(); + + $this->logEvent( + $r->part, $batch, $batch->warehouse, + 'issue', -$take, (float) $batch->buy_price, + $wo, "WO #{$wo->number}" + ); + + $r->status = PartReservation::STATUS_CONSUMED; + $r->consumed_at = now(); + $r->save(); + + if ($r->part) { + $r->part->qty_reserved = max(0.0, (float) $r->part->qty_reserved - (float) $r->qty); + $r->part->saveQuietly(); + } + } + + // Re-sync all touched parts. + $partIds = $active->pluck('part_id')->unique(); + foreach ($partIds as $pid) { + if ($p = Part::find($pid)) $this->syncPartCachedQty($p); + } + + return $active->count(); + }); + } + + /** + * Move stock between warehouses (FIFO from source). Creates one transfer_out + * + one transfer_in batch in the destination warehouse. + */ + public function transfer( + Part $part, + float $qty, + Warehouse $from, + Warehouse $to, + ?string $notes = null, + ): PartBatch { + if ($qty <= 0) throw new \InvalidArgumentException('Cantitatea de transfer trebuie să fie pozitivă.'); + if ($from->id === $to->id) throw new \InvalidArgumentException('Sursa și destinația sunt identice.'); + + return DB::transaction(function () use ($part, $qty, $from, $to, $notes) { + $available = $this->availableForIssue($part, $from); + if ($available < $qty) { + throw new InsufficientStockException($part->id, $qty, $available); + } + + $remaining = $qty; + $totalCost = 0.0; + + $batches = $this->fifoBatches($part, $from)->lockForUpdate()->get(); + foreach ($batches as $batch) { + if ($remaining <= 0) break; + $take = min($remaining, (float) $batch->qty_remaining); + if ($take <= 0) continue; + + $batch->qty_remaining = (float) $batch->qty_remaining - $take; + $batch->save(); + + $totalCost += $take * (float) $batch->buy_price; + + $this->logEvent($part, $batch, $from, 'transfer_out', -$take, (float) $batch->buy_price, null, $notes); + $remaining -= $take; + } + + $avgCost = $qty > 0 ? round($totalCost / $qty, 2) : 0.0; + + $destBatch = PartBatch::create([ + 'company_id' => $part->company_id, + 'part_id' => $part->id, + 'warehouse_id' => $to->id, + 'qty_in' => $qty, + 'qty_remaining' => $qty, + 'buy_price' => $avgCost, + 'received_at' => now(), + 'notes' => $notes ?? "Transfer din {$from->code}", + ]); + + $this->logEvent($part, $destBatch, $to, 'transfer_in', $qty, $avgCost, null, $notes); + + return $destBatch; + }); + } + + /** + * Inventory adjustment (stock-take correction). Positive delta = mystery gain; + * negative = write-off. For losses we consume FIFO; for gains we open a new batch. + */ + public function adjust(Part $part, float $delta, ?Warehouse $warehouse = null, ?string $notes = null): void + { + if (abs($delta) < 0.001) return; + $warehouse ??= $this->defaultWarehouse($part->company_id); + + DB::transaction(function () use ($part, $delta, $warehouse, $notes) { + if ($delta > 0) { + // Use average current cost or last buy_price as cost for the gain batch. + $cost = (float) ($part->batches()->latest('received_at')->value('buy_price') ?? $part->buy_price); + $batch = PartBatch::create([ + 'company_id' => $part->company_id, + 'part_id' => $part->id, + 'warehouse_id' => $warehouse->id, + 'qty_in' => $delta, + 'qty_remaining' => $delta, + 'buy_price' => $cost, + 'received_at' => now(), + 'notes' => $notes ?? 'Ajustare manuală', + ]); + $this->logEvent($part, $batch, $warehouse, 'adjustment', $delta, $cost, null, $notes); + } else { + $remaining = -$delta; + $batches = $this->fifoBatches($part, $warehouse)->lockForUpdate()->get(); + foreach ($batches as $batch) { + if ($remaining <= 0) break; + $take = min($remaining, (float) $batch->qty_remaining); + if ($take <= 0) continue; + $batch->qty_remaining = (float) $batch->qty_remaining - $take; + $batch->save(); + $this->logEvent($part, $batch, $warehouse, 'adjustment', -$take, (float) $batch->buy_price, null, $notes); + $remaining -= $take; + } + } + + $this->syncPartCachedQty($part); + }); + } + + // ─── Internals ───────────────────────────────────────────────── + + /** + * Available qty for FIFO issue ignoring reservations + * (caller decides whether reservations matter). + */ + public function availableForIssue(Part $part, Warehouse $warehouse): float + { + return (float) PartBatch::where('part_id', $part->id) + ->where('warehouse_id', $warehouse->id) + ->sum('qty_remaining'); + } + + /** + * Available for new reservation = on_hand − active_reservations. + */ + public function availableForReservation(Part $part, Warehouse $warehouse): float + { + $onHand = $this->availableForIssue($part, $warehouse); + + $reserved = (float) PartReservation::where('part_id', $part->id) + ->where('status', PartReservation::STATUS_ACTIVE) + ->whereHas('batch', fn ($q) => $q->where('warehouse_id', $warehouse->id)) + ->sum('qty'); + + return max(0.0, $onHand - $reserved); + } + + protected function fifoBatches(Part $part, Warehouse $warehouse) + { + return PartBatch::where('part_id', $part->id) + ->where('warehouse_id', $warehouse->id) + ->where('qty_remaining', '>', 0) + ->orderBy('received_at') + ->orderBy('id'); + } + + protected function fifoBatchesAvailable(Part $part, Warehouse $warehouse) + { + return $this->fifoBatches($part, $warehouse); + } + + protected function logEvent( + Part $part, + PartBatch $batch, + Warehouse $warehouse, + string $type, + float $qtyDelta, + ?float $unitCost, + ?Model $ref, + ?string $notes, + ?Carbon $occurredAt = null, + ): WarehouseEvent { + return WarehouseEvent::create([ + 'company_id' => $part->company_id, + 'part_id' => $part->id, + 'batch_id' => $batch->id, + 'warehouse_id' => $warehouse->id, + 'type' => $type, + 'qty_delta' => $qtyDelta, + 'unit_cost' => $unitCost, + 'ref_type' => $ref ? get_class($ref) : null, + 'ref_id' => $ref?->getKey(), + 'user_id' => auth()->id(), + 'occurred_at' => $occurredAt ?? now(), + 'notes' => $notes, + ]); + } + + /** Keep parts.qty in sync as a cached aggregate across all warehouses. */ + protected function syncPartCachedQty(Part $part): void + { + $total = (float) PartBatch::where('part_id', $part->id)->sum('qty_remaining'); + $part->qty = $total; + $part->saveQuietly(); + } +} diff --git a/database/migrations/2026_05_27_140000_create_warehouse_tables.php b/database/migrations/2026_05_27_140000_create_warehouse_tables.php new file mode 100644 index 0000000..6b1d99f --- /dev/null +++ b/database/migrations/2026_05_27_140000_create_warehouse_tables.php @@ -0,0 +1,163 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('code', 32); + $t->string('name', 120); + $t->string('address')->nullable(); + $t->boolean('is_default')->default(false); + $t->boolean('is_active')->default(true); + $t->timestamps(); + $t->softDeletes(); + + $t->unique(['company_id', 'code']); + $t->index(['company_id', 'is_active']); + }); + + Schema::create('part_batches', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('part_id')->constrained()->cascadeOnDelete(); + $t->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $t->foreignId('supplier_id')->nullable()->constrained()->nullOnDelete(); + $t->string('batch_ref', 64)->nullable(); // PO number / invoice ref + $t->decimal('qty_in', 12, 3); // initial intake + $t->decimal('qty_remaining', 12, 3); // current available + $t->decimal('buy_price', 12, 2); // unit cost for this batch + $t->dateTime('received_at'); + $t->text('notes')->nullable(); + $t->timestamps(); + + // FIFO consume = ORDER BY received_at ASC, id ASC + $t->index(['company_id', 'part_id', 'warehouse_id', 'received_at']); + $t->index(['company_id', 'qty_remaining']); + }); + + Schema::create('warehouse_events', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('part_id')->constrained()->cascadeOnDelete(); + $t->foreignId('batch_id')->nullable()->constrained('part_batches')->nullOnDelete(); + $t->foreignId('warehouse_id')->constrained()->cascadeOnDelete(); + $t->string('type', 24); // opening / receipt / issue / transfer_out / transfer_in / adjustment / write_off / return + $t->decimal('qty_delta', 12, 3); // signed: + intake, − issue + $t->decimal('unit_cost', 12, 2)->nullable(); + $t->nullableMorphs('ref'); // ref_type/ref_id — link la WorkOrder/Purchase/Transfer/Inventory + $t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $t->dateTime('occurred_at'); + $t->text('notes')->nullable(); + $t->timestamps(); + + $t->index(['company_id', 'part_id', 'occurred_at']); + $t->index(['company_id', 'warehouse_id', 'occurred_at']); + $t->index(['company_id', 'type']); + }); + + Schema::create('part_reservations', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('work_order_id')->constrained()->cascadeOnDelete(); + $t->foreignId('work_order_part_id')->nullable()->constrained('wo_parts')->cascadeOnDelete(); + $t->foreignId('part_id')->constrained()->cascadeOnDelete(); + $t->foreignId('batch_id')->constrained('part_batches')->cascadeOnDelete(); + $t->decimal('qty', 12, 3); + $t->string('status', 16)->default('active'); // active / consumed / released + $t->dateTime('reserved_at'); + $t->dateTime('consumed_at')->nullable(); + $t->timestamps(); + + $t->index(['company_id', 'work_order_id']); + $t->index(['company_id', 'part_id', 'status']); + $t->index(['company_id', 'batch_id', 'status']); + }); + + Schema::table('companies', function (Blueprint $t) { + $t->foreignId('default_warehouse_id')->nullable()->after('plan_id') + ->constrained('warehouses')->nullOnDelete(); + }); + + Schema::table('parts', function (Blueprint $t) { + $t->decimal('qty_reserved', 12, 3)->default(0)->after('qty'); + }); + + // ── Backfill ────────────────────────────────────────────────── + // For each existing company: create 1 default warehouse + open + // one batch per part with the current parts.qty as opening stock. + $now = now(); + foreach (Company::withoutGlobalScopes()->cursor() as $company) { + $whId = DB::table('warehouses')->insertGetId([ + 'company_id' => $company->id, + 'code' => 'MAIN', + 'name' => 'Depozit principal', + 'is_default' => true, + 'is_active' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('companies')->where('id', $company->id) + ->update(['default_warehouse_id' => $whId]); + + $parts = DB::table('parts') + ->where('company_id', $company->id) + ->whereNull('deleted_at') + ->where('qty', '>', 0) + ->get(); + + foreach ($parts as $p) { + $batchId = DB::table('part_batches')->insertGetId([ + 'company_id' => $company->id, + 'part_id' => $p->id, + 'warehouse_id' => $whId, + 'qty_in' => $p->qty, + 'qty_remaining' => $p->qty, + 'buy_price' => $p->buy_price, + 'received_at' => $p->created_at ?: $now, + 'notes' => 'Stoc inițial (migrare)', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('warehouse_events')->insert([ + 'company_id' => $company->id, + 'part_id' => $p->id, + 'batch_id' => $batchId, + 'warehouse_id' => $whId, + 'type' => 'opening', + 'qty_delta' => $p->qty, + 'unit_cost' => $p->buy_price, + 'occurred_at' => $p->created_at ?: $now, + 'notes' => 'Stoc inițial la migrare', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down(): void + { + Schema::table('parts', function (Blueprint $t) { + $t->dropColumn('qty_reserved'); + }); + Schema::table('companies', function (Blueprint $t) { + $t->dropForeign(['default_warehouse_id']); + $t->dropColumn('default_warehouse_id'); + }); + Schema::dropIfExists('part_reservations'); + Schema::dropIfExists('warehouse_events'); + Schema::dropIfExists('part_batches'); + Schema::dropIfExists('warehouses'); + } +}; diff --git a/tests/Feature/WarehouseFifoTest.php b/tests/Feature/WarehouseFifoTest.php new file mode 100644 index 0000000..c5d6aa9 --- /dev/null +++ b/tests/Feature/WarehouseFifoTest.php @@ -0,0 +1,247 @@ +svc = app(WarehouseService::class); + } + + public function test_receive_creates_batch_and_event(): void + { + $part = $this->makePart('alpha'); + + $batch = $this->svc->receive($part, 10, 50.0); + + $this->assertEquals(10.0, (float) $batch->qty_in); + $this->assertEquals(10.0, (float) $batch->qty_remaining); + $this->assertEquals(50.0, (float) $batch->buy_price); + + $events = WarehouseEvent::where('part_id', $part->id)->get(); + $this->assertCount(1, $events); + $this->assertEquals('receipt', $events[0]->type); + $this->assertEquals(10.0, (float) $events[0]->qty_delta); + + $part->refresh(); + $this->assertEquals(10.0, (float) $part->qty); + } + + public function test_issue_consumes_oldest_batches_first(): void + { + $part = $this->makePart('beta'); + + // 3 batches received in chronological order with different prices. + $b1 = $this->svc->receive($part, 5, 10.0, occurredAt: now()->subDays(3)); + $b2 = $this->svc->receive($part, 5, 20.0, occurredAt: now()->subDays(2)); + $b3 = $this->svc->receive($part, 5, 30.0, occurredAt: now()->subDays(1)); + + // Issue 7 → should consume all of b1 (5) + 2 from b2. + $events = $this->svc->issue($part, 7); + + $b1->refresh(); $b2->refresh(); $b3->refresh(); + $this->assertEquals(0.0, (float) $b1->qty_remaining, 'oldest batch should be fully consumed'); + $this->assertEquals(3.0, (float) $b2->qty_remaining, 'second batch should have 3 left'); + $this->assertEquals(5.0, (float) $b3->qty_remaining, 'newest batch untouched'); + + // Two events written. + $this->assertCount(2, $events); + } + + public function test_issue_throws_when_insufficient_stock(): void + { + $part = $this->makePart('gamma'); + $this->svc->receive($part, 3, 10.0); + + $this->expectException(InsufficientStockException::class); + $this->svc->issue($part, 5); + } + + public function test_reservation_blocks_other_reservations_but_not_stock(): void + { + $part = $this->makePart('delta'); + $this->svc->receive($part, 10, 25.0); + + $wo = $this->makeWorkOrder($part); + $wop = WorkOrderPart::create([ + 'work_order_id' => $wo->id, + 'part_id' => $part->id, + 'name' => $part->name, + 'qty' => 6, + 'unit' => 'buc', + 'buy_price' => 25, + 'sell_price' => 40, + ]); + + $part->refresh(); + $this->assertEquals(10.0, (float) $part->qty, 'on hand stays the same — reservation does not deplete'); + $this->assertEquals(6.0, (float) $part->qty_reserved, 'qty_reserved tracks reservation'); + + $this->assertCount(1, PartReservation::where('work_order_part_id', $wop->id)->where('status', 'active')->get()); + } + + public function test_wo_done_consumes_reservations(): void + { + $part = $this->makePart('epsilon'); + $this->svc->receive($part, 10, 25.0); + + $wo = $this->makeWorkOrder($part); + WorkOrderPart::create([ + 'work_order_id' => $wo->id, + 'part_id' => $part->id, + 'name' => $part->name, + 'qty' => 4, + 'unit' => 'buc', + 'buy_price' => 25, + 'sell_price' => 40, + ]); + + $wo->status = 'done'; + $wo->save(); + + $part->refresh(); + $this->assertEquals(6.0, (float) $part->qty, 'on hand decreased by 4'); + $this->assertEquals(0.0, (float) $part->qty_reserved, 'reservation consumed'); + + $r = PartReservation::where('work_order_id', $wo->id)->first(); + $this->assertEquals('consumed', $r->status); + } + + public function test_wo_cancelled_releases_reservations(): void + { + $part = $this->makePart('zeta'); + $this->svc->receive($part, 10, 25.0); + + $wo = $this->makeWorkOrder($part); + WorkOrderPart::create([ + 'work_order_id' => $wo->id, + 'part_id' => $part->id, + 'name' => $part->name, + 'qty' => 4, + 'unit' => 'buc', + 'buy_price' => 25, + 'sell_price' => 40, + ]); + + $wo->status = 'cancelled'; + $wo->save(); + + $part->refresh(); + $this->assertEquals(10.0, (float) $part->qty, 'on hand untouched after cancel'); + $this->assertEquals(0.0, (float) $part->qty_reserved, 'reservation released'); + + $r = PartReservation::where('work_order_id', $wo->id)->first(); + $this->assertEquals('released', $r->status); + } + + public function test_batches_are_tenant_isolated(): void + { + $partA = $this->makePart('aa'); + $this->svc->receive($partA, 5, 10.0); + + $companyB = $this->makeCompany('bb'); + app(TenantManager::class)->setCurrent($companyB); + + $this->assertEquals(0, PartBatch::count(), 'tenant B sees no batches from tenant A'); + } + + public function test_transfer_moves_qty_between_warehouses(): void + { + $part = $this->makePart('eta'); + $main = Warehouse::where('is_default', true)->first(); + $secondary = Warehouse::create(['code' => 'BR1', 'name' => 'Sucursală', 'is_active' => true]); + + $this->svc->receive($part, 10, 15.0, $main); + $this->svc->transfer($part, 4, $main, $secondary); + + $this->assertEquals(6.0, $this->svc->availableForIssue($part, $main)); + $this->assertEquals(4.0, $this->svc->availableForIssue($part, $secondary)); + + $events = WarehouseEvent::where('part_id', $part->id)->get(); + $this->assertEqualsCanonicalizing( + ['receipt', 'transfer_out', 'transfer_in'], + $events->pluck('type')->sort()->values()->all() === ['receipt', 'transfer_in', 'transfer_out'] + ? ['receipt', 'transfer_in', 'transfer_out'] + : $events->pluck('type')->sort()->values()->all() + ); + } + + private function makePart(string $companySlug): Part + { + $company = $this->makeCompany($companySlug); + app(TenantManager::class)->setCurrent($company); + + // Ensure default warehouse exists (migration creates one for existing + // companies; new test companies need it created on-the-fly). + $wh = Warehouse::create([ + 'code' => 'MAIN', 'name' => 'Depozit principal', + 'is_default' => true, 'is_active' => true, + ]); + $company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly(); + + return Part::create([ + 'name' => 'Filtru ulei', + 'unit' => 'buc', + 'qty' => 0, + 'buy_price' => 0, + 'sell_price' => 0, + 'is_active' => true, + ]); + } + + private function makeCompany(string $slug): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], [ + 'name' => 'Test', 'price' => 0, 'features' => [], + ]); + return Company::create([ + 'plan_id' => $plan->id, + 'slug' => $slug . '-' . uniqid(), + 'name' => ucfirst($slug), + 'status' => 'active', + ]); + } + + private function makeWorkOrder(Part $part): WorkOrder + { + $client = Client::create([ + 'name' => 'WOClient', 'phone' => '+37399' . random_int(100000, 999999), + 'type' => 'individual', 'status' => 'active', + ]); + $vehicle = Vehicle::create([ + 'client_id' => $client->id, + 'make' => 'X', 'model' => 'Y', + 'plate' => 'WO' . random_int(100, 999), + ]); + return WorkOrder::create([ + 'number' => WorkOrder::generateNumber($part->company_id), + 'client_id' => $client->id, + 'vehicle_id' => $vehicle->id, + 'opened_at' => now(), + 'status' => 'in_work', + ]); + } +}