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:
parent
41a84d05a0
commit
562397402c
@ -87,3 +87,10 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
|||||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
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=
|
||||||
|
|||||||
@ -481,6 +481,7 @@ public function inventoryReport(Request $request)
|
|||||||
$query = Inventory::with([
|
$query = Inventory::with([
|
||||||
'category:id,name',
|
'category:id,name',
|
||||||
'price:inventory_id,cost,retail_price',
|
'price:inventory_id,cost,retail_price',
|
||||||
|
'unitOfMeasure:id,abbreviation',
|
||||||
'serials'
|
'serials'
|
||||||
])
|
])
|
||||||
->when($request->q, function($q) use ($request) {
|
->when($request->q, function($q) use ($request) {
|
||||||
@ -535,6 +536,7 @@ public function inventoryReport(Request $request)
|
|||||||
'sku' => $inventory->sku ?? 'N/A',
|
'sku' => $inventory->sku ?? 'N/A',
|
||||||
'name' => $inventory->name,
|
'name' => $inventory->name,
|
||||||
'category' => $inventory->category?->name ?? 'Sin categoría',
|
'category' => $inventory->category?->name ?? 'Sin categoría',
|
||||||
|
'unit' => $inventory->unitOfMeasure?->abbreviation ?? 'u',
|
||||||
'stock' => $inventory->stock,
|
'stock' => $inventory->stock,
|
||||||
'quantity_sold' => $quantitySold,
|
'quantity_sold' => $quantitySold,
|
||||||
'serials_total' => $serialsTotal,
|
'serials_total' => $serialsTotal,
|
||||||
@ -684,25 +686,26 @@ public function inventoryReport(Request $request)
|
|||||||
'B' => 'SKU',
|
'B' => 'SKU',
|
||||||
'C' => 'NOMBRE',
|
'C' => 'NOMBRE',
|
||||||
'D' => 'CATEGORÍA',
|
'D' => 'CATEGORÍA',
|
||||||
'E' => "STOCK\nDISPONIBLE",
|
'E' => 'UNIDAD',
|
||||||
'F' => "CANTIDAD\nVENDIDA",
|
'F' => "STOCK\nDISPONIBLE",
|
||||||
'G' => "SERIALES\nTOTALES",
|
'G' => "CANTIDAD\nVENDIDA",
|
||||||
'H' => "SERIALES\nDISPONIBLES",
|
'H' => "SERIALES\nTOTALES",
|
||||||
'I' => "SERIALES\nVENDIDOS",
|
'I' => "SERIALES\nDISPONIBLES",
|
||||||
'J' => "SERIALES\nDEVUELTOS",
|
'J' => "SERIALES\nVENDIDOS",
|
||||||
'K' => "COSTO\nUNITARIO",
|
'K' => "SERIALES\nDEVUELTOS",
|
||||||
'L' => "PRECIO\nVENTA",
|
'L' => "COSTO\nUNITARIO",
|
||||||
'M' => "TOTAL\nVENDIDO",
|
'M' => "PRECIO\nVENTA",
|
||||||
'N' => "VALOR\nINVENTARIO",
|
'N' => "TOTAL\nVENDIDO",
|
||||||
'O' => "UTILIDAD\nPOR UNIDAD",
|
'O' => "VALOR\nINVENTARIO",
|
||||||
'P' => "UTILIDAD\nTOTAL",
|
'P' => "UTILIDAD\nPOR UNIDAD",
|
||||||
|
'Q' => "UTILIDAD\nTOTAL",
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($headers as $col => $text) {
|
foreach ($headers as $col => $text) {
|
||||||
$sheet->setCellValue("{$col}{$h}", $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);
|
$sheet->getRowDimension($h)->setRowHeight(35);
|
||||||
|
|
||||||
// --- LLENADO DE DATOS ---
|
// --- LLENADO DE DATOS ---
|
||||||
@ -715,27 +718,28 @@ public function inventoryReport(Request $request)
|
|||||||
$sheet->setCellValue('B' . $row, $item['sku']);
|
$sheet->setCellValue('B' . $row, $item['sku']);
|
||||||
$sheet->setCellValue('C' . $row, $item['name']);
|
$sheet->setCellValue('C' . $row, $item['name']);
|
||||||
$sheet->setCellValue('D' . $row, $item['category']);
|
$sheet->setCellValue('D' . $row, $item['category']);
|
||||||
|
$sheet->setCellValue('E' . $row, $item['unit']);
|
||||||
|
|
||||||
// NÚMEROS SIN FORMATO
|
// NÚMEROS SIN FORMATO
|
||||||
$sheet->setCellValueExplicit('E' . $row, (int) $item['stock'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('F' . $row, (float) $item['stock'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('F' . $row, (int) $item['quantity_sold'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('G' . $row, (float) $item['quantity_sold'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('G' . $row, (int) $item['serials_total'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('H' . $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_available'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('I' . $row, (int) $item['serials_sold'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('J' . $row, (int) $item['serials_sold'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('J' . $row, (int) $item['serials_returned'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('K' . $row, (int) $item['serials_returned'], DataType::TYPE_NUMERIC);
|
||||||
|
|
||||||
// NÚMEROS CON FORMATO MONEDA
|
// NÚMEROS CON FORMATO MONEDA
|
||||||
$sheet->setCellValueExplicit('K' . $row, (float) $item['cost'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('L' . $row, (float) $item['cost'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('L' . $row, (float) $item['price'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('M' . $row, (float) $item['price'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('M' . $row, (float) $item['total_sold'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('N' . $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['inventory_value'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('O' . $row, (float) $item['unit_profit'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('P' . $row, (float) $item['unit_profit'], DataType::TYPE_NUMERIC);
|
||||||
$sheet->setCellValueExplicit('P' . $row, (float) $item['total_profit'], DataType::TYPE_NUMERIC);
|
$sheet->setCellValueExplicit('Q' . $row, (float) $item['total_profit'], DataType::TYPE_NUMERIC);
|
||||||
|
|
||||||
$totalProfit += $item['total_profit'];
|
$totalProfit += $item['total_profit'];
|
||||||
|
|
||||||
// Estilos de fila
|
// Estilos de fila
|
||||||
$sheet->getStyle("A{$row}:P{$row}")->applyFromArray([
|
$sheet->getStyle("A{$row}:Q{$row}")->applyFromArray([
|
||||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||||
'font' => ['size' => 10]
|
'font' => ['size' => 10]
|
||||||
@ -750,20 +754,21 @@ public function inventoryReport(Request $request)
|
|||||||
$sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
$sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||||
$sheet->getStyle("I{$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("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||||
|
$sheet->getStyle("K{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||||
|
|
||||||
// Formato moneda para columnas K-P
|
// Formato moneda para columnas L-Q
|
||||||
$sheet->getStyle("K{$row}:P{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
|
$sheet->getStyle("L{$row}:Q{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
|
||||||
|
|
||||||
// Color de utilidad (verde si es positiva)
|
// Color de utilidad (verde si es positiva)
|
||||||
if ($item['total_profit'] > 0) {
|
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) {
|
} 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
|
// Color alterno de filas
|
||||||
if ($i % 2 == 0) {
|
if ($i % 2 == 0) {
|
||||||
$sheet->getStyle("A{$row}:P{$row}")->getFill()
|
$sheet->getStyle("A{$row}:Q{$row}")->getFill()
|
||||||
->setFillType(Fill::FILL_SOLID)
|
->setFillType(Fill::FILL_SOLID)
|
||||||
->getStartColor()->setRGB('F2F2F2');
|
->getStartColor()->setRGB('F2F2F2');
|
||||||
}
|
}
|
||||||
@ -773,22 +778,23 @@ public function inventoryReport(Request $request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- ANCHOS DE COLUMNA ---
|
// --- ANCHOS DE COLUMNA ---
|
||||||
$sheet->getColumnDimension('A')->setWidth(18);
|
$sheet->getColumnDimension('A')->setWidth(6);
|
||||||
$sheet->getColumnDimension('B')->setWidth(18);
|
$sheet->getColumnDimension('B')->setWidth(15);
|
||||||
$sheet->getColumnDimension('C')->setWidth(18);
|
$sheet->getColumnDimension('C')->setWidth(25);
|
||||||
$sheet->getColumnDimension('D')->setWidth(18);
|
$sheet->getColumnDimension('D')->setWidth(18);
|
||||||
$sheet->getColumnDimension('E')->setWidth(18);
|
$sheet->getColumnDimension('E')->setWidth(10);
|
||||||
$sheet->getColumnDimension('F')->setWidth(18);
|
$sheet->getColumnDimension('F')->setWidth(15);
|
||||||
$sheet->getColumnDimension('G')->setWidth(18);
|
$sheet->getColumnDimension('G')->setWidth(15);
|
||||||
$sheet->getColumnDimension('H')->setWidth(18);
|
$sheet->getColumnDimension('H')->setWidth(15);
|
||||||
$sheet->getColumnDimension('I')->setWidth(18);
|
$sheet->getColumnDimension('I')->setWidth(15);
|
||||||
$sheet->getColumnDimension('J')->setWidth(18);
|
$sheet->getColumnDimension('J')->setWidth(15);
|
||||||
$sheet->getColumnDimension('K')->setWidth(18);
|
$sheet->getColumnDimension('K')->setWidth(15);
|
||||||
$sheet->getColumnDimension('L')->setWidth(18);
|
$sheet->getColumnDimension('L')->setWidth(15);
|
||||||
$sheet->getColumnDimension('M')->setWidth(18);
|
$sheet->getColumnDimension('M')->setWidth(15);
|
||||||
$sheet->getColumnDimension('N')->setWidth(18);
|
$sheet->getColumnDimension('N')->setWidth(18);
|
||||||
$sheet->getColumnDimension('O')->setWidth(18);
|
$sheet->getColumnDimension('O')->setWidth(18);
|
||||||
$sheet->getColumnDimension('P')->setWidth(18);
|
$sheet->getColumnDimension('P')->setWidth(18);
|
||||||
|
$sheet->getColumnDimension('Q')->setWidth(18);
|
||||||
|
|
||||||
$writer = new Xlsx($spreadsheet);
|
$writer = new Xlsx($spreadsheet);
|
||||||
$writer->save($filePath);
|
$writer->save($filePath);
|
||||||
|
|||||||
@ -24,7 +24,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$products = Inventory::with(['category', 'price'])->withCount('serials')
|
$products = Inventory::with(['category', 'price', 'unitOfMeasure'])->withCount('serials')
|
||||||
->where('is_active', true);
|
->where('is_active', true);
|
||||||
|
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ public function index(Request $request)
|
|||||||
public function show(Inventory $inventario)
|
public function show(Inventory $inventario)
|
||||||
{
|
{
|
||||||
return ApiResponse::OK->response([
|
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)
|
public function getProductsByWarehouse(Request $request, int $warehouseId)
|
||||||
{
|
{
|
||||||
$query = Inventory::query()
|
$query = Inventory::query()
|
||||||
->with(['category', 'price'])
|
->with(['category', 'price', 'unitOfMeasure'])
|
||||||
->where('is_active', true)
|
->where('is_active', true)
|
||||||
->whereHas('warehouses', function ($q) use ($warehouseId) {
|
->whereHas('warehouses', function ($q) use ($warehouseId) {
|
||||||
$q->where('warehouse_id', $warehouseId)
|
$q->where('warehouse_id', $warehouseId)
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\InvoiceRequest;
|
use App\Models\InvoiceRequest;
|
||||||
use App\Models\Sale;
|
use App\Models\Sale;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||||
|
|
||||||
class InvoiceController extends Controller
|
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([
|
return ApiResponse::OK->response([
|
||||||
'sale' => $this->formatSaleData($sale),
|
'sale' => $this->formatSaleData($sale),
|
||||||
'client' => $sale->client,
|
'client' => $sale->client,
|
||||||
|
'existing_client' => $existingClient,
|
||||||
'invoice_requests' => $sale->invoiceRequests,
|
'invoice_requests' => $sale->invoiceRequests,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guarda los datos fiscales del cliente para la venta.
|
* 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
|
// Buscar si ya existe un cliente con ese RFC
|
||||||
$client = Client::where('rfc', strtoupper($request->rfc))->first();
|
$client = Client::where('rfc', strtoupper($request->rfc))->first();
|
||||||
|
|
||||||
if ($client) {
|
if ($client) {
|
||||||
// Actualizar datos del cliente existente
|
// Solo actualizar campos que el usuario proporciona explícitamente
|
||||||
$client->update([
|
$updateData = [];
|
||||||
'name' => $request->name,
|
|
||||||
'email' => $request->email,
|
// Actualizar nombre solo si es diferente y fue proporcionado
|
||||||
'phone' => $request->phone ?? $client->phone,
|
if ($request->filled('name') && $request->name !== $client->name) {
|
||||||
'address' => $request->address ?? $client->address,
|
$updateData['name'] = $request->name;
|
||||||
'razon_social' => $request->razon_social,
|
}
|
||||||
'regimen_fiscal' => $request->regimen_fiscal,
|
|
||||||
'cp_fiscal' => $request->cp_fiscal,
|
// Actualizar email solo si es diferente y fue proporcionado
|
||||||
'uso_cfdi' => $request->uso_cfdi,
|
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 {
|
} else {
|
||||||
// Crear nuevo cliente
|
// Crear nuevo cliente
|
||||||
$client = Client::create([
|
$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]);
|
$sale->update(['client_id' => $client->id]);
|
||||||
|
}
|
||||||
|
|
||||||
// Recargar relaciones
|
// Crear solicitud de facturación
|
||||||
$invoiceRequest = InvoiceRequest::create([
|
$invoiceRequest = InvoiceRequest::create([
|
||||||
'sale_id' => $sale->id,
|
'sale_id' => $sale->id,
|
||||||
'client_id' => $client->id,
|
'client_id' => $client->id,
|
||||||
@ -131,12 +163,47 @@ public function store(InvoiceStoreRequest $request, string $invoiceNumber)
|
|||||||
|
|
||||||
return ApiResponse::OK->response([
|
return ApiResponse::OK->response([
|
||||||
'message' => 'Solicitud de facturación guardada correctamente',
|
'message' => 'Solicitud de facturación guardada correctamente',
|
||||||
'client' => $client,
|
'client' => $client->fresh(),
|
||||||
'sale' => $this->formatSaleData($sale),
|
'sale' => $this->formatSaleData($sale->fresh('details.serials')),
|
||||||
'invoice_request' => $invoiceRequest,
|
'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
|
* Formatear datos de la venta incluyendo números de serie
|
||||||
*/
|
*/
|
||||||
|
|||||||
76
app/Http/Controllers/App/UnitOfMeasurementController.php
Normal file
76
app/Http/Controllers/App/UnitOfMeasurementController.php
Normal 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.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Http/Controllers/App/WhatsappController.php
Normal file
121
app/Http/Controllers/App/WhatsappController.php
Normal 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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\App;
|
namespace App\Http\Requests\App;
|
||||||
|
|
||||||
|
use App\Models\Inventory;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class InventoryEntryRequest extends FormRequest
|
class InventoryEntryRequest extends FormRequest
|
||||||
@ -22,7 +23,7 @@ public function rules(): array
|
|||||||
// Validación del array de productos
|
// Validación del array de productos
|
||||||
'products' => 'required|array|min:1',
|
'products' => 'required|array|min:1',
|
||||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
'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.*.unit_cost' => 'required|numeric|min:0',
|
||||||
'products.*.serial_numbers' => 'nullable|array',
|
'products.*.serial_numbers' => 'nullable|array',
|
||||||
'products.*.serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
|
'products.*.serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
|
||||||
@ -32,7 +33,7 @@ public function rules(): array
|
|||||||
return [
|
return [
|
||||||
'inventory_id' => 'required|exists:inventories,id',
|
'inventory_id' => 'required|exists:inventories,id',
|
||||||
'warehouse_id' => 'required|exists:warehouses,id',
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
'quantity' => 'required|integer|min:1',
|
'quantity' => 'required|numeric|min:0.001',
|
||||||
'unit_cost' => 'required|numeric|min:0',
|
'unit_cost' => 'required|numeric|min:0',
|
||||||
'invoice_reference' => 'required|string|max:255',
|
'invoice_reference' => 'required|string|max:255',
|
||||||
'notes' => 'nullable|string|max:1000',
|
'notes' => 'nullable|string|max:1000',
|
||||||
@ -77,4 +78,50 @@ public function messages(): array
|
|||||||
'invoice_reference.required' => 'La referencia de la factura es requerida',
|
'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'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\App;
|
namespace App\Http\Requests\App;
|
||||||
|
|
||||||
|
use App\Models\Inventory;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class InventoryExitRequest extends FormRequest
|
class InventoryExitRequest extends FormRequest
|
||||||
@ -22,7 +23,7 @@ public function rules(): array
|
|||||||
// Validación del array de productos
|
// Validación del array de productos
|
||||||
'products' => 'required|array|min:1',
|
'products' => 'required|array|min:1',
|
||||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
'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' => 'nullable|array',
|
||||||
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
||||||
];
|
];
|
||||||
@ -32,7 +33,7 @@ public function rules(): array
|
|||||||
return [
|
return [
|
||||||
'inventory_id' => 'required|exists:inventories,id',
|
'inventory_id' => 'required|exists:inventories,id',
|
||||||
'warehouse_id' => 'required|exists:warehouses,id',
|
'warehouse_id' => 'required|exists:warehouses,id',
|
||||||
'quantity' => 'required|integer|min:1',
|
'quantity' => 'required|numeric|min:0.001',
|
||||||
'notes' => 'nullable|string|max:1000',
|
'notes' => 'nullable|string|max:1000',
|
||||||
'serial_numbers' => 'nullable|array',
|
'serial_numbers' => 'nullable|array',
|
||||||
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
'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',
|
'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'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\App;
|
namespace App\Http\Requests\App;
|
||||||
|
|
||||||
|
use App\Models\Inventory;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class InventoryTransferRequest extends FormRequest
|
class InventoryTransferRequest extends FormRequest
|
||||||
@ -23,7 +24,7 @@ public function rules(): array
|
|||||||
// Validación del array de productos
|
// Validación del array de productos
|
||||||
'products' => 'required|array|min:1',
|
'products' => 'required|array|min:1',
|
||||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
'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' => 'nullable|array',
|
||||||
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
||||||
];
|
];
|
||||||
@ -34,7 +35,7 @@ public function rules(): array
|
|||||||
'inventory_id' => 'required|exists:inventories,id',
|
'inventory_id' => 'required|exists:inventories,id',
|
||||||
'warehouse_from_id' => 'required|exists:warehouses,id',
|
'warehouse_from_id' => 'required|exists:warehouses,id',
|
||||||
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_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',
|
'notes' => 'nullable|string|max:1000',
|
||||||
'serial_numbers' => 'nullable|array',
|
'serial_numbers' => 'nullable|array',
|
||||||
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
|
'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',
|
'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'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Http/Requests/App/UnitOfMeasurementStoreRequest.php
Normal file
41
app/Http/Requests/App/UnitOfMeasurementStoreRequest.php
Normal 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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php
Normal file
71
app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,12 +24,17 @@ public function entry(array $data): InventoryMovement
|
|||||||
return DB::transaction(function () use ($data) {
|
return DB::transaction(function () use ($data) {
|
||||||
$inventory = Inventory::findOrFail($data['inventory_id']);
|
$inventory = Inventory::findOrFail($data['inventory_id']);
|
||||||
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
|
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
|
||||||
$quantity = (int) $data['quantity'];
|
$quantity = (float) $data['quantity'];
|
||||||
$unitCost = (float) $data['unit_cost'];
|
$unitCost = (float) $data['unit_cost'];
|
||||||
$serialNumbers = $data['serial_numbers'] ?? null;
|
$serialNumbers = $data['serial_numbers'] ?? null;
|
||||||
|
|
||||||
// Validar seriales si el producto los requiere
|
// cargar la unidad de medida
|
||||||
if ($inventory->track_serials) {
|
$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)) {
|
if (empty($serialNumbers)) {
|
||||||
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
|
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
|
// Obtener stock actual para calcular costo promedio ponderado
|
||||||
$curentStock = $inventory->stock;
|
$currentStock = $inventory->stock;
|
||||||
$currentCost = $inventory->price?->cost ?? 0.00;
|
$currentCost = $inventory->price?->cost ?? 0.00;
|
||||||
|
|
||||||
// Calcular nuevo costo promedio ponderado
|
// Calcular nuevo costo promedio ponderado
|
||||||
$newCost = $this->calculateWeightedAverageCost(
|
$newCost = $this->calculateWeightedAverageCost(
|
||||||
$curentStock,
|
$currentStock,
|
||||||
$currentCost,
|
$currentCost,
|
||||||
$quantity,
|
$quantity,
|
||||||
$unitCost
|
$unitCost
|
||||||
@ -63,8 +68,8 @@ public function entry(array $data): InventoryMovement
|
|||||||
// Actualizar costo en prices
|
// Actualizar costo en prices
|
||||||
$this->updateProductCost($inventory, $newCost);
|
$this->updateProductCost($inventory, $newCost);
|
||||||
|
|
||||||
// Crear seriales si se proporcionan
|
// Solo crear seriales si se proporcionaron
|
||||||
if (!empty($serialNumbers)) {
|
if ($requiresSerials && !empty($serialNumbers)) {
|
||||||
foreach ($serialNumbers as $serialNumber) {
|
foreach ($serialNumbers as $serialNumber) {
|
||||||
InventorySerial::create([
|
InventorySerial::create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
@ -82,7 +87,7 @@ public function entry(array $data): InventoryMovement
|
|||||||
// Sincronizar stock desde seriales
|
// Sincronizar stock desde seriales
|
||||||
$inventory->syncStock();
|
$inventory->syncStock();
|
||||||
} else {
|
} else {
|
||||||
// Sin seriales, actualizar stock manualmente
|
// **SIN SERIALES**: Actualizar stock manualmente (permite decimales)
|
||||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +117,7 @@ public function bulkEntry(array $data): array
|
|||||||
|
|
||||||
foreach ($data['products'] as $productData) {
|
foreach ($data['products'] as $productData) {
|
||||||
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
||||||
$quantity = (int) $productData['quantity'];
|
$quantity = (float) $productData['quantity'];
|
||||||
$unitCost = (float)$productData['unit_cost'];
|
$unitCost = (float)$productData['unit_cost'];
|
||||||
$serialNumbers = $productData['serial_numbers'] ?? null;
|
$serialNumbers = $productData['serial_numbers'] ?? null;
|
||||||
|
|
||||||
@ -207,12 +212,15 @@ public function bulkExit(array $data): array
|
|||||||
$movements = [];
|
$movements = [];
|
||||||
|
|
||||||
foreach ($data['products'] as $productData) {
|
foreach ($data['products'] as $productData) {
|
||||||
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
$inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']);
|
||||||
$quantity = (int) $productData['quantity'];
|
$quantity = (float) $productData['quantity'];
|
||||||
$serialNumbers = (array) $productData['serial_numbers'] ?? null;
|
$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
|
// Validar y procesar seriales si el producto los requiere
|
||||||
if ($inventory->track_serials) {
|
if ($requiresSerials) {
|
||||||
if (empty($serialNumbers)) {
|
if (empty($serialNumbers)) {
|
||||||
throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales.");
|
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) {
|
foreach ($data['products'] as $productData) {
|
||||||
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
||||||
$quantity = (int) $productData['quantity'];
|
$quantity = (float) $productData['quantity'];
|
||||||
$serialNumbers = (array) ($productData['serial_numbers'] ?? null);
|
$serialNumbers = (array) ($productData['serial_numbers'] ?? null);
|
||||||
|
|
||||||
// Validar y procesar seriales si el producto los requiere
|
// Validar y procesar seriales si el producto los requiere
|
||||||
@ -370,9 +378,9 @@ public function bulkTransfer(array $data): array
|
|||||||
* Calcular costo promedio ponderado
|
* Calcular costo promedio ponderado
|
||||||
*/
|
*/
|
||||||
protected function calculateWeightedAverageCost(
|
protected function calculateWeightedAverageCost(
|
||||||
int $currentStock,
|
float $currentStock,
|
||||||
float $currentCost,
|
float $currentCost,
|
||||||
int $entryQuantity,
|
float $entryQuantity,
|
||||||
float $entryCost
|
float $entryCost
|
||||||
): float {
|
): float {
|
||||||
if ($currentStock <= 0) {
|
if ($currentStock <= 0) {
|
||||||
@ -399,11 +407,17 @@ public function exit(array $data): InventoryMovement
|
|||||||
return DB::transaction(function () use ($data) {
|
return DB::transaction(function () use ($data) {
|
||||||
$inventory = Inventory::findOrFail($data['inventory_id']);
|
$inventory = Inventory::findOrFail($data['inventory_id']);
|
||||||
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
|
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
|
||||||
$quantity = (int) $data['quantity'];
|
$quantity = (float) $data['quantity'];
|
||||||
$serialNumbers = (array) ($data['serial_numbers'] ?? null);
|
$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
|
// Validar y procesar seriales si el producto los requiere
|
||||||
if ($inventory->track_serials) {
|
if ($requiresSerials) {
|
||||||
if (empty($serialNumbers)) {
|
if (empty($serialNumbers)) {
|
||||||
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
|
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)
|
// Eliminar los seriales (salida definitiva)
|
||||||
InventorySerial::whereIn('serial_number', $serialNumbers)
|
InventorySerial::whereIn('serial_numbers', $serialNumbers)
|
||||||
->where('inventory_id', $inventory->id)
|
->where('inventory_id', $inventory->id)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
// Sincronizar stock desde seriales
|
// Sincronizar stock desde seriales
|
||||||
$inventory->syncStock();
|
$inventory->syncStock();
|
||||||
} else {
|
} else {
|
||||||
// Sin seriales, validar y decrementar stock manualmente
|
// **SIN SERIALES**: Validar y decrementar stock manualmente
|
||||||
$this->validateStock($inventory->id, $warehouse->id, $quantity);
|
$this->validateStock($inventory->id, $warehouse->id, $quantity);
|
||||||
$this->updateWarehouseStock($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
|
public function transfer(array $data): InventoryMovement
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($data) {
|
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']);
|
$warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']);
|
||||||
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
|
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
|
||||||
$quantity = (int) $data['quantity'];
|
$quantity = (float) $data['quantity'];
|
||||||
$serialNumbers = (array) ($data['serial_numbers'] ?? null);
|
$serialNumbers = $data['serial_numbers'] ?? null;
|
||||||
|
|
||||||
// Validar que no sea el mismo almacén
|
// Validar que no sea el mismo almacén
|
||||||
if ($warehouseFrom->id === $warehouseTo->id) {
|
if ($warehouseFrom->id === $warehouseTo->id) {
|
||||||
throw new \Exception('No se puede traspasar al mismo almacén.');
|
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
|
// Validar y procesar seriales si el producto los requiere
|
||||||
if ($inventory->track_serials) {
|
if ($requiresSerials) {
|
||||||
if (empty($serialNumbers)) {
|
if (empty($serialNumbers)) {
|
||||||
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
|
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
|
* 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(
|
$record = InventoryWarehouse::firstOrCreate(
|
||||||
[
|
[
|
||||||
@ -569,7 +586,7 @@ public function updateWarehouseStock(int $inventoryId, int $warehouseId, int $qu
|
|||||||
/**
|
/**
|
||||||
* Validar stock disponible
|
* 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)
|
$record = InventoryWarehouse::where('inventory_id', $inventoryId)
|
||||||
->where('warehouse_id', $warehouseId)
|
->where('warehouse_id', $warehouseId)
|
||||||
@ -585,7 +602,7 @@ public function validateStock(int $inventoryId, int $warehouseId, int $requiredQ
|
|||||||
/**
|
/**
|
||||||
* Registrar movimiento de venta
|
* 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([
|
return InventoryMovement::create([
|
||||||
'inventory_id' => $inventoryId,
|
'inventory_id' => $inventoryId,
|
||||||
@ -602,7 +619,7 @@ public function recordSale(int $inventoryId, int $warehouseId, int $quantity, in
|
|||||||
/**
|
/**
|
||||||
* Registrar movimiento de devolución
|
* 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([
|
return InventoryMovement::create([
|
||||||
'inventory_id' => $inventoryId,
|
'inventory_id' => $inventoryId,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ public function createProduct(array $data)
|
|||||||
'sku' => $data['sku'],
|
'sku' => $data['sku'],
|
||||||
'barcode' => $data['barcode'] ?? null,
|
'barcode' => $data['barcode'] ?? null,
|
||||||
'category_id' => $data['category_id'],
|
'category_id' => $data['category_id'],
|
||||||
|
'unit_of_measure_id' => $data['unit_of_measure_id'],
|
||||||
'track_serials' => $data['track_serials'] ?? false,
|
'track_serials' => $data['track_serials'] ?? false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -24,7 +25,7 @@ public function createProduct(array $data)
|
|||||||
'tax' => $data['tax'] ?? 16.00,
|
'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,
|
'sku' => $data['sku'] ?? null,
|
||||||
'barcode' => $data['barcode'] ?? null,
|
'barcode' => $data['barcode'] ?? null,
|
||||||
'category_id' => $data['category_id'] ?? null,
|
'category_id' => $data['category_id'] ?? null,
|
||||||
|
'unit_of_measure_id' => $data['unit_of_measure_id'] ?? null,
|
||||||
'track_serials' => $data['track_serials'] ?? null,
|
'track_serials' => $data['track_serials'] ?? null,
|
||||||
], fn($value) => $value !== 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']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
94
app/Services/WhatsAppAuthService.php
Normal file
94
app/Services/WhatsAppAuthService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
213
app/Services/WhatsappService.php
Normal file
213
app/Services/WhatsappService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -92,7 +92,7 @@ public function run(): void
|
|||||||
$salesCreate = $this->onCreate('sales', 'Crear registros', $salesType, 'api');
|
$salesCreate = $this->onCreate('sales', 'Crear registros', $salesType, 'api');
|
||||||
$salesCancel = $this->onPermission('sales.cancel', 'Cancelar venta', $salesType, 'api');
|
$salesCancel = $this->onPermission('sales.cancel', 'Cancelar venta', $salesType, 'api');
|
||||||
|
|
||||||
// Permisos de Inventario (solo lectura)
|
// Permisos de Inventario
|
||||||
$inventoryType = PermissionType::firstOrCreate([
|
$inventoryType = PermissionType::firstOrCreate([
|
||||||
'name' => 'Inventario'
|
'name' => 'Inventario'
|
||||||
]);
|
]);
|
||||||
@ -104,6 +104,26 @@ public function run(): void
|
|||||||
$inventoryDestroy = $this->onDestroy('inventario', 'Eliminar registro', $inventoryType, 'api');
|
$inventoryDestroy = $this->onDestroy('inventario', 'Eliminar registro', $inventoryType, 'api');
|
||||||
$inventoryImport = $this->onPermission('inventario.import', 'Importar productos desde Excel', $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
|
// Permisos de Clientes
|
||||||
$clientsType = PermissionType::firstOrCreate([
|
$clientsType = PermissionType::firstOrCreate([
|
||||||
@ -199,6 +219,10 @@ public function run(): void
|
|||||||
$inventoryCreate,
|
$inventoryCreate,
|
||||||
$inventoryEdit,
|
$inventoryEdit,
|
||||||
$inventoryDestroy,
|
$inventoryDestroy,
|
||||||
|
$categoriesIndex,
|
||||||
|
$categoriesCreate,
|
||||||
|
$categoriesEdit,
|
||||||
|
$categoriesDestroy,
|
||||||
$clientIndex,
|
$clientIndex,
|
||||||
$clientCreate,
|
$clientCreate,
|
||||||
$clientEdit,
|
$clientEdit,
|
||||||
@ -220,7 +244,11 @@ public function run(): void
|
|||||||
$movementsIndex,
|
$movementsIndex,
|
||||||
$movementsCreate,
|
$movementsCreate,
|
||||||
$movementsEdit,
|
$movementsEdit,
|
||||||
$movementsDestroy
|
$movementsDestroy,
|
||||||
|
$unitsIndex,
|
||||||
|
$unitsCreate,
|
||||||
|
$unitsEdit,
|
||||||
|
$unitsDestroy
|
||||||
);
|
);
|
||||||
|
|
||||||
//Operador PDV (solo permisos de operación de caja y ventas)
|
//Operador PDV (solo permisos de operación de caja y ventas)
|
||||||
|
|||||||
@ -15,7 +15,9 @@
|
|||||||
use App\Http\Controllers\App\InvoiceRequestController;
|
use App\Http\Controllers\App\InvoiceRequestController;
|
||||||
use App\Http\Controllers\App\InventoryMovementController;
|
use App\Http\Controllers\App\InventoryMovementController;
|
||||||
use App\Http\Controllers\App\KardexController;
|
use App\Http\Controllers\App\KardexController;
|
||||||
|
use App\Http\Controllers\App\UnitOfMeasurementController;
|
||||||
use App\Http\Controllers\App\WarehouseController;
|
use App\Http\Controllers\App\WarehouseController;
|
||||||
|
use App\Http\Controllers\App\WhatsappController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,6 +61,16 @@
|
|||||||
Route::post('/traspaso', [InventoryMovementController::class, 'transfer']);
|
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
|
//CATEGORIAS
|
||||||
Route::resource('categorias', CategoryController::class);
|
Route::resource('categorias', CategoryController::class);
|
||||||
|
|
||||||
@ -129,11 +141,19 @@
|
|||||||
Route::put('/{id}/reject', [InvoiceRequestController::class, 'reject']);
|
Route::put('/{id}/reject', [InvoiceRequestController::class, 'reject']);
|
||||||
Route::post('/{id}/upload', [InvoiceRequestController::class, 'uploadInvoiceFile']);
|
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 */
|
/** Rutas públicas */
|
||||||
// Formulario de datos fiscales para facturación
|
// Formulario de datos fiscales para facturación
|
||||||
Route::prefix('facturacion')->group(function () {
|
Route::prefix('facturacion')->group(function () {
|
||||||
|
Route::get('/check-rfc', [InvoiceController::class, 'checkRfc']);
|
||||||
Route::get('/{invoiceNumber}', [InvoiceController::class, 'show']);
|
Route::get('/{invoiceNumber}', [InvoiceController::class, 'show']);
|
||||||
Route::post('/{invoiceNumber}', [InvoiceController::class, 'store']);
|
Route::post('/{invoiceNumber}', [InvoiceController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user