diff --git a/.env.example b/.env.example index 7bd6183..9892b4f 100644 --- a/.env.example +++ b/.env.example @@ -87,3 +87,10 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}" + +# WhatsApp API Configuracion +WHATSAPP_LOGIN_URL=https://whatsapp.golsystems.mx/api/login +WHATSAPP_API_URL=https://whatsapp.golsystems.mx/api/send-whatsapp +WHATSAPP_ORG_ID=1 +WHATSAPP_AUTH_EMAIL= +WHATSAPP_AUTH_PASSWORD= diff --git a/app/Http/Controllers/App/ExcelController.php b/app/Http/Controllers/App/ExcelController.php index 45bdc01..2a3ba6f 100644 --- a/app/Http/Controllers/App/ExcelController.php +++ b/app/Http/Controllers/App/ExcelController.php @@ -481,6 +481,7 @@ public function inventoryReport(Request $request) $query = Inventory::with([ 'category:id,name', 'price:inventory_id,cost,retail_price', + 'unitOfMeasure:id,abbreviation', 'serials' ]) ->when($request->q, function($q) use ($request) { @@ -535,6 +536,7 @@ public function inventoryReport(Request $request) 'sku' => $inventory->sku ?? 'N/A', 'name' => $inventory->name, 'category' => $inventory->category?->name ?? 'Sin categoría', + 'unit' => $inventory->unitOfMeasure?->abbreviation ?? 'u', 'stock' => $inventory->stock, 'quantity_sold' => $quantitySold, 'serials_total' => $serialsTotal, @@ -684,25 +686,26 @@ public function inventoryReport(Request $request) 'B' => 'SKU', 'C' => 'NOMBRE', 'D' => 'CATEGORÍA', - 'E' => "STOCK\nDISPONIBLE", - 'F' => "CANTIDAD\nVENDIDA", - 'G' => "SERIALES\nTOTALES", - 'H' => "SERIALES\nDISPONIBLES", - 'I' => "SERIALES\nVENDIDOS", - 'J' => "SERIALES\nDEVUELTOS", - 'K' => "COSTO\nUNITARIO", - 'L' => "PRECIO\nVENTA", - 'M' => "TOTAL\nVENDIDO", - 'N' => "VALOR\nINVENTARIO", - 'O' => "UTILIDAD\nPOR UNIDAD", - 'P' => "UTILIDAD\nTOTAL", + 'E' => 'UNIDAD', + 'F' => "STOCK\nDISPONIBLE", + 'G' => "CANTIDAD\nVENDIDA", + 'H' => "SERIALES\nTOTALES", + 'I' => "SERIALES\nDISPONIBLES", + 'J' => "SERIALES\nVENDIDOS", + 'K' => "SERIALES\nDEVUELTOS", + 'L' => "COSTO\nUNITARIO", + 'M' => "PRECIO\nVENTA", + 'N' => "TOTAL\nVENDIDO", + 'O' => "VALOR\nINVENTARIO", + 'P' => "UTILIDAD\nPOR UNIDAD", + 'Q' => "UTILIDAD\nTOTAL", ]; foreach ($headers as $col => $text) { $sheet->setCellValue("{$col}{$h}", $text); } - $sheet->getStyle("A{$h}:P{$h}")->applyFromArray($styleTableHeader); + $sheet->getStyle("A{$h}:Q{$h}")->applyFromArray($styleTableHeader); $sheet->getRowDimension($h)->setRowHeight(35); // --- LLENADO DE DATOS --- @@ -715,27 +718,28 @@ public function inventoryReport(Request $request) $sheet->setCellValue('B' . $row, $item['sku']); $sheet->setCellValue('C' . $row, $item['name']); $sheet->setCellValue('D' . $row, $item['category']); + $sheet->setCellValue('E' . $row, $item['unit']); // NÚMEROS SIN FORMATO - $sheet->setCellValueExplicit('E' . $row, (int) $item['stock'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('F' . $row, (int) $item['quantity_sold'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('G' . $row, (int) $item['serials_total'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('H' . $row, (int) $item['serials_available'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('I' . $row, (int) $item['serials_sold'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('J' . $row, (int) $item['serials_returned'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('F' . $row, (float) $item['stock'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('G' . $row, (float) $item['quantity_sold'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('H' . $row, (int) $item['serials_total'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('I' . $row, (int) $item['serials_available'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('J' . $row, (int) $item['serials_sold'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('K' . $row, (int) $item['serials_returned'], DataType::TYPE_NUMERIC); // NÚMEROS CON FORMATO MONEDA - $sheet->setCellValueExplicit('K' . $row, (float) $item['cost'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('L' . $row, (float) $item['price'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('M' . $row, (float) $item['total_sold'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('N' . $row, (float) $item['inventory_value'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('O' . $row, (float) $item['unit_profit'], DataType::TYPE_NUMERIC); - $sheet->setCellValueExplicit('P' . $row, (float) $item['total_profit'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('L' . $row, (float) $item['cost'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('M' . $row, (float) $item['price'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('N' . $row, (float) $item['total_sold'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('O' . $row, (float) $item['inventory_value'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('P' . $row, (float) $item['unit_profit'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('Q' . $row, (float) $item['total_profit'], DataType::TYPE_NUMERIC); $totalProfit += $item['total_profit']; // Estilos de fila - $sheet->getStyle("A{$row}:P{$row}")->applyFromArray([ + $sheet->getStyle("A{$row}:Q{$row}")->applyFromArray([ 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], 'font' => ['size' => 10] @@ -750,20 +754,21 @@ public function inventoryReport(Request $request) $sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->getStyle("I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->getStyle("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); + $sheet->getStyle("K{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); - // Formato moneda para columnas K-P - $sheet->getStyle("K{$row}:P{$row}")->getNumberFormat()->setFormatCode('$#,##0.00'); + // Formato moneda para columnas L-Q + $sheet->getStyle("L{$row}:Q{$row}")->getNumberFormat()->setFormatCode('$#,##0.00'); // Color de utilidad (verde si es positiva) if ($item['total_profit'] > 0) { - $sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('008000'); + $sheet->getStyle("Q{$row}")->getFont()->getColor()->setRGB('008000'); } elseif ($item['total_profit'] < 0) { - $sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('FF0000'); + $sheet->getStyle("Q{$row}")->getFont()->getColor()->setRGB('FF0000'); } // Color alterno de filas if ($i % 2 == 0) { - $sheet->getStyle("A{$row}:P{$row}")->getFill() + $sheet->getStyle("A{$row}:Q{$row}")->getFill() ->setFillType(Fill::FILL_SOLID) ->getStartColor()->setRGB('F2F2F2'); } @@ -773,22 +778,23 @@ public function inventoryReport(Request $request) } // --- ANCHOS DE COLUMNA --- - $sheet->getColumnDimension('A')->setWidth(18); - $sheet->getColumnDimension('B')->setWidth(18); - $sheet->getColumnDimension('C')->setWidth(18); + $sheet->getColumnDimension('A')->setWidth(6); + $sheet->getColumnDimension('B')->setWidth(15); + $sheet->getColumnDimension('C')->setWidth(25); $sheet->getColumnDimension('D')->setWidth(18); - $sheet->getColumnDimension('E')->setWidth(18); - $sheet->getColumnDimension('F')->setWidth(18); - $sheet->getColumnDimension('G')->setWidth(18); - $sheet->getColumnDimension('H')->setWidth(18); - $sheet->getColumnDimension('I')->setWidth(18); - $sheet->getColumnDimension('J')->setWidth(18); - $sheet->getColumnDimension('K')->setWidth(18); - $sheet->getColumnDimension('L')->setWidth(18); - $sheet->getColumnDimension('M')->setWidth(18); + $sheet->getColumnDimension('E')->setWidth(10); + $sheet->getColumnDimension('F')->setWidth(15); + $sheet->getColumnDimension('G')->setWidth(15); + $sheet->getColumnDimension('H')->setWidth(15); + $sheet->getColumnDimension('I')->setWidth(15); + $sheet->getColumnDimension('J')->setWidth(15); + $sheet->getColumnDimension('K')->setWidth(15); + $sheet->getColumnDimension('L')->setWidth(15); + $sheet->getColumnDimension('M')->setWidth(15); $sheet->getColumnDimension('N')->setWidth(18); $sheet->getColumnDimension('O')->setWidth(18); $sheet->getColumnDimension('P')->setWidth(18); + $sheet->getColumnDimension('Q')->setWidth(18); $writer = new Xlsx($spreadsheet); $writer->save($filePath); diff --git a/app/Http/Controllers/App/InventoryController.php b/app/Http/Controllers/App/InventoryController.php index 88cf616..8096fad 100644 --- a/app/Http/Controllers/App/InventoryController.php +++ b/app/Http/Controllers/App/InventoryController.php @@ -24,7 +24,7 @@ public function __construct( public function index(Request $request) { - $products = Inventory::with(['category', 'price'])->withCount('serials') + $products = Inventory::with(['category', 'price', 'unitOfMeasure'])->withCount('serials') ->where('is_active', true); @@ -61,7 +61,7 @@ public function index(Request $request) public function show(Inventory $inventario) { return ApiResponse::OK->response([ - 'model' => $inventario->load(['category', 'price'])->loadCount('serials') + 'model' => $inventario->load(['category', 'price', 'unitOfMeasure'])->loadCount('serials') ]); } @@ -96,7 +96,7 @@ public function destroy(Inventory $inventario) public function getProductsByWarehouse(Request $request, int $warehouseId) { $query = Inventory::query() - ->with(['category', 'price']) + ->with(['category', 'price', 'unitOfMeasure']) ->where('is_active', true) ->whereHas('warehouses', function ($q) use ($warehouseId) { $q->where('warehouse_id', $warehouseId) diff --git a/app/Http/Controllers/App/InvoiceController.php b/app/Http/Controllers/App/InvoiceController.php index 65c148d..af8f5b0 100644 --- a/app/Http/Controllers/App/InvoiceController.php +++ b/app/Http/Controllers/App/InvoiceController.php @@ -7,6 +7,7 @@ use App\Models\Client; use App\Models\InvoiceRequest; use App\Models\Sale; +use Illuminate\Http\Request; use Notsoweb\ApiResponse\Enums\ApiResponse; class InvoiceController extends Controller @@ -46,13 +47,21 @@ public function show(string $invoiceNumber) ]); } + // Buscar cliente existente si la venta ya tiene cliente asociado + $existingClient = null; + if ($sale->client_id) { + $existingClient = $sale->client; + } + return ApiResponse::OK->response([ 'sale' => $this->formatSaleData($sale), 'client' => $sale->client, + 'existing_client' => $existingClient, 'invoice_requests' => $sale->invoiceRequests, ]); } + /** * Guarda los datos fiscales del cliente para la venta. */ @@ -86,22 +95,43 @@ public function store(InvoiceStoreRequest $request, string $invoiceNumber) ]); } - // Buscar si ya existe un cliente con ese RFC $client = Client::where('rfc', strtoupper($request->rfc))->first(); if ($client) { - // Actualizar datos del cliente existente - $client->update([ - 'name' => $request->name, - 'email' => $request->email, - 'phone' => $request->phone ?? $client->phone, - 'address' => $request->address ?? $client->address, - 'razon_social' => $request->razon_social, - 'regimen_fiscal' => $request->regimen_fiscal, - 'cp_fiscal' => $request->cp_fiscal, - 'uso_cfdi' => $request->uso_cfdi, - ]); + // Solo actualizar campos que el usuario proporciona explícitamente + $updateData = []; + + // Actualizar nombre solo si es diferente y fue proporcionado + if ($request->filled('name') && $request->name !== $client->name) { + $updateData['name'] = $request->name; + } + + // Actualizar email solo si es diferente y fue proporcionado + if ($request->filled('email') && $request->email !== $client->email) { + $updateData['email'] = $request->email; + } + + // Actualizar teléfono si fue proporcionado + if ($request->filled('phone')) { + $updateData['phone'] = $request->phone; + } + + // Actualizar dirección si fue proporcionada + if ($request->filled('address')) { + $updateData['address'] = $request->address; + } + + // Actualizar datos fiscales siempre (son obligatorios en el request) + $updateData['razon_social'] = $request->razon_social; + $updateData['regimen_fiscal'] = $request->regimen_fiscal; + $updateData['cp_fiscal'] = $request->cp_fiscal; + $updateData['uso_cfdi'] = $request->uso_cfdi; + + // Solo actualizar si hay cambios + if (!empty($updateData)) { + $client->update($updateData); + } } else { // Crear nuevo cliente $client = Client::create([ @@ -118,10 +148,12 @@ public function store(InvoiceStoreRequest $request, string $invoiceNumber) ]); } - // Asociar cliente a la venta - $sale->update(['client_id' => $client->id]); + // Asociar cliente a la venta solo si no está ya asociado + if ($sale->client_id !== $client->id) { + $sale->update(['client_id' => $client->id]); + } - // Recargar relaciones + // Crear solicitud de facturación $invoiceRequest = InvoiceRequest::create([ 'sale_id' => $sale->id, 'client_id' => $client->id, @@ -131,12 +163,47 @@ public function store(InvoiceStoreRequest $request, string $invoiceNumber) return ApiResponse::OK->response([ 'message' => 'Solicitud de facturación guardada correctamente', - 'client' => $client, - 'sale' => $this->formatSaleData($sale), + 'client' => $client->fresh(), + 'sale' => $this->formatSaleData($sale->fresh('details.serials')), 'invoice_request' => $invoiceRequest, ]); } + /** + * Verificar si existe un cliente con el RFC proporcionado + */ + public function checkRfc(Request $request) + { + $request->validate([ + 'rfc' => ['required', 'string', 'min:12', 'max:13'], + ]); + + $rfc = $request->input('rfc'); + + $client = Client::where('rfc', strtoupper($rfc))->first(); + + if ($client) { + return ApiResponse::OK->response([ + 'exists' => true, + 'client' => [ + 'name' => $client->name, + 'email' => $client->email, + 'phone' => $client->phone, + 'address' => $client->address, + 'rfc' => $client->rfc, + 'razon_social' => $client->razon_social, + 'regimen_fiscal' => $client->regimen_fiscal, + 'cp_fiscal' => $client->cp_fiscal, + 'uso_cfdi' => $client->uso_cfdi, + ] + ]); + } + + return ApiResponse::OK->response([ + 'exists' => false + ]); + } + /** * Formatear datos de la venta incluyendo números de serie */ diff --git a/app/Http/Controllers/App/UnitOfMeasurementController.php b/app/Http/Controllers/App/UnitOfMeasurementController.php new file mode 100644 index 0000000..cfb928d --- /dev/null +++ b/app/Http/Controllers/App/UnitOfMeasurementController.php @@ -0,0 +1,76 @@ +get(); + + return ApiResponse::OK->response([ + 'units' => $units + ]); + } + + public function show(UnitOfMeasurement $unit) + { + return ApiResponse::OK->response([ + 'unit' => $unit + ]); + } + + public function active() + { + $units = UnitOfMeasurement::active()->orderBy('name')->get(); + + return ApiResponse::OK->response([ + 'units' => $units + ]); + } + + public function store(UnitOfMeasurementStoreRequest $request) + { + $unit = UnitOfMeasurement::create($request->validated()); + + return ApiResponse::CREATED->response([ + 'message' => 'Unidad de medida creada correctamente.', + 'unit' => $unit + ]); + } + + public function update(UnitOfMeasurementUpdateRequest $request, UnitOfMeasurement $unit) + { + $unit->update($request->validated()); + + return ApiResponse::OK->response([ + 'message' => 'Unidad de medida actualizada correctamente.' + ]); + } + + public function destroy(UnitOfMeasurement $unit) + { + // Verificar si hay productos asociados a esta unidad de medida + if ($unit->inventories()->exists()) { + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'No se puede eliminar esta unidad de medida porque hay productos asociados a ella.' + ]); + } + + $unit->delete(); + + return ApiResponse::OK->response([ + 'message' => 'Unidad de medida eliminada correctamente.' + ]); + } +} diff --git a/app/Http/Controllers/App/WhatsappController.php b/app/Http/Controllers/App/WhatsappController.php new file mode 100644 index 0000000..e89cded --- /dev/null +++ b/app/Http/Controllers/App/WhatsappController.php @@ -0,0 +1,121 @@ +validate([ + 'phone_number' => ['required', 'string', 'regex:/^[0-9]{10,13}$/'], + 'document_url' => ['required', 'url'], + 'caption' => ['required', 'string', 'max:1000'], + 'filename' => ['required', 'string', 'max:255'], + 'ticket' => ['required', 'string', 'max:100'], + 'customer_name' => ['required', 'string', 'max:255'], + ], [ + 'phone_number.required' => 'El número de teléfono es obligatorio', + 'phone_number.regex' => 'El número de teléfono debe tener entre 10 y 13 dígitos', + 'document_url.required' => 'La URL del documento es obligatoria', + 'document_url.url' => 'La URL del documento no es válida', + ]); + + $result = $this->whatsAppService->sendDocument( + phoneNumber: $validated['phone_number'], + documentUrl: $validated['document_url'], + caption: $validated['caption'], + filename: $validated['filename'], + userEmail: auth()->user()->email, + ticket: $validated['ticket'], + customerName: $validated['customer_name'] + ); + + if ($result['success']) { + return ApiResponse::OK->response([ + 'message' => 'Documento enviado correctamente por WhatsApp', + 'data' => $result + ]); + } + + return ApiResponse::BAD_REQUEST->response([ + 'message' => $result['message'], + 'error' => $result['error'] ?? null + ]); + } + + /** + * Enviar factura por WhatsApp + */ + public function sendInvoice(Request $request) + { + $validated = $request->validate([ + 'phone_number' => ['required', 'string', 'regex:/^[0-9]{10,13}$/'], + 'invoice_number' => ['required', 'string'], + 'pdf_url' => ['required', 'url'], + 'xml_url' => ['nullable', 'url'], + 'customer_name' => ['required', 'string', 'max:255'], + ]); + + $result = $this->whatsAppService->sendInvoice( + phoneNumber: $validated['phone_number'], + pdfUrl: $validated['pdf_url'], + xmlUrl: $validated['xml_url'] ?? null, + invoiceNumber: $validated['invoice_number'], + customerName: $validated['customer_name'] + ); + + if ($result['success']) { + return ApiResponse::OK->response([ + 'message' => 'Factura enviada correctamente por WhatsApp', + 'data' => $result + ]); + } + + return ApiResponse::BAD_REQUEST->response([ + 'message' => 'Error al enviar la factura', + 'error' => $result + ]); + } + + /** + * Enviar ticket de venta por WhatsApp + */ + public function sendSaleTicket(Request $request) + { + $validated = $request->validate([ + 'phone_number' => ['required', 'string', 'regex:/^[0-9]{10,13}$/'], + 'sale_number' => ['required', 'string'], + 'ticket_url' => ['required', 'url'], + 'customer_name' => ['required', 'string', 'max:255'], + ]); + + $result = $this->whatsAppService->sendSaleTicket( + phoneNumber: $validated['phone_number'], + ticketUrl: $validated['ticket_url'], + saleNumber: $validated['sale_number'], + customerName: $validated['customer_name'] + ); + + if ($result['success']) { + return ApiResponse::OK->response([ + 'message' => 'Ticket enviado correctamente por WhatsApp', + 'data' => $result + ]); + } + + return ApiResponse::BAD_REQUEST->response([ + 'message' => $result['message'], + 'error' => $result['error'] ?? null + ]); + } +} diff --git a/app/Http/Requests/App/InventoryEntryRequest.php b/app/Http/Requests/App/InventoryEntryRequest.php index aae8ab0..cd2083b 100644 --- a/app/Http/Requests/App/InventoryEntryRequest.php +++ b/app/Http/Requests/App/InventoryEntryRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\App; +use App\Models\Inventory; use Illuminate\Foundation\Http\FormRequest; class InventoryEntryRequest extends FormRequest @@ -22,7 +23,7 @@ public function rules(): array // Validación del array de productos 'products' => 'required|array|min:1', 'products.*.inventory_id' => 'required|exists:inventories,id', - 'products.*.quantity' => 'required|integer|min:1', + 'products.*.quantity' => 'required|numeric|min:0.001', 'products.*.unit_cost' => 'required|numeric|min:0', 'products.*.serial_numbers' => 'nullable|array', 'products.*.serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number', @@ -32,7 +33,7 @@ public function rules(): array return [ 'inventory_id' => 'required|exists:inventories,id', 'warehouse_id' => 'required|exists:warehouses,id', - 'quantity' => 'required|integer|min:1', + 'quantity' => 'required|numeric|min:0.001', 'unit_cost' => 'required|numeric|min:0', 'invoice_reference' => 'required|string|max:255', 'notes' => 'nullable|string|max:1000', @@ -77,4 +78,50 @@ public function messages(): array 'invoice_reference.required' => 'La referencia de la factura es requerida', ]; } + + /** + * Validaciones adicionales de negocio + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + // Extraer productos del request (entrada simple o múltiple) + $products = $this->has('products') ? $this->products : [[ + 'inventory_id' => $this->inventory_id, + 'quantity' => $this->quantity, + 'serial_numbers' => $this->serial_numbers ?? null, + ]]; + + foreach ($products as $index => $product) { + $inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']); + + if (!$inventory || !$inventory->unitOfMeasure) { + continue; + } + + // VALIDACIÓN 1: Cantidades decimales solo con unidades que permiten decimales + $quantity = $product['quantity']; + $isDecimal = floor($quantity) != $quantity; + + if ($isDecimal && !$inventory->unitOfMeasure->allows_decimals) { + $field = $this->has('products') ? "products.{$index}.quantity" : 'quantity'; + $validator->errors()->add( + $field, + "El producto '{$inventory->name}' usa la unidad '{$inventory->unitOfMeasure->name}' que no permite cantidades decimales. Use cantidades enteras." + ); + } + + // VALIDACIÓN 2: No permitir seriales con unidades decimales + $serialNumbers = $product['serial_numbers'] ?? null; + + if (!empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { + $field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers'; + $validator->errors()->add( + $field, + "No se pueden registrar números de serie para el producto '{$inventory->name}' porque usa la unidad '{$inventory->unitOfMeasure->name}' que permite cantidades decimales. Los seriales solo son válidos para unidades discretas como 'Pieza'." + ); + } + } + }); + } } diff --git a/app/Http/Requests/App/InventoryExitRequest.php b/app/Http/Requests/App/InventoryExitRequest.php index ad93034..3ba2ea3 100644 --- a/app/Http/Requests/App/InventoryExitRequest.php +++ b/app/Http/Requests/App/InventoryExitRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\App; +use App\Models\Inventory; use Illuminate\Foundation\Http\FormRequest; class InventoryExitRequest extends FormRequest @@ -22,7 +23,7 @@ public function rules(): array // Validación del array de productos 'products' => 'required|array|min:1', 'products.*.inventory_id' => 'required|exists:inventories,id', - 'products.*.quantity' => 'required|integer|min:1', + 'products.*.quantity' => 'required|numeric|min:0.001', 'products.*.serial_numbers' => 'nullable|array', 'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', ]; @@ -32,7 +33,7 @@ public function rules(): array return [ 'inventory_id' => 'required|exists:inventories,id', 'warehouse_id' => 'required|exists:warehouses,id', - 'quantity' => 'required|integer|min:1', + 'quantity' => 'required|numeric|min:0.001', 'notes' => 'nullable|string|max:1000', 'serial_numbers' => 'nullable|array', 'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', @@ -68,4 +69,48 @@ public function messages(): array 'quantity.min' => 'La cantidad debe ser al menos 1', ]; } + + /** + * Validación adicional: cantidades decimales solo con unidades que permiten decimales + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + $products = $this->has('products') ? $this->products : [[ + 'inventory_id' => $this->inventory_id, + 'quantity' => $this->quantity, + 'serial_numbers' => $this->serial_numbers ?? null, + ]]; + + foreach ($products as $index => $product) { + $inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']); + + if (!$inventory || !$inventory->unitOfMeasure) { + continue; + } + + $quantity = $product['quantity']; + $isDecimal = floor($quantity) != $quantity; + + // Si la cantidad es decimal pero la unidad no permite decimales + if ($isDecimal && !$inventory->unitOfMeasure->allows_decimals) { + $field = $this->has('products') ? "products.{$index}.quantity" : 'quantity'; + $validator->errors()->add( + $field, + "El producto '{$inventory->name}' usa la unidad '{$inventory->unitOfMeasure->name}' que no permite cantidades decimales. Use cantidades enteras." + ); + } + + $serialNumbers = $product['serial_numbers'] ?? null; + + if (!empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { + $field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers'; + $validator->errors()->add( + $field, + "No se pueden registrar números de serie para el producto '{$inventory->name}' porque usa la unidad '{$inventory->unitOfMeasure->name}' que permite cantidades decimales. Los seriales solo son válidos para unidades discretas como 'Pieza'." + ); + } + } + }); + } } diff --git a/app/Http/Requests/App/InventoryTransferRequest.php b/app/Http/Requests/App/InventoryTransferRequest.php index 2a17a52..132cbd8 100644 --- a/app/Http/Requests/App/InventoryTransferRequest.php +++ b/app/Http/Requests/App/InventoryTransferRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\App; +use App\Models\Inventory; use Illuminate\Foundation\Http\FormRequest; class InventoryTransferRequest extends FormRequest @@ -23,7 +24,7 @@ public function rules(): array // Validación del array de productos 'products' => 'required|array|min:1', 'products.*.inventory_id' => 'required|exists:inventories,id', - 'products.*.quantity' => 'required|integer|min:1', + 'products.*.quantity' => 'required|numeric|min:0.001', 'products.*.serial_numbers' => 'nullable|array', 'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', ]; @@ -34,7 +35,7 @@ public function rules(): array 'inventory_id' => 'required|exists:inventories,id', 'warehouse_from_id' => 'required|exists:warehouses,id', 'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id', - 'quantity' => 'required|integer|min:1', + 'quantity' => 'required|numeric|min:0.001', 'notes' => 'nullable|string|max:1000', 'serial_numbers' => 'nullable|array', 'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', @@ -73,4 +74,50 @@ public function messages(): array 'quantity.min' => 'La cantidad debe ser al menos 1', ]; } + + /** + * Validación adicional: cantidades decimales solo con unidades que permiten decimales + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + // Extraer productos del request (entrada simple o múltiple) + $products = $this->has('products') ? $this->products : [[ + 'inventory_id' => $this->inventory_id, + 'quantity' => $this->quantity, + 'serial_numbers' => $this->serial_numbers ?? null, + ]]; + + foreach ($products as $index => $product) { + $inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']); + + if (!$inventory || !$inventory->unitOfMeasure) { + continue; + } + + // VALIDACIÓN 1: Cantidades decimales solo con unidades que permiten decimales + $quantity = $product['quantity']; + $isDecimal = floor($quantity) != $quantity; + + if ($isDecimal && !$inventory->unitOfMeasure->allows_decimals) { + $field = $this->has('products') ? "products.{$index}.quantity" : 'quantity'; + $validator->errors()->add( + $field, + "El producto '{$inventory->name}' usa la unidad '{$inventory->unitOfMeasure->name}' que no permite cantidades decimales. Use cantidades enteras." + ); + } + + // VALIDACIÓN 2: No permitir seriales con unidades decimales + $serialNumbers = $product['serial_numbers'] ?? null; + + if (!empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { + $field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers'; + $validator->errors()->add( + $field, + "No se pueden registrar números de serie para el producto '{$inventory->name}' porque usa la unidad '{$inventory->unitOfMeasure->name}' que permite cantidades decimales. Los seriales solo son válidos para unidades discretas como 'Pieza'." + ); + } + } + }); + } } diff --git a/app/Http/Requests/App/UnitOfMeasurementStoreRequest.php b/app/Http/Requests/App/UnitOfMeasurementStoreRequest.php new file mode 100644 index 0000000..0556595 --- /dev/null +++ b/app/Http/Requests/App/UnitOfMeasurementStoreRequest.php @@ -0,0 +1,41 @@ + ['required', 'string', 'max:50', 'unique:units_of_measurement,name'], + 'abbreviation' => ['required', 'string', 'max:10', 'unique:units_of_measurement,abbreviation'], + 'allows_decimals' => ['required', 'boolean'], + 'is_active' => ['boolean'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'El nombre es obligatorio.', + 'name.unique' => 'Ya existe una unidad con este nombre.', + 'name.max' => 'El nombre no debe exceder los 50 caracteres.', + 'abbreviation.required' => 'La abreviatura es obligatoria.', + 'abbreviation.unique' => 'Ya existe una unidad con esta abreviatura.', + 'abbreviation.max' => 'La abreviatura no debe exceder los 10 caracteres.', + 'allows_decimals.required' => 'Debe especificar si la unidad permite cantidades decimales.', + 'allows_decimals.boolean' => 'El campo permite decimales debe ser verdadero o falso.', + 'is_active.boolean' => 'El campo activo debe ser verdadero o falso.', + ]; + } +} diff --git a/app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php b/app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php new file mode 100644 index 0000000..01e82e2 --- /dev/null +++ b/app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php @@ -0,0 +1,71 @@ +route('unidad'); + + return [ + 'name' => ['sometimes', 'string', 'max:50', 'unique:units_of_measurement,name,' . $unitId], + 'abbreviation' => ['sometimes', 'string', 'max:10', 'unique:units_of_measurement,abbreviation,' . $unitId], + 'allows_decimals' => ['sometimes', 'boolean'], + 'is_active' => ['sometimes', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'name.unique' => 'Ya existe una unidad con este nombre.', + 'name.max' => 'El nombre no debe exceder los 50 caracteres.', + 'abbreviation.unique' => 'Ya existe una unidad con esta abreviatura.', + 'abbreviation.max' => 'La abreviatura no debe exceder los 10 caracteres.', + 'allows_decimals.boolean' => 'El campo permite decimales debe ser verdadero o falso.', + 'is_active.boolean' => 'El campo activo debe ser verdadero o falso.', + ]; + } + + /** + * Validación adicional: no permitir cambiar allows_decimals si hay productos con seriales + */ + public function withValidator($validator) + { + $validator->after(function ($validator) { + $unitId = $this->route('unidad'); + $unit = UnitOfMeasurement::find($unitId); + + if (!$unit) { + return; + } + + // Si se intenta cambiar allows_decimals + if ($this->has('allows_decimals') && $this->allows_decimals !== $unit->allows_decimals) { + // Verificar si hay productos con track_serials usando esta unidad + $hasProductsWithSerials = $unit->inventories() + ->where('track_serials', true) + ->exists(); + + if ($hasProductsWithSerials) { + $validator->errors()->add( + 'allows_decimals', + 'No se puede cambiar el tipo de unidad (decimal/entero) porque hay productos con números de serie que la utilizan' + ); + } + } + }); + } +} diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index ac01297..84d3b19 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -24,12 +24,17 @@ public function entry(array $data): InventoryMovement return DB::transaction(function () use ($data) { $inventory = Inventory::findOrFail($data['inventory_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']); - $quantity = (int) $data['quantity']; + $quantity = (float) $data['quantity']; $unitCost = (float) $data['unit_cost']; $serialNumbers = $data['serial_numbers'] ?? null; - // Validar seriales si el producto los requiere - if ($inventory->track_serials) { + // cargar la unidad de medida + $inventory->load('unitOfMeasure'); + + // Solo validar seriales si track_serials Y la unidad NO permite decimales + $requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; + + if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.'); } @@ -48,13 +53,13 @@ public function entry(array $data): InventoryMovement } } - //Obtener stock actual para calcular costo promedio ponderado - $curentStock = $inventory->stock; + // Obtener stock actual para calcular costo promedio ponderado + $currentStock = $inventory->stock; $currentCost = $inventory->price?->cost ?? 0.00; // Calcular nuevo costo promedio ponderado $newCost = $this->calculateWeightedAverageCost( - $curentStock, + $currentStock, $currentCost, $quantity, $unitCost @@ -63,8 +68,8 @@ public function entry(array $data): InventoryMovement // Actualizar costo en prices $this->updateProductCost($inventory, $newCost); - // Crear seriales si se proporcionan - if (!empty($serialNumbers)) { + // Solo crear seriales si se proporcionaron + if ($requiresSerials && !empty($serialNumbers)) { foreach ($serialNumbers as $serialNumber) { InventorySerial::create([ 'inventory_id' => $inventory->id, @@ -82,7 +87,7 @@ public function entry(array $data): InventoryMovement // Sincronizar stock desde seriales $inventory->syncStock(); } else { - // Sin seriales, actualizar stock manualmente + // **SIN SERIALES**: Actualizar stock manualmente (permite decimales) $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); } @@ -112,7 +117,7 @@ public function bulkEntry(array $data): array foreach ($data['products'] as $productData) { $inventory = Inventory::findOrFail($productData['inventory_id']); - $quantity = (int) $productData['quantity']; + $quantity = (float) $productData['quantity']; $unitCost = (float)$productData['unit_cost']; $serialNumbers = $productData['serial_numbers'] ?? null; @@ -207,12 +212,15 @@ public function bulkExit(array $data): array $movements = []; foreach ($data['products'] as $productData) { - $inventory = Inventory::findOrFail($productData['inventory_id']); - $quantity = (int) $productData['quantity']; - $serialNumbers = (array) $productData['serial_numbers'] ?? null; + $inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']); + $quantity = (float) $productData['quantity']; + $serialNumbers = $productData['serial_numbers'] ?? null; + + // **NUEVO**: Solo exigir seriales si track_serials Y unidad NO permite decimales + $requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; // Validar y procesar seriales si el producto los requiere - if ($inventory->track_serials) { + if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales."); } @@ -295,7 +303,7 @@ public function bulkTransfer(array $data): array foreach ($data['products'] as $productData) { $inventory = Inventory::findOrFail($productData['inventory_id']); - $quantity = (int) $productData['quantity']; + $quantity = (float) $productData['quantity']; $serialNumbers = (array) ($productData['serial_numbers'] ?? null); // Validar y procesar seriales si el producto los requiere @@ -370,9 +378,9 @@ public function bulkTransfer(array $data): array * Calcular costo promedio ponderado */ protected function calculateWeightedAverageCost( - int $currentStock, + float $currentStock, float $currentCost, - int $entryQuantity, + float $entryQuantity, float $entryCost ): float { if ($currentStock <= 0) { @@ -399,11 +407,17 @@ public function exit(array $data): InventoryMovement return DB::transaction(function () use ($data) { $inventory = Inventory::findOrFail($data['inventory_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']); - $quantity = (int) $data['quantity']; - $serialNumbers = (array) ($data['serial_numbers'] ?? null); + $quantity = (float) $data['quantity']; + $serialNumbers = $data['serial_numbers'] ?? null; + + // **NUEVO**: Cargar la unidad de medida + $inventory->load('unitOfMeasure'); + + // **CAMBIO**: Solo validar seriales si track_serials Y la unidad NO permite decimales + $requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; // Validar y procesar seriales si el producto los requiere - if ($inventory->track_serials) { + if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.'); } @@ -439,14 +453,14 @@ public function exit(array $data): InventoryMovement } // Eliminar los seriales (salida definitiva) - InventorySerial::whereIn('serial_number', $serialNumbers) + InventorySerial::whereIn('serial_numbers', $serialNumbers) ->where('inventory_id', $inventory->id) ->delete(); // Sincronizar stock desde seriales $inventory->syncStock(); } else { - // Sin seriales, validar y decrementar stock manualmente + // **SIN SERIALES**: Validar y decrementar stock manualmente $this->validateStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); } @@ -470,19 +484,22 @@ public function exit(array $data): InventoryMovement public function transfer(array $data): InventoryMovement { return DB::transaction(function () use ($data) { - $inventory = Inventory::findOrFail($data['inventory_id']); + $inventory = Inventory::with('unitOfMeasure')->findOrFail($data['inventory_id']); $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']); $warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']); - $quantity = (int) $data['quantity']; - $serialNumbers = (array) ($data['serial_numbers'] ?? null); + $quantity = (float) $data['quantity']; + $serialNumbers = $data['serial_numbers'] ?? null; // Validar que no sea el mismo almacén if ($warehouseFrom->id === $warehouseTo->id) { throw new \Exception('No se puede traspasar al mismo almacén.'); } + // **NUEVO**: Solo exigir seriales si track_serials Y unidad NO permite decimales + $requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; + // Validar y procesar seriales si el producto los requiere - if ($inventory->track_serials) { + if ($requiresSerials) { if (empty($serialNumbers)) { throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.'); } @@ -547,7 +564,7 @@ public function transfer(array $data): InventoryMovement /** * Actualizar stock en inventory_warehouse */ - public function updateWarehouseStock(int $inventoryId, int $warehouseId, int $quantityChange): void + public function updateWarehouseStock(int $inventoryId, int $warehouseId, float $quantityChange): void { $record = InventoryWarehouse::firstOrCreate( [ @@ -569,7 +586,7 @@ public function updateWarehouseStock(int $inventoryId, int $warehouseId, int $qu /** * Validar stock disponible */ - public function validateStock(int $inventoryId, int $warehouseId, int $requiredQuantity): void + public function validateStock(int $inventoryId, int $warehouseId, float $requiredQuantity): void { $record = InventoryWarehouse::where('inventory_id', $inventoryId) ->where('warehouse_id', $warehouseId) @@ -585,7 +602,7 @@ public function validateStock(int $inventoryId, int $warehouseId, int $requiredQ /** * Registrar movimiento de venta */ - public function recordSale(int $inventoryId, int $warehouseId, int $quantity, int $saleId): InventoryMovement + public function recordSale(int $inventoryId, int $warehouseId, float $quantity, int $saleId): InventoryMovement { return InventoryMovement::create([ 'inventory_id' => $inventoryId, @@ -602,7 +619,7 @@ public function recordSale(int $inventoryId, int $warehouseId, int $quantity, in /** * Registrar movimiento de devolución */ - public function recordReturn(int $inventoryId, int $warehouseId, int $quantity, int $returnId): InventoryMovement + public function recordReturn(int $inventoryId, int $warehouseId, float $quantity, int $returnId): InventoryMovement { return InventoryMovement::create([ 'inventory_id' => $inventoryId, diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 2de721d..87ae245 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -14,6 +14,7 @@ public function createProduct(array $data) 'sku' => $data['sku'], 'barcode' => $data['barcode'] ?? null, 'category_id' => $data['category_id'], + 'unit_of_measure_id' => $data['unit_of_measure_id'], 'track_serials' => $data['track_serials'] ?? false, ]); @@ -24,7 +25,7 @@ public function createProduct(array $data) 'tax' => $data['tax'] ?? 16.00, ]); - return $inventory->load(['category', 'price']); + return $inventory->load(['category', 'price', 'unitOfMeasure']); }); } @@ -37,6 +38,7 @@ public function updateProduct(Inventory $inventory, array $data) 'sku' => $data['sku'] ?? null, 'barcode' => $data['barcode'] ?? null, 'category_id' => $data['category_id'] ?? null, + 'unit_of_measure_id' => $data['unit_of_measure_id'] ?? null, 'track_serials' => $data['track_serials'] ?? null, ], fn($value) => $value !== null); @@ -58,7 +60,7 @@ public function updateProduct(Inventory $inventory, array $data) ); } - return $inventory->fresh(['category', 'price']); + return $inventory->fresh(['category', 'price', 'unitOfMeasure']); }); } } diff --git a/app/Services/WhatsAppAuthService.php b/app/Services/WhatsAppAuthService.php new file mode 100644 index 0000000..157eafa --- /dev/null +++ b/app/Services/WhatsAppAuthService.php @@ -0,0 +1,94 @@ +loginUrl = config('services.whatsapp.login_url', 'https://whatsapp.golsystems.mx/api/login'); + $this->email = config('services.whatsapp.auth_email', 'juan.zapata@golsystems.com.mx'); + $this->password = config('services.whatsapp.auth_password'); + } + + /** + * Obtener token de acceso + */ + public function getAccessToken(): string + { + if (!$this->email || !$this->password) { + throw new \Exception('Las credenciales de WhatsApp no están configuradas en .env'); + } + + return Cache::remember('whatsapp_access_token', 3000, function () { + try { + $response = Http::timeout(30) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]) + ->post($this->loginUrl, [ + 'email' => $this->email, + 'password' => $this->password, + ]); + + if (!$response->successful()) { + Log::channel('daily')->error('WhatsApp Auth Error', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + throw new \Exception('Error al autenticar con WhatsApp API: ' . $response->body()); + } + + $data = $response->json(); + + // La API puede retornar el token en diferentes formatos + // Ajusta según la respuesta real de la API + $token = $data['token'] + ?? $data['access_token'] + ?? $data['data']['token'] + ?? null; + + if (!$token) { + Log::channel('daily')->error('WhatsApp Auth Error: No token in response', [ + 'response' => $data + ]); + + throw new \Exception('La API de WhatsApp no retornó un token válido'); + } + + Log::channel('daily')->info('WhatsApp token obtenido exitosamente'); + + return $token; + + } catch (\Exception $e) { + Log::channel('daily')->error('WhatsApp Auth Exception', [ + 'error' => $e->getMessage(), + ]); + + throw $e; + } + }); + } + + /** + * Forzar renovación del token + */ + public function refreshToken(): string + { + Cache::forget('whatsapp_access_token'); + return $this->getAccessToken(); + } +} diff --git a/app/Services/WhatsappService.php b/app/Services/WhatsappService.php new file mode 100644 index 0000000..a836041 --- /dev/null +++ b/app/Services/WhatsappService.php @@ -0,0 +1,213 @@ +apiUrl = config('services.whatsapp.api_url', 'https://whatsapp.golsystems.mx/api/send-whatsapp'); + $this->orgId = config('services.whatsapp.org_id', 1); + $this->authService = $authService; + } + + /** + * Enviar documento por WhatsApp + */ + public function sendDocument( + string $phoneNumber, + string $documentUrl, + string $caption, + string $filename, + string $userEmail, + string $ticket, + string $customerName + ): array { + try { + // Obtener token de autenticación + $token = $this->authService->getAccessToken(); + + // Construir el mensaje de WhatsApp + $whatsappMessage = json_encode([ + 'messaging_product' => 'whatsapp', + 'recipient_type' => 'individual', + 'to' => $this->cleanPhoneNumber($phoneNumber), + 'type' => 'document', + 'document' => [ + 'link' => $documentUrl, + 'caption' => $caption, + 'filename' => $filename, + ], + ]); + + // Preparar payload completo + $payload = [ + 'message' => $whatsappMessage, + 'email' => $userEmail, + 'org_id' => $this->orgId, + 'ticket' => $ticket, + 'customer' => $customerName, + ]; + + // Enviar petición HTTP con token Bearer + $response = Http::timeout(30) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $token, + ]) + ->post($this->apiUrl, $payload); + + // Si el token expiró (401), renovar e intentar de nuevo + if ($response->status() === 401) { + Log::channel('daily')->warning('WhatsApp token expired, refreshing...'); + + $token = $this->authService->refreshToken(); + + $response = Http::timeout(30) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $token, + ]) + ->post($this->apiUrl, $payload); + } + + // Registrar en logs + Log::channel('daily')->info('WhatsApp message sent', [ + 'phone' => $phoneNumber, + 'ticket' => $ticket, + 'customer' => $customerName, + 'status_code' => $response->status(), + ]); + + if ($response->successful()) { + return [ + 'success' => true, + 'message' => 'Mensaje enviado correctamente', + 'data' => $response->json(), + ]; + } + + // Log detallado del error + Log::channel('daily')->error('WhatsApp API error', [ + 'phone' => $phoneNumber, + 'status_code' => $response->status(), + 'error_body' => $response->body(), + ]); + + return [ + 'success' => false, + 'message' => 'Error al enviar el mensaje', + 'error' => $response->body(), + 'status_code' => $response->status(), + ]; + + } catch (\Exception $e) { + Log::channel('daily')->error('WhatsApp sending failed', [ + 'phone' => $phoneNumber, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return [ + 'success' => false, + 'message' => 'Error al conectar con el servicio de WhatsApp', + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Enviar factura por WhatsApp + */ + public function sendInvoice( + string $phoneNumber, + string $pdfUrl, + ?string $xmlUrl, + 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, + ticket: $invoiceNumber, + customerName: $customerName + ); + + // Si hay XML y el PDF se envió correctamente, enviarlo también + if ($xmlUrl && $pdfResult['success']) { + $xmlResult = $this->sendDocument( + phoneNumber: $phoneNumber, + documentUrl: $xmlUrl, + caption: "XML - Factura {$invoiceNumber}", + filename: "Factura_{$invoiceNumber}.xml", + userEmail: $userEmail, + ticket: "{$invoiceNumber}_XML", + customerName: $customerName + ); + + return [ + 'success' => $pdfResult['success'] && $xmlResult['success'], + 'pdf' => $pdfResult, + 'xml' => $xmlResult, + ]; + } + + return $pdfResult; + } + + /** + * Enviar ticket de venta por WhatsApp + */ + public function sendSaleTicket( + string $phoneNumber, + string $ticketUrl, + 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, + ticket: $saleNumber, + customerName: $customerName + ); + } + + /** + * Limpiar número de teléfono + */ + protected function cleanPhoneNumber(string $phone): string + { + // Eliminar todo excepto números + $cleaned = preg_replace('/[^0-9]/', '', $phone); + + // Asegurar que tenga código de país (52 para México) + if (strlen($cleaned) === 10) { + $cleaned = '52' . $cleaned; + } + + return $cleaned; + } +} diff --git a/config/services.php b/config/services.php index 27a3617..f8161be 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,12 @@ ], ], + 'whatsapp' => [ + 'login_url' => env('WHATSAPP_LOGIN_URL', 'https://whatsapp.golsystems.mx/api/login'), + 'api_url' => env('WHATSAPP_API_URL', 'https://whatsapp.golsystems.mx/api/send-whatsapp'), + 'org_id' => env('WHATSAPP_ORG_ID', 1), + 'auth_email' => env('WHATSAPP_AUTH_EMAIL', 'juan.zapata@golsystems.com.mx'), + 'auth_password' => env('WHATSAPP_AUTH_PASSWORD'), + ], + ]; diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index d360549..1a83a6a 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -92,7 +92,7 @@ public function run(): void $salesCreate = $this->onCreate('sales', 'Crear registros', $salesType, 'api'); $salesCancel = $this->onPermission('sales.cancel', 'Cancelar venta', $salesType, 'api'); - // Permisos de Inventario (solo lectura) + // Permisos de Inventario $inventoryType = PermissionType::firstOrCreate([ 'name' => 'Inventario' ]); @@ -104,6 +104,26 @@ public function run(): void $inventoryDestroy = $this->onDestroy('inventario', 'Eliminar registro', $inventoryType, 'api'); $inventoryImport = $this->onPermission('inventario.import', 'Importar productos desde Excel', $inventoryType, 'api'); + // Permisos de Categorías + $categoriesType = PermissionType::firstOrCreate([ + 'name' => 'Categorías de productos' + ]); + + $categoriesIndex = $this->onIndex('categorias', 'Mostrar datos', $categoriesType, 'api'); + $categoriesCreate = $this->onCreate('categorias', 'Crear registros', $categoriesType, 'api'); + $categoriesEdit = $this->onEdit('categorias', 'Actualizar registro', $categoriesType, 'api'); + $categoriesDestroy = $this->onDestroy('categorias', 'Eliminar registro', $categoriesType, 'api'); + + //Permisos de Unidades de medida + $unitsType = PermissionType::firstOrCreate([ + 'name' => 'Unidades de medida' + ]); + + $unitsIndex = $this->onIndex('units', 'Mostrar datos', $unitsType, 'api'); + $unitsCreate = $this->onCreate('units', 'Crear registros', $unitsType, 'api'); + $unitsEdit = $this->onEdit('units', 'Actualizar registro', $unitsType, 'api'); + $unitsDestroy = $this->onDestroy('units', 'Eliminar registro', $unitsType, 'api'); + // Permisos de Clientes $clientsType = PermissionType::firstOrCreate([ @@ -199,6 +219,10 @@ public function run(): void $inventoryCreate, $inventoryEdit, $inventoryDestroy, + $categoriesIndex, + $categoriesCreate, + $categoriesEdit, + $categoriesDestroy, $clientIndex, $clientCreate, $clientEdit, @@ -220,7 +244,11 @@ public function run(): void $movementsIndex, $movementsCreate, $movementsEdit, - $movementsDestroy + $movementsDestroy, + $unitsIndex, + $unitsCreate, + $unitsEdit, + $unitsDestroy ); //Operador PDV (solo permisos de operación de caja y ventas) diff --git a/routes/api.php b/routes/api.php index 6191b56..b28e2b3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,7 +15,9 @@ use App\Http\Controllers\App\InvoiceRequestController; use App\Http\Controllers\App\InventoryMovementController; use App\Http\Controllers\App\KardexController; +use App\Http\Controllers\App\UnitOfMeasurementController; use App\Http\Controllers\App\WarehouseController; +use App\Http\Controllers\App\WhatsappController; use Illuminate\Support\Facades\Route; /** @@ -59,6 +61,16 @@ Route::post('/traspaso', [InventoryMovementController::class, 'transfer']); }); + // UNIDADES DE MEDIDA + Route::prefix('unidades-medida')->group(function () { + Route::get('/', [UnitOfMeasurementController::class, 'index']); + Route::get('/active', [UnitOfMeasurementController::class, 'active']); + Route::get('/{unidad}', [UnitOfMeasurementController::class, 'show']); + Route::post('/', [UnitOfMeasurementController::class, 'store']); + Route::put('/{unidad}', [UnitOfMeasurementController::class, 'update']); + Route::delete('/{unidad}', [UnitOfMeasurementController::class, 'destroy']); + }); + //CATEGORIAS Route::resource('categorias', CategoryController::class); @@ -129,11 +141,19 @@ Route::put('/{id}/reject', [InvoiceRequestController::class, 'reject']); Route::post('/{id}/upload', [InvoiceRequestController::class, 'uploadInvoiceFile']); }); + + // WHATSAPP + Route::prefix('whatsapp')->group(function () { + Route::post('/send-document', [WhatsappController::class, 'sendDocument']); + Route::post('/send-invoice', [WhatsappController::class, 'sendInvoice']); + Route::post('/send-ticket', [WhatsappController::class, 'sendSaleTicket']); + }); }); /** Rutas públicas */ // Formulario de datos fiscales para facturación Route::prefix('facturacion')->group(function () { + Route::get('/check-rfc', [InvoiceController::class, 'checkRfc']); Route::get('/{invoiceNumber}', [InvoiceController::class, 'show']); Route::post('/{invoiceNumber}', [InvoiceController::class, 'store']); });