From 1c21602b7e123d772456806aef86a464658bbc45 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Tue, 24 Feb 2026 23:30:47 -0600 Subject: [PATCH] feat: agregar soporte para seriales transferidos y salidas en el control de movimientos de inventario --- .../App/InventoryMovementController.php | 6 +- .../App/InventoryMovementUpdateRequest.php | 1 + app/Models/InventoryMovement.php | 10 + app/Models/InventorySerial.php | 33 +++ app/Services/InventoryMovementService.php | 233 ++++++++++++++---- ...movement_id_to_inventory_serials_table.php | 27 ++ ...movement_id_to_inventory_serials_table.php | 32 +++ 7 files changed, 292 insertions(+), 50 deletions(-) create mode 100644 database/migrations/2026_02_24_221213_add_transfer_movement_id_to_inventory_serials_table.php create mode 100644 database/migrations/2026_02_24_224729_add_exit_movement_id_to_inventory_serials_table.php diff --git a/app/Http/Controllers/App/InventoryMovementController.php b/app/Http/Controllers/App/InventoryMovementController.php index de9997a..f936513 100644 --- a/app/Http/Controllers/App/InventoryMovementController.php +++ b/app/Http/Controllers/App/InventoryMovementController.php @@ -69,7 +69,7 @@ public function index(Request $request) */ public function show(int $id) { - $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials']) + $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials', 'transferredSerials', 'exitedSerials']) ->find($id); if (!$movement) { @@ -128,7 +128,7 @@ public function entry(InventoryEntryRequest $request) return ApiResponse::CREATED->response([ 'message' => 'Entrada registrada correctamente', - 'movement' => $movement->load(['inventory', 'warehouseTo']), + 'movement' => $movement->load(['inventory', 'warehouseTo', 'supplier']), ]); } @@ -160,7 +160,7 @@ public function exit(InventoryExitRequest $request) return ApiResponse::CREATED->response([ 'message' => 'Salida registrada correctamente', - 'movement' => $movement->load(['inventory', 'warehouseFrom']), + 'movement' => $movement->load(['inventory', 'warehouseFrom', 'exitedSerials']), ]); } } catch (\Exception $e) { diff --git a/app/Http/Requests/App/InventoryMovementUpdateRequest.php b/app/Http/Requests/App/InventoryMovementUpdateRequest.php index 59589ac..9f0c19b 100644 --- a/app/Http/Requests/App/InventoryMovementUpdateRequest.php +++ b/app/Http/Requests/App/InventoryMovementUpdateRequest.php @@ -22,6 +22,7 @@ public function rules(): array 'notes' => 'sometimes|nullable|string|max:500', 'serial_numbers' => ['nullable', 'array'], 'serial_numbers.*' => ['string', 'max:255'], + 'supplier_id' => 'sometimes|nullable|exists:suppliers,id', 'unit_of_measure_id' => 'sometimes|nullable|exists:units_of_measurement,id', ]; } diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index 9bca15b..3c5b132 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -76,6 +76,16 @@ public function serials() return $this->hasMany(InventorySerial::class, 'movement_id'); } + public function transferredSerials() + { + return $this->hasMany(InventorySerial::class, 'transfer_movement_id'); + } + + public function exitedSerials() + { + return $this->hasMany(InventorySerial::class, 'exit_movement_id'); + } + // Relación polimórfica para la referencia public function reference() { diff --git a/app/Models/InventorySerial.php b/app/Models/InventorySerial.php index 2ed91ae..fbac27a 100644 --- a/app/Models/InventorySerial.php +++ b/app/Models/InventorySerial.php @@ -14,6 +14,8 @@ class InventorySerial extends Model 'inventory_id', 'warehouse_id', 'movement_id', + 'transfer_movement_id', + 'exit_movement_id', 'serial_number', 'status', 'sale_detail_id', @@ -40,6 +42,37 @@ public function movement() return $this->belongsTo(InventoryMovement::class); } + public function transferMovement() + { + return $this->belongsTo(InventoryMovement::class, 'transfer_movement_id'); + } + + public function exitMovement() + { + return $this->belongsTo(InventoryMovement::class, 'exit_movement_id'); + } + + public function markAsExited(int $exitMovementId): void + { + $this->update([ + 'status' => 'salida', + 'exit_movement_id' => $exitMovementId, + ]); + } + + public function restoreFromExit(): void + { + $this->update([ + 'status' => 'disponible', + 'exit_movement_id' => null, + ]); + } + + public function isExited(): bool + { + return $this->status === 'salida'; + } + public function saleDetail() { return $this->belongsTo(SaleDetail::class); diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 4a110df..0d95461 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -309,10 +309,26 @@ public function bulkExit(array $data): array } } - // Eliminar los seriales (salida definitiva) + // Registrar movimiento PRIMERO para tener el ID disponible + $movement = InventoryMovement::create([ + 'inventory_id' => $inventory->id, + 'warehouse_from_id' => $warehouse->id, + 'warehouse_to_id' => null, + 'movement_type' => 'exit', + 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + + // Marcar seriales como salida (no eliminar, para conservar historial) InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) - ->delete(); + ->update([ + 'status' => 'salida', + 'exit_movement_id' => $movement->id, + ]); // Sincronizar stock desde seriales $inventory->syncStock(); @@ -320,20 +336,20 @@ public function bulkExit(array $data): array // Sin seriales, validar y decrementar stock manualmente $this->validateStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); - } - // Registrar movimiento - $movement = InventoryMovement::create([ - 'inventory_id' => $inventory->id, - 'warehouse_from_id' => $warehouse->id, - 'warehouse_to_id' => null, - 'movement_type' => 'exit', - 'quantity' => $quantity, - 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, - 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, - 'user_id' => auth()->id(), - 'notes' => $data['notes'] ?? null, - ]); + // Registrar movimiento + $movement = InventoryMovement::create([ + 'inventory_id' => $inventory->id, + 'warehouse_from_id' => $warehouse->id, + 'warehouse_to_id' => null, + 'movement_type' => 'exit', + 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + } $movements[] = $movement->load(['inventory', 'warehouseFrom']); } @@ -408,10 +424,26 @@ public function bulkTransfer(array $data): array } } - // Actualizar warehouse_id de los seriales seleccionados + // Registrar movimiento PRIMERO para tener el ID disponible + $movement = InventoryMovement::create([ + 'inventory_id' => $inventory->id, + 'warehouse_from_id' => $warehouseFrom->id, + 'warehouse_to_id' => $warehouseTo->id, + 'movement_type' => 'transfer', + 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + + // Actualizar warehouse_id y transfer_movement_id de los seriales seleccionados InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) - ->update(['warehouse_id' => $warehouseTo->id]); + ->update([ + 'warehouse_id' => $warehouseTo->id, + 'transfer_movement_id' => $movement->id, + ]); // Sincronizar stock desde seriales (actualiza ambos almacenes) $inventory->syncStock(); @@ -420,20 +452,20 @@ public function bulkTransfer(array $data): array $this->validateStock($inventory->id, $warehouseFrom->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); - } - // Registrar movimiento - $movement = InventoryMovement::create([ - 'inventory_id' => $inventory->id, - 'warehouse_from_id' => $warehouseFrom->id, - 'warehouse_to_id' => $warehouseTo->id, - 'movement_type' => 'transfer', - 'quantity' => $quantity, - 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, - 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, - 'user_id' => auth()->id(), - 'notes' => $data['notes'] ?? null, - ]); + // Registrar movimiento + $movement = InventoryMovement::create([ + 'inventory_id' => $inventory->id, + 'warehouse_from_id' => $warehouseFrom->id, + 'warehouse_to_id' => $warehouseTo->id, + 'movement_type' => 'transfer', + 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + } $movements[] = $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']); } @@ -531,20 +563,38 @@ public function exit(array $data): InventoryMovement } } - // Eliminar los seriales (salida definitiva) + // Registrar movimiento PRIMERO para tener el ID disponible + $movement = InventoryMovement::create([ + 'inventory_id' => $inventory->id, + 'warehouse_from_id' => $warehouse->id, + 'warehouse_to_id' => null, + 'movement_type' => 'exit', + 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + + // Marcar seriales como salida (no eliminar, para conservar historial) InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) - ->delete(); + ->update([ + 'status' => 'salida', + 'exit_movement_id' => $movement->id, + ]); // Sincronizar stock desde seriales $inventory->syncStock(); + + return $movement; } else { // **SIN SERIALES**: Validar y decrementar stock manualmente $this->validateStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); } - // Registrar movimiento + // Registrar movimiento (solo para caso sin seriales) return InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouse->id, @@ -625,13 +675,31 @@ public function transfer(array $data): InventoryMovement } } - // Actualizar warehouse_id de los seriales seleccionados + // Registrar movimiento PRIMERO para tener el ID disponible + $movement = InventoryMovement::create([ + 'inventory_id' => $inventory->id, + 'warehouse_from_id' => $warehouseFrom->id, + 'warehouse_to_id' => $warehouseTo->id, + 'movement_type' => 'transfer', + 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + + // Actualizar warehouse_id y transfer_movement_id de los seriales seleccionados InventorySerial::whereIn('serial_number', $serialNumbers) ->where('inventory_id', $inventory->id) - ->update(['warehouse_id' => $warehouseTo->id]); + ->update([ + 'warehouse_id' => $warehouseTo->id, + 'transfer_movement_id' => $movement->id, + ]); // Sincronizar stock desde seriales (actualiza ambos almacenes) $inventory->syncStock(); + + return $movement; } else { // Sin seriales, validar y actualizar stock manualmente $this->validateStock($inventory->id, $warehouseFrom->id, $quantity); @@ -639,7 +707,7 @@ public function transfer(array $data): InventoryMovement $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); } - // Registrar movimiento + // Registrar movimiento (solo para caso sin seriales) return InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => $warehouseFrom->id, @@ -775,7 +843,7 @@ public function updateMovement(int $movementId, array $data): InventoryMovement UserEvent::report(model: $movement, event: 'updated', key: 'movement_type'); - return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'serials']); + return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials', 'transferredSerials', 'exitedSerials']); }); } @@ -815,6 +883,7 @@ protected function prepareUpdateData(InventoryMovement $movement, array $data): $updateData['unit_cost_original'] = $usesEquivalence ? $inputUnitCost : null; $updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference; $updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; + $updateData['supplier_id'] = array_key_exists('supplier_id', $data) ? $data['supplier_id'] : $movement->supplier_id; } elseif ($movement->movement_type === 'exit') { $updateData['warehouse_from_id'] = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; } elseif ($movement->movement_type === 'transfer') { @@ -834,12 +903,26 @@ protected function revertMovement(InventoryMovement $movement): void // Las entradas NO se revierten aquí, usan lógica delta en applyEntryDeltaUpdate() case 'exit': - // Revertir salida: devolver al almacén origen - $this->updateWarehouseStock( - $movement->inventory_id, - $movement->warehouse_from_id, - $movement->quantity - ); + $inventory = $movement->inventory; + $inventory->load('unitOfMeasure'); + $usesSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; + + if ($usesSerials) { + // Revertir seriales: restaurar a disponible y limpiar exit_movement_id + InventorySerial::where('exit_movement_id', $movement->id) + ->update([ + 'status' => 'disponible', + 'exit_movement_id' => null, + ]); + $inventory->syncStock(); + } else { + // Revertir salida sin seriales: devolver stock al almacén origen + $this->updateWarehouseStock( + $movement->inventory_id, + $movement->warehouse_from_id, + $movement->quantity + ); + } break; case 'transfer': @@ -950,14 +1033,58 @@ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $dat */ protected function applyExitUpdate(InventoryMovement $movement, array $data): void { + $inventory = $movement->inventory; + $inventory->load('unitOfMeasure'); + $usesSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; $warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $quantity = $data['quantity'] ?? $movement->quantity; - // Validar stock disponible - $this->validateStock($movement->inventory_id, $warehouseFromId, $quantity); + if ($usesSerials) { + $serialNumbers = $data['serial_numbers'] ?? []; - // Aplicar nueva salida - $this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity); + if (empty($serialNumbers)) { + throw new \Exception('Debe proporcionar los números de serie para actualizar esta salida.'); + } + + $serialNumbers = array_values(array_filter(array_map('trim', $serialNumbers))); + + if (count($serialNumbers) != $quantity) { + throw new \Exception('La cantidad de seriales no coincide con la cantidad del movimiento.'); + } + + // Validar que los seriales existan, estén disponibles y en el almacén correcto + foreach ($serialNumbers as $serialNumber) { + $serial = InventorySerial::where('serial_number', $serialNumber) + ->where('inventory_id', $inventory->id) + ->first(); + + if (! $serial) { + throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto."); + } + if ($serial->status !== 'disponible') { + throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status})."); + } + if ($serial->warehouse_id !== $warehouseFromId) { + throw new \Exception("El serial '{$serialNumber}' no está en el almacén seleccionado."); + } + } + + // Marcar los nuevos seriales como salida + InventorySerial::whereIn('serial_number', $serialNumbers) + ->where('inventory_id', $inventory->id) + ->update([ + 'status' => 'salida', + 'exit_movement_id' => $movement->id, + ]); + + $inventory->syncStock(); + } else { + // Validar stock disponible + $this->validateStock($movement->inventory_id, $warehouseFromId, $quantity); + + // Aplicar nueva salida + $this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity); + } } /** @@ -1082,6 +1209,18 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat "No se pueden quitar los seriales [{$unavailableNumbers}] porque ya fueron vendidos o devueltos." ); } + + // Validar que los seriales a eliminar no hayan sido traspasados a otro almacén + $transferred = $currentSerials + ->whereIn('serial_number', $serialesToRemove) + ->where('warehouse_id', '!=', $movement->warehouse_to_id); + + if ($transferred->isNotEmpty()) { + $transferredNumbers = $transferred->pluck('serial_number')->implode(', '); + throw new \Exception( + "No se pueden quitar los seriales [{$transferredNumbers}] porque fueron traspasados a otro almacén." + ); + } } // Validar que los nuevos seriales no existan ya en el sistema diff --git a/database/migrations/2026_02_24_221213_add_transfer_movement_id_to_inventory_serials_table.php b/database/migrations/2026_02_24_221213_add_transfer_movement_id_to_inventory_serials_table.php new file mode 100644 index 0000000..3dce0b6 --- /dev/null +++ b/database/migrations/2026_02_24_221213_add_transfer_movement_id_to_inventory_serials_table.php @@ -0,0 +1,27 @@ +foreignId('transfer_movement_id') + ->nullable() + ->after('movement_id') + ->constrained('inventory_movements') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('inventory_serials', function (Blueprint $table) { + $table->dropForeign(['transfer_movement_id']); + $table->dropColumn('transfer_movement_id'); + }); + } +}; diff --git a/database/migrations/2026_02_24_224729_add_exit_movement_id_to_inventory_serials_table.php b/database/migrations/2026_02_24_224729_add_exit_movement_id_to_inventory_serials_table.php new file mode 100644 index 0000000..ba77b27 --- /dev/null +++ b/database/migrations/2026_02_24_224729_add_exit_movement_id_to_inventory_serials_table.php @@ -0,0 +1,32 @@ +foreignId('exit_movement_id') + ->nullable() + ->after('transfer_movement_id') + ->constrained('inventory_movements') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('inventory_serials', function (Blueprint $table) { + $table->dropForeign(['exit_movement_id']); + $table->dropColumn('exit_movement_id'); + }); + + DB::statement("ALTER TABLE inventory_serials MODIFY COLUMN status ENUM('disponible', 'vendido', 'devuelto') NOT NULL DEFAULT 'disponible'"); + } +};