feat: agregar soporte para seriales transferidos y salidas en el control de movimientos de inventario

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-24 23:30:47 -06:00
parent e5e3412fea
commit 1c21602b7e
7 changed files with 292 additions and 50 deletions

View File

@ -69,7 +69,7 @@ public function index(Request $request)
*/ */
public function show(int $id) 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); ->find($id);
if (!$movement) { if (!$movement) {
@ -128,7 +128,7 @@ public function entry(InventoryEntryRequest $request)
return ApiResponse::CREATED->response([ return ApiResponse::CREATED->response([
'message' => 'Entrada registrada correctamente', '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([ return ApiResponse::CREATED->response([
'message' => 'Salida registrada correctamente', 'message' => 'Salida registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom']), 'movement' => $movement->load(['inventory', 'warehouseFrom', 'exitedSerials']),
]); ]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@ -22,6 +22,7 @@ public function rules(): array
'notes' => 'sometimes|nullable|string|max:500', 'notes' => 'sometimes|nullable|string|max:500',
'serial_numbers' => ['nullable', 'array'], 'serial_numbers' => ['nullable', 'array'],
'serial_numbers.*' => ['string', 'max:255'], 'serial_numbers.*' => ['string', 'max:255'],
'supplier_id' => 'sometimes|nullable|exists:suppliers,id',
'unit_of_measure_id' => 'sometimes|nullable|exists:units_of_measurement,id', 'unit_of_measure_id' => 'sometimes|nullable|exists:units_of_measurement,id',
]; ];
} }

View File

@ -76,6 +76,16 @@ public function serials()
return $this->hasMany(InventorySerial::class, 'movement_id'); 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 // Relación polimórfica para la referencia
public function reference() public function reference()
{ {

View File

@ -14,6 +14,8 @@ class InventorySerial extends Model
'inventory_id', 'inventory_id',
'warehouse_id', 'warehouse_id',
'movement_id', 'movement_id',
'transfer_movement_id',
'exit_movement_id',
'serial_number', 'serial_number',
'status', 'status',
'sale_detail_id', 'sale_detail_id',
@ -40,6 +42,37 @@ public function movement()
return $this->belongsTo(InventoryMovement::class); 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() public function saleDetail()
{ {
return $this->belongsTo(SaleDetail::class); return $this->belongsTo(SaleDetail::class);

View File

@ -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) InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id) ->where('inventory_id', $inventory->id)
->delete(); ->update([
'status' => 'salida',
'exit_movement_id' => $movement->id,
]);
// Sincronizar stock desde seriales // Sincronizar stock desde seriales
$inventory->syncStock(); $inventory->syncStock();
@ -320,20 +336,20 @@ public function bulkExit(array $data): array
// Sin seriales, validar y decrementar stock manualmente // Sin seriales, validar y decrementar stock manualmente
$this->validateStock($inventory->id, $warehouse->id, $quantity); $this->validateStock($inventory->id, $warehouse->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
}
// Registrar movimiento // Registrar movimiento
$movement = InventoryMovement::create([ $movement = InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouse->id, 'warehouse_from_id' => $warehouse->id,
'warehouse_to_id' => null, 'warehouse_to_id' => null,
'movement_type' => 'exit', 'movement_type' => 'exit',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
}
$movements[] = $movement->load(['inventory', 'warehouseFrom']); $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) InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id) ->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) // Sincronizar stock desde seriales (actualiza ambos almacenes)
$inventory->syncStock(); $inventory->syncStock();
@ -420,20 +452,20 @@ public function bulkTransfer(array $data): array
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity); $this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); $this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
}
// Registrar movimiento // Registrar movimiento
$movement = InventoryMovement::create([ $movement = InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouseFrom->id, 'warehouse_from_id' => $warehouseFrom->id,
'warehouse_to_id' => $warehouseTo->id, 'warehouse_to_id' => $warehouseTo->id,
'movement_type' => 'transfer', 'movement_type' => 'transfer',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
}
$movements[] = $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']); $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) InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id) ->where('inventory_id', $inventory->id)
->delete(); ->update([
'status' => 'salida',
'exit_movement_id' => $movement->id,
]);
// Sincronizar stock desde seriales // Sincronizar stock desde seriales
$inventory->syncStock(); $inventory->syncStock();
return $movement;
} else { } else {
// **SIN SERIALES**: Validar y decrementar stock manualmente // **SIN SERIALES**: Validar y decrementar stock manualmente
$this->validateStock($inventory->id, $warehouse->id, $quantity); $this->validateStock($inventory->id, $warehouse->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
} }
// Registrar movimiento // Registrar movimiento (solo para caso sin seriales)
return InventoryMovement::create([ return InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouse->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) InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id) ->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) // Sincronizar stock desde seriales (actualiza ambos almacenes)
$inventory->syncStock(); $inventory->syncStock();
return $movement;
} else { } else {
// Sin seriales, validar y actualizar stock manualmente // Sin seriales, validar y actualizar stock manualmente
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity); $this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
@ -639,7 +707,7 @@ public function transfer(array $data): InventoryMovement
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
} }
// Registrar movimiento // Registrar movimiento (solo para caso sin seriales)
return InventoryMovement::create([ return InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouseFrom->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'); 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['unit_cost_original'] = $usesEquivalence ? $inputUnitCost : null;
$updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference; $updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference;
$updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $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') { } elseif ($movement->movement_type === 'exit') {
$updateData['warehouse_from_id'] = $data['warehouse_from_id'] ?? $movement->warehouse_from_id; $updateData['warehouse_from_id'] = $data['warehouse_from_id'] ?? $movement->warehouse_from_id;
} elseif ($movement->movement_type === 'transfer') { } 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() // Las entradas NO se revierten aquí, usan lógica delta en applyEntryDeltaUpdate()
case 'exit': case 'exit':
// Revertir salida: devolver al almacén origen $inventory = $movement->inventory;
$this->updateWarehouseStock( $inventory->load('unitOfMeasure');
$movement->inventory_id, $usesSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals;
$movement->warehouse_from_id,
$movement->quantity 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; break;
case 'transfer': case 'transfer':
@ -950,14 +1033,58 @@ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $dat
*/ */
protected function applyExitUpdate(InventoryMovement $movement, array $data): void 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; $warehouseFromId = $data['warehouse_from_id'] ?? $movement->warehouse_from_id;
$quantity = $data['quantity'] ?? $movement->quantity; $quantity = $data['quantity'] ?? $movement->quantity;
// Validar stock disponible if ($usesSerials) {
$this->validateStock($movement->inventory_id, $warehouseFromId, $quantity); $serialNumbers = $data['serial_numbers'] ?? [];
// Aplicar nueva salida if (empty($serialNumbers)) {
$this->updateWarehouseStock($movement->inventory_id, $warehouseFromId, -$quantity); 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." "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 // Validar que los nuevos seriales no existan ya en el sistema

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('inventory_serials', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE inventory_serials MODIFY COLUMN status ENUM('disponible', 'vendido', 'devuelto', 'salida') NOT NULL DEFAULT 'disponible'");
Schema::table('inventory_serials', function (Blueprint $table) {
$table->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'");
}
};