diff --git a/app/Http/Controllers/App/InventoryMovementController.php b/app/Http/Controllers/App/InventoryMovementController.php index 720fdf4..de9997a 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']) + $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials']) ->find($id); if (!$movement) { diff --git a/app/Http/Requests/App/InventoryMovementUpdateRequest.php b/app/Http/Requests/App/InventoryMovementUpdateRequest.php index df49954..1479eff 100644 --- a/app/Http/Requests/App/InventoryMovementUpdateRequest.php +++ b/app/Http/Requests/App/InventoryMovementUpdateRequest.php @@ -22,6 +22,8 @@ public function rules(): array 'warehouse_to_id' => 'sometimes|nullable|exists:warehouses,id', 'invoice_reference' => 'sometimes|nullable|string|max:255', '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', 'notes.max' => 'Las notas no pueden exceder 1000 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', ]; } } diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index 44c74fc..2376813 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -48,6 +48,10 @@ public function user() { return $this->belongsTo(User::class); } + public function serials() { + return $this->hasMany(InventorySerial::class, 'movement_id'); + } + // Relación polimórfica para la referencia public function reference() { return $this->morphTo(); diff --git a/app/Models/InventorySerial.php b/app/Models/InventorySerial.php index 310120e..2ed91ae 100644 --- a/app/Models/InventorySerial.php +++ b/app/Models/InventorySerial.php @@ -13,6 +13,7 @@ class InventorySerial extends Model protected $fillable = [ 'inventory_id', 'warehouse_id', + 'movement_id', 'serial_number', 'status', 'sale_detail_id', @@ -34,6 +35,11 @@ public function warehouse() return $this->belongsTo(Warehouse::class); } + public function movement() + { + return $this->belongsTo(InventoryMovement::class); + } + public function saleDetail() { return $this->belongsTo(SaleDetail::class); diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index dea329d..9da8254 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -68,16 +68,35 @@ public function entry(array $data): InventoryMovement // Actualizar costo en prices $this->updateProductCost($inventory, $newCost); - // Solo crear seriales si se proporcionaron + // Registrar movimiento PRIMERO + $movement = 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, + ]); + + // Crear seriales DESPUÉS del movimiento (para asignar movement_id) if ($requiresSerials && !empty($serialNumbers)) { + $serials = []; foreach ($serialNumbers as $serialNumber) { - InventorySerial::create([ + $serials[] = [ 'inventory_id' => $inventory->id, + 'movement_id' => $movement->id, // Ahora tenemos el 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) { @@ -91,19 +110,7 @@ public function entry(array $data): InventoryMovement $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); } - // Registrar movimiento - 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, - ]); + return $movement; }); } @@ -160,29 +167,7 @@ public function bulkEntry(array $data): array // Actualizar costo en prices $this->updateProductCost($inventory, $newCost); - // Crear seriales si se proporcionan - 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); - } - + // Crear movimiento PRIMERO $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => null, @@ -196,6 +181,34 @@ public function bulkEntry(array $data): array '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']); } @@ -683,7 +696,7 @@ public function updateMovement(int $movementId, array $data): InventoryMovement // 4. Actualizar el registro del movimiento $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, ]; + // Pasar serial_numbers si fueron proporcionados + if (!empty($data['serial_numbers'])) { + $updateData['serial_numbers'] = $data['serial_numbers']; + } + // Campos específicos por tipo de movimiento if ($movement->movement_type === 'entry') { $updateData['unit_cost'] = $data['unit_cost'] ?? $movement->unit_cost; @@ -720,6 +738,7 @@ protected function revertMovement(InventoryMovement $movement): void switch ($movement->movement_type) { case 'entry': // Revertir entrada: restar del almacén destino + // NOTA: Los seriales se manejan en updateMovementSerials(), no aquí $this->updateWarehouseStock( $movement->inventory_id, $movement->warehouse_to_id, @@ -778,6 +797,9 @@ protected function applyEntryUpdate(InventoryMovement $movement, array $data): v // Aplicar nuevo stock $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()) ->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; + } + } + } diff --git a/app/Services/WhatsappService.php b/app/Services/WhatsappService.php index c700dd8..cc3622d 100644 --- a/app/Services/WhatsappService.php +++ b/app/Services/WhatsappService.php @@ -13,6 +13,7 @@ class WhatsAppService protected string $apiUrl; protected int $orgId; protected string $token; + protected string $email = 'juan.zapata@golsystems.com.mx'; public function __construct() { @@ -124,15 +125,13 @@ public function sendInvoice( string $invoiceNumber, string $customerName ): array { - $userEmail = auth()->user()->email ?? config('mail.from.address'); - // Enviar PDF $pdfResult = $this->sendDocument( phoneNumber: $phoneNumber, documentUrl: $pdfUrl, caption: "Factura {$invoiceNumber} - {$customerName}", filename: "Factura_{$invoiceNumber}.pdf", - userEmail: $userEmail, + userEmail: $this->email, ticket: $invoiceNumber, customerName: $customerName ); @@ -144,7 +143,7 @@ public function sendInvoice( documentUrl: $xmlUrl, caption: "XML - Factura {$invoiceNumber}", filename: "Factura_{$invoiceNumber}.xml", - userEmail: $userEmail, + userEmail: $this->email, ticket: "{$invoiceNumber}_XML", customerName: $customerName ); @@ -168,14 +167,12 @@ public function sendSaleTicket( string $saleNumber, string $customerName ): array { - $userEmail = auth()->user()->email ?? config('mail.from.address'); - return $this->sendDocument( phoneNumber: $phoneNumber, documentUrl: $ticketUrl, caption: "Ticket de venta {$saleNumber} - {$customerName}. Gracias por su compra.", filename: "Ticket_{$saleNumber}.pdf", - userEmail: $userEmail, + userEmail: $this->email, ticket: $saleNumber, customerName: $customerName ); diff --git a/database/migrations/2026_02_12_095647_add_movement_id_to_inventory_serials_table.php b/database/migrations/2026_02_12_095647_add_movement_id_to_inventory_serials_table.php new file mode 100644 index 0000000..a312633 --- /dev/null +++ b/database/migrations/2026_02_12_095647_add_movement_id_to_inventory_serials_table.php @@ -0,0 +1,29 @@ +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'); + }); + } +};