feat: unidades de medida y mensajería WhatsApp

- Implementa CRUD de unidades y soporte para decimales en inventario.
- Integra servicios de WhatsApp para envío de documentos y auth.
- Ajusta validación de series y permisos (RoleSeeder).
This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-10 00:06:24 -06:00
parent 41a84d05a0
commit 562397402c
18 changed files with 1013 additions and 103 deletions

View File

@ -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=

View File

@ -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);

View File

@ -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)

View File

@ -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
// 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
*/

View File

@ -0,0 +1,76 @@
<?php namespace App\Http\Controllers\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Http\Controllers\Controller;
use App\Http\Requests\App\UnitOfMeasurementStoreRequest;
use App\Http\Requests\App\UnitOfMeasurementUpdateRequest;
use App\Models\UnitOfMeasurement;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Descripción
*/
class UnitOfMeasurementController extends Controller
{
public function index()
{
$units = UnitOfMeasurement::orderBy('name')->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.'
]);
}
}

View File

@ -0,0 +1,121 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Services\WhatsappService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class WhatsappController extends Controller
{
public function __construct(
protected WhatsappService $whatsAppService
) {}
/**
* Enviar documento por WhatsApp
*/
public function sendDocument(Request $request)
{
$validated = $request->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
]);
}
}

View File

@ -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'."
);
}
}
});
}
}

View File

@ -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'."
);
}
}
});
}
}

View File

@ -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'."
);
}
}
});
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class UnitOfMeasurementStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['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.',
];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Requests\App;
use App\Models\UnitOfMeasurement;
use Illuminate\Foundation\Http\FormRequest;
class UnitOfMeasurementUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$unitId = $this->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'
);
}
}
});
}
}

View File

@ -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,

View File

@ -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']);
});
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Servicio de autenticación para WhatsApp API
*/
class WhatsAppAuthService
{
protected string $loginUrl;
protected string $email;
protected string $password;
public function __construct()
{
$this->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();
}
}

View File

@ -0,0 +1,213 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Servicio para envío de mensajes de WhatsApp
*/
class WhatsAppService
{
protected string $apiUrl;
protected int $orgId;
protected WhatsAppAuthService $authService;
public function __construct(WhatsAppAuthService $authService)
{
$this->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;
}
}

View File

@ -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'),
],
];

View File

@ -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)

View File

@ -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']);
});