feat: agregar soporte para seriales transferidos y salidas en el control de movimientos de inventario
This commit is contained in:
parent
e5e3412fea
commit
1c21602b7e
@ -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) {
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,7 +336,6 @@ 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([
|
||||
@ -334,6 +349,7 @@ public function bulkExit(array $data): array
|
||||
'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,7 +452,6 @@ 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([
|
||||
@ -434,6 +465,7 @@ public function bulkTransfer(array $data): array
|
||||
'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
|
||||
$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,15 +1033,59 @@ 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;
|
||||
|
||||
if ($usesSerials) {
|
||||
$serialNumbers = $data['serial_numbers'] ?? [];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplicar actualización de traspaso
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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'");
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user