feat: agregar gestión de números de serie en movimientos de inventario

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-12 15:47:15 -06:00
parent c1d6f58697
commit da5acc11c5
7 changed files with 220 additions and 48 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']) $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials'])
->find($id); ->find($id);
if (!$movement) { if (!$movement) {

View File

@ -22,6 +22,8 @@ public function rules(): array
'warehouse_to_id' => 'sometimes|nullable|exists:warehouses,id', 'warehouse_to_id' => 'sometimes|nullable|exists:warehouses,id',
'invoice_reference' => 'sometimes|nullable|string|max:255', 'invoice_reference' => 'sometimes|nullable|string|max:255',
'notes' => 'sometimes|nullable|string|max:500', 'notes' => 'sometimes|nullable|string|max:500',
'serial_numbers' => ['nullable', 'array'],
'serial_numbers.*' => ['string', 'max:255'],
]; ];
} }
@ -42,6 +44,7 @@ public function messages(): array
'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen', 'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen',
'notes.max' => 'Las notas no pueden exceder 1000 caracteres', 'notes.max' => 'Las notas no pueden exceder 1000 caracteres',
'invoice_reference.max' => 'La referencia de factura no puede exceder 255 caracteres', 'invoice_reference.max' => 'La referencia de factura no puede exceder 255 caracteres',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
]; ];
} }
} }

View File

@ -48,6 +48,10 @@ public function user() {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function serials() {
return $this->hasMany(InventorySerial::class, 'movement_id');
}
// Relación polimórfica para la referencia // Relación polimórfica para la referencia
public function reference() { public function reference() {
return $this->morphTo(); return $this->morphTo();

View File

@ -13,6 +13,7 @@ class InventorySerial extends Model
protected $fillable = [ protected $fillable = [
'inventory_id', 'inventory_id',
'warehouse_id', 'warehouse_id',
'movement_id',
'serial_number', 'serial_number',
'status', 'status',
'sale_detail_id', 'sale_detail_id',
@ -34,6 +35,11 @@ public function warehouse()
return $this->belongsTo(Warehouse::class); return $this->belongsTo(Warehouse::class);
} }
public function movement()
{
return $this->belongsTo(InventoryMovement::class);
}
public function saleDetail() public function saleDetail()
{ {
return $this->belongsTo(SaleDetail::class); return $this->belongsTo(SaleDetail::class);

View File

@ -68,16 +68,35 @@ public function entry(array $data): InventoryMovement
// Actualizar costo en prices // Actualizar costo en prices
$this->updateProductCost($inventory, $newCost); $this->updateProductCost($inventory, $newCost);
// Solo crear seriales si se proporcionaron // Registrar movimiento PRIMERO
if ($requiresSerials && !empty($serialNumbers)) { $movement = InventoryMovement::create([
foreach ($serialNumbers as $serialNumber) {
InventorySerial::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id,
'movement_type' => 'entry',
'quantity' => $quantity,
'unit_cost' => $unitCost,
'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'] ?? null,
]);
// Crear seriales DESPUÉS del movimiento (para asignar movement_id)
if ($requiresSerials && !empty($serialNumbers)) {
$serials = [];
foreach ($serialNumbers as $serialNumber) {
$serials[] = [
'inventory_id' => $inventory->id,
'movement_id' => $movement->id, // Ahora tenemos el ID
'warehouse_id' => $warehouse->id, 'warehouse_id' => $warehouse->id,
'serial_number' => trim($serialNumber), 'serial_number' => trim($serialNumber),
'status' => 'disponible', 'status' => 'disponible',
]); 'created_at' => now(),
'updated_at' => now(),
];
} }
InventorySerial::insert($serials);
// Activar track_serials si no estaba activo // Activar track_serials si no estaba activo
if (!$inventory->track_serials) { if (!$inventory->track_serials) {
@ -91,19 +110,7 @@ public function entry(array $data): InventoryMovement
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
} }
// Registrar movimiento return $movement;
return InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id,
'movement_type' => 'entry',
'quantity' => $quantity,
'unit_cost' => $unitCost,
'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'] ?? null,
]);
}); });
} }
@ -160,29 +167,7 @@ public function bulkEntry(array $data): array
// Actualizar costo en prices // Actualizar costo en prices
$this->updateProductCost($inventory, $newCost); $this->updateProductCost($inventory, $newCost);
// Crear seriales si se proporcionan // Crear movimiento PRIMERO
if (!empty($serialNumbers)) {
foreach ($serialNumbers as $serialNumber) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $warehouse->id,
'serial_number' => trim($serialNumber),
'status' => 'disponible',
]);
}
// Activar track_serials si no estaba activo
if (!$inventory->track_serials) {
$inventory->update(['track_serials' => true]);
}
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, actualizar stock manualmente
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
}
$movement = InventoryMovement::create([ $movement = InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => null, 'warehouse_from_id' => null,
@ -196,6 +181,34 @@ public function bulkEntry(array $data): array
'invoice_reference' => $data['invoice_reference'], 'invoice_reference' => $data['invoice_reference'],
]); ]);
// Crear seriales DESPUÉS (para asignar movement_id)
if (!empty($serialNumbers)) {
$serials = [];
foreach ($serialNumbers as $serialNumber) {
$serials[] = [
'inventory_id' => $inventory->id,
'movement_id' => $movement->id,
'warehouse_id' => $warehouse->id,
'serial_number' => trim($serialNumber),
'status' => 'disponible',
'created_at' => now(),
'updated_at' => now(),
];
}
InventorySerial::insert($serials);
// Activar track_serials si no estaba activo
if (!$inventory->track_serials) {
$inventory->update(['track_serials' => true]);
}
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, actualizar stock manualmente
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
}
$movements[] = $movement->load(['inventory', 'warehouseTo']); $movements[] = $movement->load(['inventory', 'warehouseTo']);
} }
@ -683,7 +696,7 @@ public function updateMovement(int $movementId, array $data): InventoryMovement
// 4. Actualizar el registro del movimiento // 4. Actualizar el registro del movimiento
$movement->update($updateData); $movement->update($updateData);
return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user']); return $movement->load(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'serials']);
}); });
} }
@ -697,6 +710,11 @@ protected function prepareUpdateData(InventoryMovement $movement, array $data):
'notes' => $data['notes'] ?? $movement->notes, 'notes' => $data['notes'] ?? $movement->notes,
]; ];
// Pasar serial_numbers si fueron proporcionados
if (!empty($data['serial_numbers'])) {
$updateData['serial_numbers'] = $data['serial_numbers'];
}
// Campos específicos por tipo de movimiento // Campos específicos por tipo de movimiento
if ($movement->movement_type === 'entry') { if ($movement->movement_type === 'entry') {
$updateData['unit_cost'] = $data['unit_cost'] ?? $movement->unit_cost; $updateData['unit_cost'] = $data['unit_cost'] ?? $movement->unit_cost;
@ -720,6 +738,7 @@ protected function revertMovement(InventoryMovement $movement): void
switch ($movement->movement_type) { switch ($movement->movement_type) {
case 'entry': case 'entry':
// Revertir entrada: restar del almacén destino // Revertir entrada: restar del almacén destino
// NOTA: Los seriales se manejan en updateMovementSerials(), no aquí
$this->updateWarehouseStock( $this->updateWarehouseStock(
$movement->inventory_id, $movement->inventory_id,
$movement->warehouse_to_id, $movement->warehouse_to_id,
@ -778,6 +797,9 @@ protected function applyEntryUpdate(InventoryMovement $movement, array $data): v
// Aplicar nuevo stock // Aplicar nuevo stock
$this->updateWarehouseStock($inventory->id, $warehouseToId, $quantity); $this->updateWarehouseStock($inventory->id, $warehouseToId, $quantity);
// Revertir y aplicar nuevos seriales (solo si se proporcionaron o cambió la cantidad)
$this->updateMovementSerials($movement, $data);
} }
/** /**
@ -851,4 +873,115 @@ public function syncStockFromSerials(Inventory $inventory): void
->whereNotIn('warehouse_id', $stockByWarehouse->keys()) ->whereNotIn('warehouse_id', $stockByWarehouse->keys())
->update(['stock' => 0]); ->update(['stock' => 0]);
} }
/**
* Actualizar seriales de un movimiento (revertir antiguos y aplicar nuevos)
*/
protected function updateMovementSerials(InventoryMovement $movement, array $data): void
{
$inventory = $movement->inventory;
if (!$inventory->track_serials) {
return;
}
// Solo aplicar a movimientos de entrada
if ($movement->movement_type !== 'entry') {
return;
}
// Verificar si la cantidad cambió
$quantityChanged = isset($data['quantity']) && $data['quantity'] != $movement->quantity;
$serialsProvided = !empty($data['serial_numbers']);
// Si NO cambió la cantidad Y NO se proporcionaron seriales, no hacer nada
if (!$quantityChanged && !$serialsProvided) {
return;
}
// Si cambió la cantidad pero NO se proporcionaron seriales, lanzar error
if ($quantityChanged && !$serialsProvided) {
throw new \Exception('Debe proporcionar los números de serie cuando cambia la cantidad del movimiento.');
}
// Si llegamos aquí, hay que actualizar los seriales
// Validar que TODOS los seriales actuales estén disponibles
$totalSerials = InventorySerial::where('movement_id', $movement->id)->count();
$availableSerials = InventorySerial::where('movement_id', $movement->id)
->where('status', 'disponible')
->count();
if ($totalSerials > $availableSerials) {
throw new \Exception(
'No se puede editar este movimiento porque algunos seriales ya fueron vendidos o devueltos.'
);
}
// Validar que la cantidad de nuevos seriales coincida con la cantidad
$quantity = $data['quantity'] ?? $movement->quantity;
if (count($data['serial_numbers']) !== (int)$quantity) {
throw new \Exception('La cantidad de números de serie debe coincidir con la cantidad del movimiento.');
}
// Validar que no existan seriales duplicados en los nuevos
foreach ($data['serial_numbers'] as $serialNumber) {
$exists = InventorySerial::where('inventory_id', $inventory->id)
->where('serial_number', $serialNumber)
->where('movement_id', '!=', $movement->id) // Excluir los del movimiento actual
->exists();
if ($exists) {
throw new \Exception("El número de serie '{$serialNumber}' ya existe para este producto.");
}
}
// Eliminar seriales antiguos
InventorySerial::where('movement_id', $movement->id)
->where('status', 'disponible')
->delete();
// Crear nuevos seriales
$warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
$serials = [];
foreach ($data['serial_numbers'] as $serialNumber) {
$serials[] = [
'inventory_id' => $inventory->id,
'movement_id' => $movement->id,
'serial_number' => $serialNumber,
'warehouse_id' => $warehouseToId,
'status' => 'disponible',
'created_at' => now(),
'updated_at' => now(),
];
}
InventorySerial::insert($serials);
}
/**
* Revertir seriales de un movimiento
*/
protected function revertSerials(InventoryMovement $movement): void
{
$inventory = $movement->inventory;
if (!$inventory->track_serials) {
return;
}
switch ($movement->movement_type) {
case 'entry':
// Eliminar seriales creados por este movimiento (solo si están disponibles)
InventorySerial::where('movement_id', $movement->id)
->where('status', 'disponible')
->delete();
break;
case 'exit':
case 'transfer':
break;
}
}
} }

View File

@ -13,6 +13,7 @@ class WhatsAppService
protected string $apiUrl; protected string $apiUrl;
protected int $orgId; protected int $orgId;
protected string $token; protected string $token;
protected string $email = 'juan.zapata@golsystems.com.mx';
public function __construct() public function __construct()
{ {
@ -124,15 +125,13 @@ public function sendInvoice(
string $invoiceNumber, string $invoiceNumber,
string $customerName string $customerName
): array { ): array {
$userEmail = auth()->user()->email ?? config('mail.from.address');
// Enviar PDF // Enviar PDF
$pdfResult = $this->sendDocument( $pdfResult = $this->sendDocument(
phoneNumber: $phoneNumber, phoneNumber: $phoneNumber,
documentUrl: $pdfUrl, documentUrl: $pdfUrl,
caption: "Factura {$invoiceNumber} - {$customerName}", caption: "Factura {$invoiceNumber} - {$customerName}",
filename: "Factura_{$invoiceNumber}.pdf", filename: "Factura_{$invoiceNumber}.pdf",
userEmail: $userEmail, userEmail: $this->email,
ticket: $invoiceNumber, ticket: $invoiceNumber,
customerName: $customerName customerName: $customerName
); );
@ -144,7 +143,7 @@ public function sendInvoice(
documentUrl: $xmlUrl, documentUrl: $xmlUrl,
caption: "XML - Factura {$invoiceNumber}", caption: "XML - Factura {$invoiceNumber}",
filename: "Factura_{$invoiceNumber}.xml", filename: "Factura_{$invoiceNumber}.xml",
userEmail: $userEmail, userEmail: $this->email,
ticket: "{$invoiceNumber}_XML", ticket: "{$invoiceNumber}_XML",
customerName: $customerName customerName: $customerName
); );
@ -168,14 +167,12 @@ public function sendSaleTicket(
string $saleNumber, string $saleNumber,
string $customerName string $customerName
): array { ): array {
$userEmail = auth()->user()->email ?? config('mail.from.address');
return $this->sendDocument( return $this->sendDocument(
phoneNumber: $phoneNumber, phoneNumber: $phoneNumber,
documentUrl: $ticketUrl, documentUrl: $ticketUrl,
caption: "Ticket de venta {$saleNumber} - {$customerName}. Gracias por su compra.", caption: "Ticket de venta {$saleNumber} - {$customerName}. Gracias por su compra.",
filename: "Ticket_{$saleNumber}.pdf", filename: "Ticket_{$saleNumber}.pdf",
userEmail: $userEmail, userEmail: $this->email,
ticket: $saleNumber, ticket: $saleNumber,
customerName: $customerName customerName: $customerName
); );

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventory_serials', function (Blueprint $table) {
$table->foreignId('movement_id')->nullable()->constrained('inventory_movements')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_serials', function (Blueprint $table) {
$table->dropForeign(['movement_id']);
$table->dropColumn('movement_id');
});
}
};