Compare commits

..

67 Commits

Author SHA1 Message Date
Juan Felipe Zapata Moreno
da49a9a75a feat: agregar relación de proveedor a facturas y actualizar solicitudes y migraciones 2026-03-21 12:39:10 -06:00
Juan Felipe Zapata Moreno
8eedb89172 feat: agregar gestión de facturas con controlador, solicitudes y migraciones 2026-03-21 11:49:18 -06:00
c7b28d9053 feat: mejorar gestión de seriales en entradas, salidas y transferencias de inventario 2026-03-11 21:50:26 -06:00
Juan Felipe Zapata Moreno
3384941cf5 feat: renombrar 'Paquetes de permisos' a 'Paquetes' y agregar permisos para 'Devoluciones' 2026-03-10 11:20:57 -06:00
Juan Felipe Zapata Moreno
920dcb2cc9 feat: eliminar método createWarehouse y agregar WarehouseSeeder para inicializar almacén principal 2026-03-10 11:08:16 -06:00
Juan Felipe Zapata Moreno
86ef5931e1 feat: corregir correos electrónicos en UserSeeder y ajustar nombres de usuarios 2026-03-10 09:36:59 -06:00
Juan Felipe Zapata Moreno
d7503304f1 feat: eliminar migración de inventario existente y ajustar estructura de base de datos 2026-03-10 09:27:42 -06:00
Juan Felipe Zapata Moreno
2db5cafb21 feat: sincronizar client_number con RFC al actualizar cliente 2026-03-06 10:54:48 -06:00
Juan Felipe Zapata Moreno
232d9ccaa6 feat: actualizar servicio de WhatsApp para incluir el nombre de la empresa y eliminar el soporte para XML en el envío de facturas 2026-03-06 10:07:08 -06:00
Juan Felipe Zapata Moreno
b253e8a308 feat: actualizar envío de documentos por WhatsApp para usar plantilla 'facturas_pdv' y eliminar el campo 'caption' 2026-03-05 16:33:09 -06:00
Juan Felipe Zapata Moreno
19885a0aba feat: agregar soporte para especificar el almacén al marcar seriales como vendidos en el servicio de ventas 2026-03-04 17:15:51 -06:00
Juan Felipe Zapata Moreno
ec33cf2c0e feat: actualizar validaciones de SKU y código de barras en solicitudes de bundles y categorías, y ajustar cálculo de impuestos en el servicio de bundles 2026-03-04 10:13:15 -06:00
Juan Felipe Zapata Moreno
3d5198a65a feat: agregar soporte para almacenamiento en masa de subcategorías y validaciones en la solicitud de creación 2026-03-02 13:12:54 -06:00
48fe26899a feat: permitir categorías nulas en inventarios y actualizar validaciones de subcategorías en solicitudes 2026-02-26 22:06:02 -06:00
Juan Felipe Zapata Moreno
f184e4d444 feat: agregar soporte para subcategorías en el controlador de inventario y solicitudes, incluyendo validaciones en las reglas de almacenamiento y actualización 2026-02-25 13:35:53 -06:00
Juan Felipe Zapata Moreno
37f91d84f2 feat: agregar soporte para subcategorías, incluyendo controladores, solicitudes y migraciones 2026-02-25 12:31:51 -06:00
1c21602b7e feat: agregar soporte para seriales transferidos y salidas en el control de movimientos de inventario 2026-02-24 23:30:47 -06:00
e5e3412fea feat: Implement unit equivalence functionality 2026-02-24 01:30:11 -06:00
Juan Felipe Zapata Moreno
bbc95f4ea2 feat: agregar campo 'key_sat' a los modelos de inventario y sus solicitudes, y crear observador para notificaciones de stock bajo 2026-02-23 16:31:19 -06:00
c2929d0b2f feat: mejorar validación de stock en la creación de ventas, considerando seriales y agrupación por producto 2026-02-19 21:37:26 -06:00
Juan Felipe Zapata Moreno
115a033510 feat: mejorar validación de seriales en salidas y traspasos de inventario 2026-02-19 16:53:07 -06:00
6ea9665b0b feat: actualizar reglas de validación para números de serie en items de venta 2026-02-18 21:40:59 -06:00
Juan Felipe Zapata Moreno
5ad7b9ca72 feat: agregar observador para movimientos de inventario y reportar eventos de usuario 2026-02-17 16:36:27 -06:00
897f59211d feat: agregar gestión de paquetes de permisos en RoleSeeder y definir rutas para bundles 2026-02-16 23:35:08 -06:00
Juan Felipe Zapata Moreno
7a68c458b8 feat: implementar gestión de bundles, incluyendo creación, actualización y eliminación, así como validación de stock 2026-02-16 17:17:05 -06:00
Juan Felipe Zapata Moreno
7ebca6456f feat: agregar columna unit_of_measure_id a la tabla de inventarios y ajustar movimiento_id en la tabla de seriales 2026-02-13 16:05:21 -06:00
Juan Felipe Zapata Moreno
da5acc11c5 feat: agregar gestión de números de serie en movimientos de inventario 2026-02-12 15:47:15 -06:00
Juan Felipe Zapata Moreno
c1d6f58697 feat: refactor WhatsApp autenticación y configuración de token en .env 2026-02-11 16:47:18 -06:00
Juan Felipe Zapata Moreno
aff2448356 feat: agregar gestión de proveedores y unidad de medida en inventarios 2026-02-10 16:39:36 -06:00
562397402c 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).
2026-02-10 00:06:24 -06:00
Juan Felipe Zapata Moreno
41a84d05a0 feat: agregar soporte para unidades de medida y permitir cantidades decimales en inventarios 2026-02-09 16:32:07 -06:00
7f6db1b83c feat: separar stock de importación y validar series
- Elimina gestión de stock inicial en importación (solo catálogo).
- Unifica validación de números de serie en todos los movimientos.
- Restringe controlador de series a lectura y filtra rutas.
2026-02-08 20:24:25 -06:00
Juan Felipe Zapata Moreno
6b76b94e62 feat: agregar funcionalidad para actualizar movimientos de inventario 2026-02-07 12:10:44 -06:00
3f4a03c9c5 fix: arreglo de migraciones 2026-02-06 23:45:47 -06:00
516ad1cae6 feat: agregar controlador para generación de Kardex en Excel 2026-02-06 23:23:30 -06:00
Juan Felipe Zapata Moreno
9a78d92dbf feat(inventory): movimientos masivos y costeo unitario
- Habilita entradas, salidas y traspasos masivos con validación.
- Implementa cálculo de costo promedio ponderado y campo de costo unitario.
- Agrega filtro por almacén y ajusta manejo de costos nulos.
2026-02-06 16:03:09 -06:00
5a646d84d5 Refactorizar gestión de inventario a sistema multi-almacén
- Migrar manejo de stock de  a .
- Implementar  para centralizar lógica de entradas, salidas y transferencias.
- Añadir  (CRUD) y Requests de validación.
- Actualizar reportes, cálculos de valor y migraciones para la nueva estructura.
- Agregar campo  para rastreo de movimientos.
2026-02-05 23:59:35 -06:00
Juan Felipe Zapata Moreno
3cac336e10 wip: catalog de almacenes, entrada, salida y traspaso 2026-02-05 17:01:19 -06:00
Juan Felipe Zapata Moreno
656492251b feat: agregar cálculo del valor total del inventario y filtros adicionales en el controlador de inventario 2026-02-05 12:01:25 -06:00
Juan Felipe Zapata Moreno
a0e8c70624 feat: agregar método para generar reporte de inventario en formato Excel 2026-02-04 16:44:20 -06:00
Juan Felipe Zapata Moreno
c68a16763c feat: agregar funcionalidad para subir archivos de factura y almacenar UUID del CFDI 2026-02-04 14:26:29 -06:00
Juan Felipe Zapata Moreno
f2d2fd5aaf feat: implementar controlador y modelo para solicitudes de factura, incluyendo validaciones y rutas 2026-02-03 15:23:45 -06:00
Juan Felipe Zapata Moreno
38e5050692 fix: ajustar validación de seriales en solicitudes de devolución y restauración 2026-01-30 16:32:07 -06:00
Juan Felipe Zapata Moreno
95154f4b28 refactor: cambiar creación de permisos a firstOrCreate y ajustar sincronización de permisos en roles 2026-01-30 14:55:00 -06:00
Juan Felipe Zapata Moreno
c5d8e3c65b feat: agregar reporte de ventas en formato Excel y ajustar lógica de descuentos en ventas 2026-01-30 14:18:24 -06:00
Juan Felipe Zapata Moreno
da92d0dc1a Add migration seed for tiers 2026-01-29 17:39:59 -06:00
328ce7488f feat: agregar soporte para búsqueda de clientes por número de cliente y actualizar validaciones en solicitudes de venta 2026-01-28 22:45:22 -06:00
Juan Felipe Zapata Moreno
4357c560df add: creación del modulo de tiers de clientes 2026-01-28 16:46:31 -06:00
Juan Felipe Zapata Moreno
5f0d4ec28e fix: actualizar lógica de seguimiento de seriales en devoluciones y ventas 2026-01-27 16:33:35 -06:00
Juan Felipe Zapata Moreno
91f27c4e4a feat: corrección al apartado de inventario, productos que no tiene numero de serie 2026-01-26 16:39:11 -06:00
b9e694f6ca add: devoluciones wip 2026-01-25 22:17:16 -06:00
Juan Felipe Zapata Moreno
2c8189ca59 fix: permisos de usuario 2026-01-19 12:51:52 -06:00
Juan Felipe Zapata Moreno
eaad8a57df fix: agregar conteo de seriales en inventarios y ajustar carga de seriales en ventas 2026-01-19 11:55:38 -06:00
c1473cdb95 fix: actualizar respuestas de API para ventas con datos de facturación y ajustar seriales en inventarios 2026-01-16 21:37:40 -06:00
Juan Felipe Zapata Moreno
810aff1b0e WIP: Serials 2026-01-16 17:37:39 -06:00
08871b8dde fix: importar excel 2026-01-15 20:12:32 -06:00
Juan Felipe Zapata Moreno
fa8bea2060 add: implementar controlador y modelo para la gestión de clientes, incluyendo rutas API 2026-01-12 14:49:50 -06:00
Juan Felipe Zapata Moreno
06425157b8 add: incluir campo de código de barras en la creación y actualización de productos 2026-01-06 09:07:45 -06:00
388ba3eff2 add: agregar campo de código de barras a inventarios y actualizar validaciones 2026-01-05 20:31:34 -06:00
Juan Felipe Zapata Moreno
40bc2b5735 add: reportes por rango de fecha 2026-01-05 15:52:08 -06:00
d5665f0448 add: cambio al pagar con efectivo 2026-01-04 17:11:57 -06:00
Juan Felipe Zapata Moreno
e37a8b117d fix: importación de excel 2026-01-02 15:52:16 -06:00
Juan Felipe Zapata Moreno
07ee72e548 add: importar excel 2026-01-02 15:34:57 -06:00
Juan Felipe Zapata Moreno
a3c45c5efc fix: rutas del corte de caja 2025-12-31 13:49:18 -06:00
Juan Felipe Zapata Moreno
599bb68ce6 ADD: Corte de caja 2025-12-31 13:45:42 -06:00
Juan Felipe Zapata Moreno
569fbd09d7 ADD: Implementación de controladores y solicitudes para gestión de categorías, inventarios, precios y ventas 2025-12-31 13:27:14 -06:00
Juan Felipe Zapata Moreno
f47a551d46 Implementación de controladores y modelos 2025-12-30 16:38:14 -06:00
162 changed files with 15609 additions and 904 deletions

View File

@ -87,3 +87,8 @@ 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_API_URL=https://whatsapp.golsystems.mx/api/send-whatsapp
WHATSAPP_ORG_ID=1
WHATSAPP_TOKEN=

1
.gitignore vendored
View File

@ -24,3 +24,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
CLAUDE.md

View File

@ -45,7 +45,7 @@ server {
# Handle storage files (Laravel storage link)
location /storage {
alias /var/www/pdv.backend/storage/app;
alias /var/www/pdv.backend/storage/app/public;
try_files $uri =404;
}

View File

@ -29,7 +29,7 @@ class UserController extends Controller
*/
public function index()
{
$users = User::orderBy('name');
$users = User::orderBy('name')->where('id', '!=', 1);
QuerySupport::queryByKeys($users, ['name', 'email']);

View File

@ -0,0 +1,234 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Requests\App\BillStoreRequest;
use App\Http\Requests\App\BillUpdateRequest;
use App\Models\Bill;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Storage;
use Notsoweb\ApiResponse\Enums\ApiResponse;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use Carbon\Carbon;
class BillController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$query = Bill::query()->with('supplier');
if (request()->filled('q')) {
$query->where('name', 'like', '%' . request()->q . '%');
}
$bills = $query->orderByDesc('created_at')
->paginate(config('app.pagination', 15));
return ApiResponse::OK->response(['bills' => $bills]);
}
/**
* Store a newly created resource in storage.
*/
public function store(BillStoreRequest $request)
{
$data = $request->validated();
if ($request->hasFile('file')) {
$data['file_path'] = $request->file('file')->store('bills', 'public');
}
unset($data['file']);
$bill = Bill::create($data);
return ApiResponse::CREATED->response(['bill' => $bill]);
}
/**
* Display the specified resource.
*/
public function show(Bill $bill)
{
return ApiResponse::OK->response(['bill' => $bill]);
}
/**
* Update the specified resource in storage.
*/
public function update(BillUpdateRequest $request, Bill $bill)
{
$data = $request->validated();
if ($request->hasFile('file')) {
if ($bill->file_path) {
Storage::disk('public')->delete($bill->file_path);
}
$data['file_path'] = $request->file('file')->store('bills', 'public');
}
unset($data['file']);
$bill->update($data);
return ApiResponse::OK->response(['bill' => $bill->fresh()]);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Bill $bill)
{
if ($bill->file_path) {
Storage::disk('public')->delete($bill->file_path);
}
$bill->delete();
return ApiResponse::OK->response();
}
/**
* Alterna el estado de pago de la factura.
*/
public function togglePaid(Bill $bill)
{
$bill->update(['paid' => !$bill->paid]);
return ApiResponse::OK->response(['bill' => $bill->fresh()]);
}
/**
* Exporta a Excel las facturas pendientes de pago.
*/
public function export()
{
$bills = Bill::with('supplier')->where('paid', false)
->orderBy('deadline')
->orderBy('created_at')
->get();
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Facturas por Pagar');
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial')->setSize(10);
$styleHeader = [
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
];
$styleData = [
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D0D0D0']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
];
// Título
$sheet->mergeCells('A1:F1');
$sheet->setCellValue('A1', 'FACTURAS PENDIENTES DE PAGO');
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 14],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
$sheet->mergeCells('A2:F2');
$sheet->setCellValue('A2', 'Generado el ' . Carbon::now()->format('d/m/Y H:i'));
$sheet->getStyle('A2')->applyFromArray([
'font' => ['italic' => true, 'color' => ['rgb' => '666666']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
]);
// Encabezados
$headers = ['A4' => '#', 'B4' => 'NOMBRE', 'C4' => 'PROVEEDOR', 'D4' => 'COSTO', 'E4' => 'FECHA LÍMITE', 'F4' => 'DÍAS RESTANTES'];
foreach ($headers as $cell => $text) {
$sheet->setCellValue($cell, $text);
}
$sheet->getStyle('A4:F4')->applyFromArray($styleHeader);
$sheet->getRowDimension(4)->setRowHeight(22);
// Datos
$row = 5;
$total = 0;
foreach ($bills as $i => $bill) {
$deadline = $bill->deadline ? Carbon::parse($bill->deadline) : null;
// Positivo = días que faltan, negativo = días vencida
$daysLeft = $deadline ? (int) Carbon::today()->diffInDays($deadline, false) : null;
if ($daysLeft === null) {
$daysLabel = '—';
} elseif ($daysLeft === 0) {
$daysLabel = 'Hoy';
} elseif ($daysLeft > 0) {
$daysLabel = "+{$daysLeft}";
} else {
$daysLabel = (string) $daysLeft; // ya incluye el signo negativo
}
$sheet->setCellValue('A' . $row, $i + 1);
$sheet->setCellValue('B' . $row, $bill->name);
$sheet->setCellValue('C' . $row, $bill->supplier?->business_name ?? '—');
$sheet->setCellValue('D' . $row, (float) $bill->cost);
$sheet->setCellValue('E' . $row, $deadline?->format('d/m/Y') ?? '—');
$sheet->setCellValue('F' . $row, $daysLabel);
$sheet->getStyle('D' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle('A' . $row . ':F' . $row)->applyFromArray($styleData);
$sheet->getStyle('A' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('D' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('E' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('F' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// Verde si quedan días, rojo si está vencida
if ($daysLeft !== null && $daysLeft > 0) {
$sheet->getStyle('F' . $row)->getFont()->getColor()->setRGB('1A7A1A');
} elseif ($daysLeft !== null && $daysLeft < 0) {
$sheet->getStyle('E' . $row . ':F' . $row)->getFont()->getColor()->setRGB('CC0000');
$sheet->getStyle('E' . $row . ':F' . $row)->getFont()->setBold(true);
} elseif ($daysLeft === 0) {
$sheet->getStyle('F' . $row)->getFont()->getColor()->setRGB('B45309');
$sheet->getStyle('F' . $row)->getFont()->setBold(true);
}
$total += (float) $bill->cost;
$row++;
}
// Total
$sheet->setCellValue('C' . $row, 'TOTAL PENDIENTE');
$sheet->setCellValue('D' . $row, $total);
$sheet->getStyle('D' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle('C' . $row . ':D' . $row)->applyFromArray([
'font' => ['bold' => true, 'size' => 11],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FFF2CC']],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT],
]);
// Anchos de columna
$sheet->getColumnDimension('A')->setWidth(6);
$sheet->getColumnDimension('B')->setWidth(36);
$sheet->getColumnDimension('C')->setWidth(30);
$sheet->getColumnDimension('D')->setWidth(18);
$sheet->getColumnDimension('E')->setWidth(16);
$sheet->getColumnDimension('F')->setWidth(16);
// Generar archivo
$fileName = 'Facturas_Pendientes_' . Carbon::now()->format('Ymd_His') . '.xlsx';
$filePath = storage_path('app/temp/' . $fileName);
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
(new Xlsx($spreadsheet))->save($filePath);
return response()->download($filePath, $fileName, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
])->deleteFileAfterSend(true);
}
}

View File

@ -0,0 +1,140 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Bundle;
use App\Services\BundleService;
use App\Http\Requests\App\BundleStoreRequest;
use App\Http\Requests\App\BundleUpdateRequest;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class BundleController extends Controller
{
public function __construct(
protected BundleService $bundleService
) {}
/**
* Listar todos los bundles activos
*/
public function index(Request $request)
{
$bundles = Bundle::with(['items.inventory.price', 'price'])
->when($request->has('q'), function ($query) use ($request) {
$query->where(function ($q) use ($request) {
$q->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
})
->orderBy('name')
->paginate(config('app.pagination', 15));
return ApiResponse::OK->response([
'bundles' => $bundles,
]);
}
/**
* Ver detalle de un bundle
*/
public function show(Bundle $bundle)
{
$bundle->load(['items.inventory.price', 'price']);
return ApiResponse::OK->response([
'model' => $bundle,
]);
}
/**
* Crear un nuevo bundle
*/
public function store(BundleStoreRequest $request)
{
try {
$bundle = $this->bundleService->createBundle($request->validated());
return ApiResponse::CREATED->response([
'model' => $bundle,
'message' => 'Bundle creado exitosamente',
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al crear el bundle: ' . $e->getMessage(),
]);
}
}
/**
* Actualizar un bundle existente
*/
public function update(BundleUpdateRequest $request, Bundle $bundle)
{
try {
$updatedBundle = $this->bundleService->updateBundle($bundle, $request->validated());
return ApiResponse::OK->response([
'model' => $updatedBundle,
'message' => 'Bundle actualizado exitosamente',
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al actualizar el bundle: ' . $e->getMessage(),
]);
}
}
/**
* Eliminar (soft delete) un bundle
*/
public function destroy(Bundle $bundle)
{
try {
$this->bundleService->deleteBundle($bundle);
return ApiResponse::OK->response([
'message' => 'Bundle eliminado exitosamente',
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al eliminar el bundle: ' . $e->getMessage(),
]);
}
}
/**
* Verificar stock disponible de un bundle
*/
public function checkStock(Request $request, Bundle $bundle)
{
$quantity = $request->input('quantity', 1);
$warehouseId = $request->input('warehouse_id');
$bundle->load(['items.inventory']);
$availableStock = $warehouseId
? $bundle->stockInWarehouse($warehouseId)
: $bundle->available_stock;
$hasStock = $bundle->hasStock($quantity, $warehouseId);
return ApiResponse::OK->response([
'bundle_id' => $bundle->id,
'bundle_name' => $bundle->name,
'quantity_requested' => $quantity,
'available_stock' => $availableStock,
'has_stock' => $hasStock,
'components_stock' => $bundle->items->map(function ($item) use ($warehouseId) {
return [
'inventory_id' => $item->inventory_id,
'product_name' => $item->inventory->name,
'required_quantity' => $item->quantity,
'available_stock' => $warehouseId
? $item->inventory->stockInWarehouse($warehouseId)
: $item->inventory->stock,
];
}),
]);
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\CashRegister;
use App\Services\CashRegisterService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class CashRegisterController extends Controller
{
public function __construct(
protected CashRegisterService $cashRegisterService
) {}
/**
* Listar cortes de caja
*/
public function index(Request $request)
{
$query = CashRegister::with('user')->orderBy('opened_at', 'desc');
// Filtro por rango de fechas
if ($request->has('from') && $request->has('to')) {
$query->whereBetween('opened_at', [$request->from, $request->to]);
}
// Filtro por estado
if ($request->has('status')) {
$query->where('status', $request->status);
}
$registers = $query->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'registers' => $registers
]);
}
/**
* Ver caja actual del usuario
*/
public function current()
{
$register = CashRegister::where('user_id', auth()->id())
->where('status', 'open')
->with(['user', 'sales'])
->first();
if (!$register) {
return ApiResponse::OK->response([
'register' => null,
'message' => 'No tienes una caja abierta.'
]);
}
$service = new CashRegisterService();
$summary = $service->getCurrentSummary($register);
return ApiResponse::OK->response(['register' => $summary ]);
}
/**
* Ver detalle de un corte específico
*/
public function show(CashRegister $register)
{
$register->load(['user', 'sales.details']);
$summary = [
'register' => $register,
'payment_summary' => $register->getTotalsByPaymentMethod(),
];
return ApiResponse::OK->response($summary);
}
/**
* Abrir caja
*/
public function open(Request $request)
{
$request->validate([
'initial_cash' => ['required', 'numeric', 'min:0'],
], [
'initial_cash.required' => 'El efectivo inicial es obligatorio.',
'initial_cash.numeric' => 'El efectivo inicial debe ser un número.',
'initial_cash.min' => 'El efectivo inicial no puede ser negativo.',
]);
try {
$register = $this->cashRegisterService->openRegister([
'user_id' => auth()->id(),
'initial_cash' => $request->initial_cash,
]);
return ApiResponse::CREATED->response([
'model' => $register,
'message' => 'Caja abierta exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
/**
* Cerrar caja
*/
public function close(CashRegister $register, Request $request)
{
$request->validate([
'final_cash' => ['required', 'numeric', 'min:0'],
'notes' => ['nullable', 'string', 'max:500'],
], [
'final_cash.required' => 'El efectivo final es obligatorio.',
'final_cash.numeric' => 'El efectivo final debe ser un número.',
'final_cash.min' => 'El efectivo final no puede ser negativo.',
]);
try {
$closedRegister = $this->cashRegisterService->closeRegister($register, [
'final_cash' => $request->final_cash,
'notes' => $request->notes,
]);
return ApiResponse::OK->response([
'model' => $closedRegister,
'message' => 'Caja cerrada exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
}

View File

@ -0,0 +1,63 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\CategoryStoreRequest;
use App\Http\Requests\App\CategoryUpdateRequest;
use App\Models\Category;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class CategoryController extends Controller
{
public function index()
{
$categorias = Category::with(['subcategories' => fn ($q) => $q->where('is_active', true)->orderBy('name')])
->where('is_active', true)
->orderBy('name')
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'categories' => $categorias
]);
}
public function show(Category $categoria)
{
$categoria->load(['subcategories' => fn ($q) => $q->where('is_active', true)->orderBy('name')]);
return ApiResponse::OK->response([
'model' => $categoria
]);
}
public function store(CategoryStoreRequest $request)
{
$categoria = Category::create($request->validated());
return ApiResponse::OK->response([
'model' => $categoria
]);
}
public function update(CategoryUpdateRequest $request, Category $categoria)
{
$categoria->update($request->validated());
return ApiResponse::OK->response([
'model' => $categoria->fresh()
]);
}
public function destroy(Category $categoria)
{
if ($categoria->inventories()->exists()) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se puede eliminar la clasificación porque tiene productos asociados.'
]);
}
$categoria->delete();
return ApiResponse::OK->response();
}
}

View File

@ -0,0 +1,245 @@
<?php namespace App\Http\Controllers\App;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Models\Client;
use App\Services\ClientTierService;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ClientController extends Controller
{
protected ClientTierService $clientTierService;
public function __construct(ClientTierService $clientTierService)
{
$this->clientTierService = $clientTierService;
}
public function index(Request $request)
{
$query = Client::query();
if ($request->has('with')) {
$relations = explode(',', $request->with);
$query->with($relations);
}
if ($request->has('client_number') && $request->client_number) {
$query->where('client_number', 'like', "%{$request->client_number}%");
}
elseif ($request->has('q') && $request->q) {
$query->where(function($q) use ($request) {
$q->where('name', 'like', "%{$request->q}%")
->orWhere('email', 'like', "%{$request->q}%")
->orWhere('rfc', 'like', "%{$request->q}%");
});
}
return ApiResponse::OK->response([
'clients' => $query->paginate(config('app.pagination')),
]);
}
public function show(Client $client)
{
return ApiResponse::OK->response([
'client' => $client
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:500',
'rfc' => 'required|string|max:13',
'razon_social' => 'nullable|string|max:255',
'regimen_fiscal' => 'nullable|string|max:100',
'cp_fiscal' => 'nullable|string|max:5',
'uso_cfdi' => 'nullable|string|max:100',
],[
'email.unique' => 'El correo electrónico ya está en uso por otro cliente.',
'phone.unique' => 'El teléfono ya está en uso por otro cliente.',
'rfc.unique' => 'El RFC ya está en uso por otro cliente.',
'rfc.required' => 'El RFC es obligatorio.',
]);
try{
$data = $request->only([
'name',
'email',
'phone',
'address',
'rfc',
'razon_social',
'regimen_fiscal',
'cp_fiscal',
'uso_cfdi',
]);
// Usar RFC como client_number
$data['client_number'] = $data['rfc'] ?? null;
$client = Client::create($data);
// Cargar relación tier
$client->load('tier');
return ApiResponse::OK->response([
'client' => $client,
'message' => 'Cliente creado correctamente.'
]);
}catch(\Exception $e){
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al crear el cliente.'
]);
}
}
public function update(Request $request, Client $client)
{
$request->validate([
'name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:500',
'rfc' => 'nullable|string|max:13',
],[
'email.unique' => 'El correo electrónico ya está en uso por otro cliente.',
'phone.unique' => 'El teléfono ya está en uso por otro cliente.',
'rfc.unique' => 'El RFC ya está en uso por otro cliente.',
]);
try{
$data = $request->only([
'name',
'email',
'phone',
'address',
'rfc',
'razon_social',
'regimen_fiscal',
'cp_fiscal',
'uso_cfdi',
]);
// Mantener client_number sincronizado con RFC cuando se actualiza
if ($request->filled('rfc')) {
$data['client_number'] = $data['rfc'];
}
$client->update($data);
return ApiResponse::OK->response([
'client' => $client,
'message' => 'Cliente actualizado correctamente.'
]);
}catch(\Exception $e){
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al actualizar el cliente.'
]);
}
}
public function destroy(Client $client)
{
try{
$client->delete();
return ApiResponse::OK->response([
'message' => 'Cliente eliminado correctamente.'
]);
}catch(\Exception $e){
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al eliminar el cliente.'
]);
}
}
/**
* Obtener estadísticas de compra del cliente
*/
public function stats(Client $client)
{
try {
$stats = $this->clientTierService->getClientStats($client);
return ApiResponse::OK->response([
'stats' => $stats
]);
} catch(\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al obtener estadísticas del cliente.'
]);
}
}
/**
* Obtener historial de cambios de tier del cliente
*/
public function tierHistory(Client $client)
{
try {
$history = $client->tierHistory()
->with(['oldTier', 'newTier'])
->orderBy('changed_at', 'desc')
->get();
return ApiResponse::OK->response([
'history' => $history
]);
} catch(\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al obtener historial del cliente.'
]);
}
}
/**
* Obtener ventas con descuento de un cliente
*/
public function salesWithDiscounts(Client $client, Request $request)
{
try {
$query = $client->sales()
->where('discount_amount', '>', 0)
->with(['details', 'user:id,name'])
->orderBy('created_at', 'desc');
// Filtro por rango de fechas
if ($request->has('date_from')) {
$query->where('created_at', '>=', $request->date_from);
}
if ($request->has('date_to')) {
$query->where('created_at', '<=', $request->date_to);
}
$sales = $query->paginate($request->per_page ?? 15);
// Calcular totales
$totals = [
'total_sales' => $client->sales()->where('discount_amount', '>', 0)->count(),
'total_amount' => $client->sales()->where('discount_amount', '>', 0)->sum('total'),
'total_discounts' => $client->sales()->where('discount_amount', '>', 0)->sum('discount_amount'),
];
return ApiResponse::OK->response([
'sales' => $sales,
'totals' => $totals
]);
} catch(\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al obtener ventas con descuentos.',
'error' => $e->getMessage()
]);
}
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers\App;
use App\Models\ClientTier;
use App\Http\Controllers\Controller;
use App\Http\Requests\ClientTiers\ClientTierStoreRequest;
use App\Http\Requests\ClientTiers\ClientTierUpdateRequest;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ClientTierController extends Controller
{
/**
* Listar todos los tiers
*/
public function index()
{
$tiers = ClientTier::withCount('clients')
->orderBy('min_purchase_amount')
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'tiers' => $tiers
]);
}
/**
* Almacenar un nuevo tier
*/
public function store(ClientTierStoreRequest $request)
{
$tier = ClientTier::create($request->validated());
return ApiResponse::OK->response([
'tier' => $tier,
'message' => 'Tier creado correctamente.'
]);
}
/**
* Mostrar un tier específico
*/
public function show(ClientTier $tier)
{
$tier->loadCount('clients');
return ApiResponse::OK->response([
'tier' => $tier
]);
}
/**
* Actualizar un tier existente
*/
public function update(ClientTierUpdateRequest $request, ClientTier $tier)
{
$tier->update($request->validated());
return ApiResponse::OK->response([
'tier' => $tier,
'message' => 'Tier actualizado correctamente.'
]);
}
/**
* Eliminar un tier
*/
public function destroy(ClientTier $tier)
{
// Verificar si el tier tiene clientes asignados
if ($tier->clients()->count() > 0) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se puede eliminar el tier porque tiene clientes asignados.'
]);
}
$tier->delete();
return ApiResponse::OK->response([
'message' => 'Tier eliminado correctamente.'
]);
}
/**
* Activar/Desactivar un tier
*/
public function toggleActive(ClientTier $tier)
{
$tier->update(['is_active' => !$tier->is_active]);
return ApiResponse::OK->response([
'tier' => $tier,
'message' => 'Estado del tier actualizado correctamente.'
]);
}
/**
* Seleccionar tiers activos
*/
public function active()
{
$tiers = ClientTier::active()
->orderBy('min_purchase_amount')
->get();
return ApiResponse::OK->response([
'tiers' => $tiers
]);
}
}

View File

@ -0,0 +1,818 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Client;
use App\Models\Inventory;
use App\Models\Sale;
use App\Models\SaleDetail;
use Illuminate\Http\Request;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use Carbon\Carbon;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
class ExcelController extends Controller
{
/**
* Generar reporte Excel de descuentos a clientes
*/
public function clientDiscountsReport(Request $request)
{
// 1. VALIDACIÓN Y OBTENCIÓN DE DATOS
$request->validate([
'fecha_inicio' => 'required|date',
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
'tier_id' => 'nullable|exists:client_tiers,id',
]);
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
$tierId = $request->tier_id;
// 2. OBTENER DATOS DE CLIENTES CON DESCUENTOS
$clients = Client::whereNotNull('tier_id')
->with('tier:id,tier_name,discount_percentage')
->whereHas('sales', function($q) use ($fechaInicio, $fechaFin) {
$q->whereBetween('created_at', [$fechaInicio, $fechaFin]);
})
->when($tierId, function($query) use ($tierId) {
$query->where('tier_id', $tierId);
})
->get();
if ($clients->isEmpty()) {
return response()->json(['message' => 'No se encontraron registros en el periodo especificado'], 404);
}
// 3. MAPEO DE DATOS
$data = $clients->map(function($client) use ($fechaInicio, $fechaFin) {
$sales = $client->sales()
->whereBetween('created_at', [$fechaInicio, $fechaFin])
->get();
return [
'numero' => $client->client_number,
'nombre' => $client->name,
'email' => $client->email ?? 'N/A',
'telefono' => $client->phone ?? 'N/A',
'tier' => $client->tier?->tier_name ?? 'N/A',
'descuento_porcentaje' => $client->tier?->discount_percentage ?? 0,
'total_compras' => $client->total_purchases,
'ventas_con_descuento' => $sales->count(),
'descuentos_recibidos' => $sales->sum('discount_amount'),
'promedio_descuento' => $sales->count() > 0 ? $sales->avg('discount_amount') : 0,
];
});
// 4. CONFIGURACIÓN EXCEL Y ESTILOS
$fileName = 'Reporte_Descuentos_Clientes_' . $fechaInicio->format('Ymd') . '.xlsx';
$filePath = storage_path('app/temp/' . $fileName);
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Fuente Global
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
$sheet->getParent()->getDefaultStyle()->getFont()->setSize(10);
// Estilos Comunes
$styleBox = [
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '000000']]],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER]
];
$styleLabel = [
'font' => ['size' => 12, 'bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER]
];
$styleTableHeader = [
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]
];
// --- ESTRUCTURA DEL DOCUMENTO ---
$sheet->getRowDimension(2)->setRowHeight(10);
$sheet->getRowDimension(3)->setRowHeight(25);
$sheet->getRowDimension(5)->setRowHeight(30);
// --- TÍTULO PRINCIPAL ---
$sheet->mergeCells('A3:J3');
$sheet->setCellValue('A3', 'REPORTE DE DESCUENTOS A CLIENTES');
$sheet->getStyle('A3')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
// --- INFORMACIÓN DEL PERIODO ---
Carbon::setLocale('es');
if ($fechaInicio->format('m/Y') === $fechaFin->format('m/Y')) {
$periodoTexto = 'del ' . $fechaInicio->format('d') . ' al ' . $fechaFin->format('d') . ' de ' . $fechaFin->translatedFormat('F \d\e Y');
} else {
$periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y');
}
$sheet->mergeCells('A5:B5');
$sheet->setCellValue('A5', 'PERÍODO:');
$sheet->getStyle('A5')->applyFromArray($styleLabel);
$sheet->mergeCells('C5:J5');
$sheet->setCellValue('C5', $periodoTexto);
$sheet->getStyle('C5:J5')->applyFromArray($styleBox);
$sheet->getStyle('C5')->getFont()->setSize(12);
// --- RESUMEN DE TOTALES ---
$totalClientes = $data->count();
$totalVentas = $data->sum('ventas_con_descuento');
$totalDescuentos = $data->sum('descuentos_recibidos');
$row = 7;
$sheet->setCellValue('A' . $row, 'TOTAL CLIENTES CON DESCUENTOS:');
$sheet->setCellValueExplicit('C' . $row, (int) $totalClientes, DataType::TYPE_NUMERIC);
$sheet->setCellValue('E' . $row, 'TOTAL VENTAS:');
$sheet->setCellValueExplicit('G' . $row, (int) $totalVentas, DataType::TYPE_NUMERIC);
$sheet->setCellValue('I' . $row, 'TOTAL DESCUENTOS:');
$sheet->setCellValueExplicit('J' . $row, (float) $totalDescuentos, DataType::TYPE_NUMERIC);
// Aplicar formato moneda
$sheet->getStyle('G' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle('J' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle('A' . $row . ':J' . $row)->getFont()->setBold(true);
$sheet->getStyle('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4');
$sheet->getStyle('G' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4');
$sheet->getStyle('J' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000');
// --- ENCABEZADOS DE TABLA ---
$h = 9;
$headers = [
'A' => 'No.',
'B' => "NÚMERO\nCLIENTE",
'C' => 'NOMBRE',
'D' => 'EMAIL',
'E' => 'TELÉFONO',
'F' => 'NIVEL',
'G' => "DESCUENTO\n(%)",
'H' => "VENTAS CON\nDESCUENTO",
'I' => "DESCUENTOS\nRECIBIDOS",
'J' => "PROMEDIO\nDESCUENTO"
];
foreach ($headers as $col => $text) {
$sheet->setCellValue("{$col}{$h}", $text);
}
$sheet->getStyle("A{$h}:J{$h}")->applyFromArray($styleTableHeader);
$sheet->getRowDimension($h)->setRowHeight(35);
// --- LLENADO DE DATOS ---
$row = 10;
$i = 1;
foreach ($data as $item) {
$sheet->setCellValue('A' . $row, $i);
$sheet->setCellValue('B' . $row, $item['numero']);
$sheet->setCellValue('C' . $row, $item['nombre']);
$sheet->setCellValue('D' . $row, $item['email']);
$sheet->setCellValue('E' . $row, $item['telefono']);
$sheet->setCellValue('F' . $row, $item['tier']);
$sheet->setCellValue('G' . $row, $item['descuento_porcentaje'] . '%');
$sheet->setCellValue('H' . $row, $item['ventas_con_descuento']);
$sheet->setCellValue('I' . $row, '$' . number_format($item['descuentos_recibidos'], 2));
$sheet->setCellValue('J' . $row, '$' . number_format($item['promedio_descuento'], 2));
// Estilos de fila
$sheet->getStyle("A{$row}:J{$row}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10]
]);
// Centrados
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("G{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("H{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// Color alterno de filas
if ($i % 2 == 0) {
$sheet->getStyle("A{$row}:J{$row}")->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setRGB('F2F2F2');
}
$row++;
$i++;
}
// --- ANCHOS DE COLUMNA ---
$sheet->getColumnDimension('A')->setWidth(5);
$sheet->getColumnDimension('B')->setWidth(15);
$sheet->getColumnDimension('C')->setWidth(25);
$sheet->getColumnDimension('D')->setWidth(25);
$sheet->getColumnDimension('E')->setWidth(15);
$sheet->getColumnDimension('F')->setWidth(12);
$sheet->getColumnDimension('G')->setWidth(12);
$sheet->getColumnDimension('H')->setWidth(15);
$sheet->getColumnDimension('I')->setWidth(18);
$sheet->getColumnDimension('J')->setWidth(18);
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
}
/**
* Generar reporte Excel de ventas
*/
public function salesReport(Request $request)
{
// 1. VALIDACIÓN
$request->validate([
'fecha_inicio' => 'required|date',
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
'client_id' => 'nullable|exists:clients,id',
'user_id' => 'nullable|exists:users,id',
'status' => 'nullable|in:completed,cancelled',
]);
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
// 2. CONSULTA DE VENTAS
$sales = Sale::with(['client:id,name,client_number,rfc', 'user:id,name', 'details'])
->whereBetween('created_at', [$fechaInicio, $fechaFin])
->when($request->client_id, function($query) use ($request) {
$query->where('client_id', $request->client_id);
})
->when($request->user_id, function($query) use ($request) {
$query->where('user_id', $request->user_id);
})
->when($request->status, function($query) use ($request) {
$query->where('status', $request->status);
})
->orderBy('created_at', 'desc')
->get();
if ($sales->isEmpty()) {
return response()->json(['message' => 'No se encontraron ventas en el periodo especificado'], 404);
}
// 3. MAPEO DE DATOS
$data = $sales->map(function($sale) {
return [
'folio' => $sale->invoice_number,
'fecha' => $sale->created_at->format('d/m/Y H:i'),
'cliente' => $sale->client?->name ?? 'N/A',
'rfc_cliente' => $sale->client?->rfc ?? 'N/A',
'vendedor' => $sale->user?->name ?? 'N/A',
'subtotal' => (float) $sale->subtotal,
'iva' => (float) $sale->tax,
'descuento' => (float) ($sale->discount_amount ?? 0),
'total' => (float) $sale->total,
'metodo_pago' => $this->translatePaymentMethod($sale->payment_method),
'status' => $sale->status === 'completed' ? 'Completada' : 'Cancelada',
'productos' => $sale->details->count(),
];
});
// 4. CONFIGURACIÓN EXCEL
$fileName = 'Reporte_Ventas_' . $fechaInicio->format('Ymd') . '_' . $fechaFin->format('Ymd') . '.xlsx';
$filePath = storage_path('app/temp/' . $fileName);
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Fuente Global
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
$sheet->getParent()->getDefaultStyle()->getFont()->setSize(10);
// Estilos Comunes
$styleBox = [
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '000000']]],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER]
];
$styleLabel = [
'font' => ['size' => 12, 'bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER]
];
$styleTableHeader = [
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '2E75B6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]
];
// --- ESTRUCTURA DEL DOCUMENTO ---
$lastCol = 'L';
$sheet->getRowDimension(2)->setRowHeight(10);
$sheet->getRowDimension(3)->setRowHeight(25);
$sheet->getRowDimension(5)->setRowHeight(30);
// --- TÍTULO PRINCIPAL ---
$sheet->mergeCells("A3:{$lastCol}3");
$sheet->setCellValue('A3', 'REPORTE DE VENTAS');
$sheet->getStyle('A3')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
// --- INFORMACIÓN DEL PERIODO ---
Carbon::setLocale('es');
if ($fechaInicio->format('m/Y') === $fechaFin->format('m/Y')) {
$periodoTexto = 'del ' . $fechaInicio->format('d') . ' al ' . $fechaFin->format('d') . ' de ' . $fechaFin->translatedFormat('F \d\e Y');
} else {
$periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y');
}
$sheet->mergeCells('A5:B5');
$sheet->setCellValue('A5', 'PERÍODO:');
$sheet->getStyle('A5')->applyFromArray($styleLabel);
$sheet->mergeCells("C5:{$lastCol}5");
$sheet->setCellValue('C5', $periodoTexto);
$sheet->getStyle("C5:{$lastCol}5")->applyFromArray($styleBox);
$sheet->getStyle('C5')->getFont()->setSize(12);
// --- RESUMEN DE TOTALES ---
$totalVentas = $data->count();
$totalSubtotal = $data->sum('subtotal');
$totalIva = $data->sum('iva');
$totalDescuentos = $data->sum('descuento');
$totalMonto = $data->sum('total');
$row = 7;
$sheet->setCellValue('A' . $row, 'TOTAL VENTAS:');
$sheet->setCellValue('B' . $row, $totalVentas);
$sheet->setCellValue('D' . $row, 'SUBTOTAL:');
$sheet->setCellValue('E' . $row, '$' . number_format($totalSubtotal, 2));
$sheet->setCellValue('G' . $row, 'IVA:');
$sheet->setCellValue('H' . $row, '$' . number_format($totalIva, 2));
$sheet->setCellValue('I' . $row, 'DESCUENTOS:');
$sheet->setCellValue('J' . $row, '$' . number_format($totalDescuentos, 2));
$sheet->setCellValue('K' . $row, 'TOTAL:');
$sheet->setCellValue('L' . $row, '$' . number_format($totalMonto, 2));
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setBold(true);
$sheet->getStyle('B' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
$sheet->getStyle('E' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
$sheet->getStyle('H' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
$sheet->getStyle('J' . $row)->getFont()->setSize(12)->getColor()->setRGB('FF6600');
$sheet->getStyle('L' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000');
// --- ENCABEZADOS DE TABLA ---
$h = 9;
$headers = [
'A' => 'No.',
'B' => 'FOLIO',
'C' => 'FECHA',
'D' => 'CLIENTE',
'E' => 'RFC',
'F' => 'VENDEDOR',
'G' => 'SUBTOTAL',
'H' => 'IVA',
'I' => 'DESCUENTO',
'J' => 'TOTAL',
'K' => "MÉTODO\nPAGO",
'L' => 'ESTADO',
];
foreach ($headers as $col => $text) {
$sheet->setCellValue("{$col}{$h}", $text);
}
$sheet->getStyle("A{$h}:{$lastCol}{$h}")->applyFromArray($styleTableHeader);
$sheet->getRowDimension($h)->setRowHeight(35);
// --- LLENADO DE DATOS ---
$row = 10;
$i = 1;
foreach ($data as $item) {
$sheet->setCellValue('A' . $row, $i);
$sheet->setCellValue('B' . $row, $item['folio']);
$sheet->setCellValue('C' . $row, $item['fecha']);
$sheet->setCellValue('D' . $row, $item['cliente']);
$sheet->setCellValue('E' . $row, $item['rfc_cliente']);
$sheet->setCellValue('F' . $row, $item['vendedor']);
$sheet->setCellValue('G' . $row, '$' . number_format($item['subtotal'], 2));
$sheet->setCellValue('H' . $row, '$' . number_format($item['iva'], 2));
$sheet->setCellValue('I' . $row, '$' . number_format($item['descuento'], 2));
$sheet->setCellValue('J' . $row, '$' . number_format($item['total'], 2));
$sheet->setCellValue('K' . $row, $item['metodo_pago']);
$sheet->setCellValue('L' . $row, $item['status']);
// Estilos de fila
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10]
]);
// Centrados
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("C{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("K{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("L{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// Color de estado
if ($item['status'] === 'Cancelada') {
$sheet->getStyle("L{$row}")->getFont()->getColor()->setRGB('FF0000');
}
// Color alterno de filas
if ($i % 2 == 0) {
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setRGB('F2F2F2');
}
$row++;
$i++;
}
// --- ANCHOS DE COLUMNA ---
$sheet->getColumnDimension('A')->setWidth(5);
$sheet->getColumnDimension('B')->setWidth(22);
$sheet->getColumnDimension('C')->setWidth(18);
$sheet->getColumnDimension('D')->setWidth(22);
$sheet->getColumnDimension('E')->setWidth(16);
$sheet->getColumnDimension('F')->setWidth(20);
$sheet->getColumnDimension('G')->setWidth(14);
$sheet->getColumnDimension('H')->setWidth(12);
$sheet->getColumnDimension('I')->setWidth(14);
$sheet->getColumnDimension('J')->setWidth(14);
$sheet->getColumnDimension('K')->setWidth(14);
$sheet->getColumnDimension('L')->setWidth(14);
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
}
/**
* Generar reporte Excel de inventario
*/
public function inventoryReport(Request $request)
{
// 1. VALIDACIÓN
$request->validate([
'fecha_inicio' => 'sometimes|date',
'fecha_fin' => 'sometimes|date|after_or_equal:fecha_inicio',
'category_id' => 'sometimes|nullable|exists:categories,id',
'with_serials_only' => 'sometimes|nullable|boolean',
'low_stock_threshold' => 'sometimes|nullable|integer|min:0',
]);
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
// 2. CONSULTA DE INVENTARIO
$query = Inventory::with([
'category:id,name',
'price:inventory_id,cost,retail_price',
'unitOfMeasure:id,abbreviation',
'serials'
])
->when($request->q, function($q) use ($request) {
$q->where(function($query) use ($request) {
$query->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
})
->when($request->category_id, function($q) use ($request) {
$q->where('category_id', $request->category_id);
})
->when($request->with_serials_only, function($q) {
$q->where('track_serials', true);
})
->when($request->low_stock_threshold, function($q) use ($request) {
$q->whereHas('warehouses', function($wq) use ($request) {
$wq->where('inventory_warehouse.stock', '<=', $request->low_stock_threshold);
});
});
$inventories = $query->orderBy('name')->get();
if ($inventories->isEmpty()) {
return response()->json(['message' => 'No se encontraron productos'], 404);
}
// 3. MAPEO DE DATOS
$data = $inventories->map(function($inventory) use ($fechaInicio, $fechaFin) {
// Cantidad vendida en el periodo
$quantitySold = SaleDetail::where('inventory_id', $inventory->id)
->whereHas('sale', function($q) use ($fechaInicio, $fechaFin) {
$q->where('status', 'completed')
->whereBetween('created_at', [$fechaInicio, $fechaFin]);
})
->sum('quantity');
// Conteo de seriales
$serialsTotal = $inventory->serials->count();
$serialsAvailable = $inventory->serials->where('status', 'disponible')->count();
$serialsSold = $inventory->serials->where('status', 'vendido')->count();
$serialsReturned = $inventory->serials->where('status', 'devuelto')->count();
$cost = $inventory->price?->cost ?? 0;
$retailPrice = $inventory->price?->retail_price ?? 0;
$totalSold = $quantitySold * $retailPrice;
$inventoryValue = $inventory->stock * $cost;
$unitProfit = $retailPrice - $cost;
$totalProfit = $unitProfit * $quantitySold;
return [
'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,
'serials_available' => $serialsAvailable,
'serials_sold' => $serialsSold,
'serials_returned' => $serialsReturned,
'cost' => (float) $cost,
'price' => (float) $retailPrice,
'total_sold' => $totalSold,
'inventory_value' => $inventoryValue,
'unit_profit' => (float) $unitProfit,
'total_profit' => $totalProfit,
];
});
// 4. CONFIGURACIÓN EXCEL
$fileName = 'Reporte_Inventario_' . $fechaInicio->format('Ymd') . '_' . $fechaFin->format('Ymd') . '.xlsx';
$filePath = storage_path('app/temp/' . $fileName);
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Fuente Global
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
$sheet->getParent()->getDefaultStyle()->getFont()->setSize(10);
// Estilos Comunes
$styleBox = [
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => '000000']]],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER]
];
$styleLabel = [
'font' => ['size' => 12, 'bold' => true],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER]
];
$styleTableHeader = [
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '70AD47']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]
];
// --- ESTRUCTURA DEL DOCUMENTO ---
$lastCol = 'N';
$sheet->getRowDimension(2)->setRowHeight(10);
$sheet->getRowDimension(3)->setRowHeight(25);
$sheet->getRowDimension(5)->setRowHeight(30);
// --- TÍTULO PRINCIPAL ---
$sheet->mergeCells("A3:{$lastCol}3");
$sheet->setCellValue('A3', 'REPORTE DE INVENTARIO');
$sheet->getStyle('A3')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '000000']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
// --- INFORMACIÓN DEL PERIODO ---
$sheet->mergeCells('A5:B5');
$sheet->setCellValue('A5', 'PERÍODO:');
$sheet->getStyle('A5')->applyFromArray($styleLabel);
Carbon::setLocale('es');
if ($fechaInicio->format('m/Y') === $fechaFin->format('m/Y')) {
$periodoTexto = 'del ' . $fechaInicio->format('d') . ' al ' . $fechaFin->format('d') . ' de ' . $fechaFin->translatedFormat('F \d\e Y');
} else {
$periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y');
}
$sheet->mergeCells("C5:{$lastCol}5");
$sheet->setCellValue('C5', $periodoTexto);
$sheet->getStyle("C5:{$lastCol}5")->applyFromArray($styleBox);
$sheet->getStyle('C5')->getFont()->setSize(12);
// --- RESUMEN DE TOTALES ---
$totalProducts = $data->count();
$totalStock = $data->sum('stock');
$totalQuantitySold = $data->sum('quantity_sold');
$totalSoldValue = $data->sum('total_sold');
$totalInventoryValue = $data->sum('inventory_value');
$totalProfit = $data->sum('total_profit');
$row = 7;
// Columna A-B: TOTAL PRODUCTOS
$sheet->mergeCells("A{$row}:B{$row}");
$sheet->setCellValue("A{$row}", 'TOTAL PRODUCTOS:');
$sheet->setCellValueExplicit("C{$row}", (int) $totalProducts, DataType::TYPE_NUMERIC);
// Columna D-E: STOCK TOTAL
$sheet->mergeCells("D{$row}:E{$row}");
$sheet->setCellValue("D{$row}", 'STOCK TOTAL:');
$sheet->setCellValueExplicit("F{$row}", (int) $totalStock, DataType::TYPE_NUMERIC);
// Columna G: VENDIDOS
$sheet->setCellValue("G{$row}", 'VENDIDOS:');
$sheet->setCellValueExplicit("H{$row}", (int) $totalQuantitySold, DataType::TYPE_NUMERIC);
// Columna I-J: TOTAL VENDIDO
$sheet->mergeCells("I{$row}:J{$row}");
$sheet->setCellValue("I{$row}", 'TOTAL VENDIDO:');
$sheet->setCellValueExplicit("K{$row}", (float) $totalSoldValue, DataType::TYPE_NUMERIC);
// Columna L-M: VALOR INVENTARIO
$sheet->mergeCells("L{$row}:M{$row}");
$sheet->setCellValue("L{$row}", 'VALOR INVENTARIO:');
$sheet->setCellValueExplicit("N{$row}", (float) $totalInventoryValue, DataType::TYPE_NUMERIC);
// Columna O: UTILIDAD TOTAL
$sheet->setCellValue("O{$row}", 'UTILIDAD TOTAL:');
$sheet->setCellValueExplicit("P{$row}", (float) $totalProfit, DataType::TYPE_NUMERIC);
// Aplicar formato moneda
$sheet->getStyle("K{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle("N{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle("P{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
// Aplicar estilos a TODA la fila
$sheet->getStyle("A{$row}:P{$row}")->getFont()->setBold(true);
// Centrar vertical y horizontalmente TODOS los datos
$sheet->getStyle("A{$row}:P{$row}")->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// Colores para cada valor numérico
$sheet->getStyle("C{$row}")->getFont()->setSize(12)->getColor()->setRGB('70AD47');
$sheet->getStyle("F{$row}")->getFont()->setSize(12)->getColor()->setRGB('70AD47');
$sheet->getStyle("H{$row}")->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
$sheet->getStyle("K{$row}")->getFont()->setSize(12)->getColor()->setRGB('FF6600');
$sheet->getStyle("N{$row}")->getFont()->setSize(12)->getColor()->setRGB('008000');
$sheet->getStyle("P{$row}")->getFont()->setSize(12);
// Color de utilidad (verde si es positiva, rojo si es negativa)
if ($totalProfit > 0) {
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('008000');
} elseif ($totalProfit < 0) {
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('FF0000');
}
// Altura de fila para que se vea mejor
$sheet->getRowDimension($row)->setRowHeight(25);
// --- ENCABEZADOS DE TABLA ---
$h = 9;
$headers = [
'A' => 'No.',
'B' => 'SKU',
'C' => 'NOMBRE',
'D' => 'CATEGORÍA',
'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}:Q{$h}")->applyFromArray($styleTableHeader);
$sheet->getRowDimension($h)->setRowHeight(35);
// --- LLENADO DE DATOS ---
$row = 10;
$i = 1;
$totalProfit = 0;
foreach ($data as $item) {
$sheet->setCellValue('A' . $row, $i);
$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('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('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}:Q{$row}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10]
]);
// Centrados
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("B{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("E{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("F{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("G{$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("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("K{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// 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("Q{$row}")->getFont()->getColor()->setRGB('008000');
} elseif ($item['total_profit'] < 0) {
$sheet->getStyle("Q{$row}")->getFont()->getColor()->setRGB('FF0000');
}
// Color alterno de filas
if ($i % 2 == 0) {
$sheet->getStyle("A{$row}:Q{$row}")->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setRGB('F2F2F2');
}
$row++;
$i++;
}
// --- ANCHOS DE COLUMNA ---
$sheet->getColumnDimension('A')->setWidth(6);
$sheet->getColumnDimension('B')->setWidth(15);
$sheet->getColumnDimension('C')->setWidth(25);
$sheet->getColumnDimension('D')->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);
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
}
/**
* Traducir método de pago
*/
private function translatePaymentMethod(?string $method): string
{
return match($method) {
'cash' => 'Efectivo',
'credit_card' => 'T. Crédito',
'debit_card' => 'T. Débito',
'transfer' => 'Transferencia',
default => $method ?? 'N/A',
};
}
}

View File

@ -0,0 +1,259 @@
<?php namespace App\Http\Controllers\App;
use App\Models\Inventory;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\InventoryStoreRequest;
use App\Http\Requests\App\InventoryUpdateRequest;
use App\Http\Requests\App\InventoryImportRequest;
use App\Services\ProductService;
use App\Imports\ProductsImport;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Notsoweb\ApiResponse\Enums\ApiResponse;
use Maatwebsite\Excel\Facades\Excel;
use Maatwebsite\Excel\Validators\ValidationException;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\Exportable;
class InventoryController extends Controller
{
public function __construct(
protected ProductService $productService
) {}
public function index(Request $request)
{
$products = Inventory::with(['category', 'subcategory', 'price', 'unitOfMeasure'])->withCount('serials')
->where('is_active', true);
// Filtro por búsqueda de texto (nombre, SKU, código de barras)
if ($request->has('q') && $request->q) {
$products->where(function($query) use ($request) {
$query->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
}
// Filtro por categoría (independiente de la búsqueda de texto)
if ($request->has('category_id') && $request->category_id) {
$products->where('category_id', $request->category_id);
}
// Calcular el valor total del inventario
$totalInventoryValue = DB::table('inventory_warehouse')
->join('prices', 'inventory_warehouse.inventory_id', '=', 'prices.inventory_id')
->join('inventories', 'inventory_warehouse.inventory_id', '=', 'inventories.id')
->where('inventories.is_active', true)
->sum(DB::raw('inventory_warehouse.stock * prices.cost'));
$products = $products->orderBy('name')
->paginate(config('app.pagination'));
// Stock del almacén principal por producto
$mainWarehouseId = DB::table('warehouses')->where('is_main', true)->value('id');
$mainWarehouseStocks = $mainWarehouseId
? DB::table('inventory_warehouse')
->where('warehouse_id', $mainWarehouseId)
->whereIn('inventory_id', $products->pluck('id')->toArray())
->pluck('stock', 'inventory_id')
: collect();
$products->each(function ($product) use ($mainWarehouseStocks) {
$product->main_warehouse_stock = (float) ($mainWarehouseStocks[$product->id] ?? 0);
});
return ApiResponse::OK->response([
'products' => $products,
'total_inventory_value' => round($totalInventoryValue, 2),
'main_warehouse_id' => $mainWarehouseId,
]);
}
public function show(Inventory $inventario)
{
return ApiResponse::OK->response([
'model' => $inventario->load(['category', 'subcategory', 'price', 'unitOfMeasure'])->loadCount('serials')
]);
}
public function store(InventoryStoreRequest $request)
{
$product = $this->productService->createProduct($request->validated());
return ApiResponse::OK->response([
'model' => $product
]);
}
public function update(InventoryUpdateRequest $request, Inventory $inventario)
{
$product = $this->productService->updateProduct($inventario, $request->validated());
return ApiResponse::OK->response([
'model' => $product
]);
}
public function destroy(Inventory $inventario)
{
$inventario->delete();
return ApiResponse::OK->response();
}
/**
* Obtener productos disponibles en un almacén específico
*/
public function getProductsByWarehouse(Request $request, int $warehouseId)
{
$query = Inventory::query()
->with(['category', 'subcategory', 'price', 'unitOfMeasure'])
->where('is_active', true)
->whereHas('warehouses', function ($q) use ($warehouseId) {
$q->where('warehouse_id', $warehouseId)
->where('stock', '>', 0);
});
// Filtro por búsqueda de texto
if ($request->has('q') && $request->q) {
$query->where(function($q) use ($request) {
$q->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
}
// Filtro por categoría
if ($request->has('category_id') && $request->category_id) {
$query->where('category_id', $request->category_id);
}
$products = $query->orderBy('name')->get();
// Agregar el stock específico de este almacén a cada producto
$products->each(function ($product) use ($warehouseId) {
$warehouseStock = $product->warehouses()
->where('warehouse_id', $warehouseId)
->first();
$product->warehouse_stock = $warehouseStock ? $warehouseStock->pivot->stock : 0;
});
return ApiResponse::OK->response([
'products' => $products,
'warehouse_id' => $warehouseId,
]);
}
/**
* Importar productos desde Excel
*/
public function import(InventoryImportRequest $request)
{
try {
$import = new ProductsImport();
Excel::import($import, $request->file('file'));
$stats = $import->getStats();
return ApiResponse::OK->response([
'message' => 'Importación completada exitosamente.',
'imported' => $stats['imported'],
'updated' => $stats['updated'],
'skipped' => $stats['skipped'],
'errors' => $stats['errors'],
]);
} catch (ValidationException $e) {
$failures = $e->failures();
$errors = [];
foreach ($failures as $failure) {
$errors[] = [
'row' => $failure->row(),
'attribute' => $failure->attribute(),
'errors' => $failure->errors(),
'values' => $failure->values(),
];
}
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error de validación en el archivo.',
'errors' => $errors,
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al importar productos: ' . $e->getMessage(),
]);
}
}
/**
* Descargar plantilla de Excel para importación
*/
public function downloadTemplate()
{
$headers = [
'nombre',
'sku',
'codigo_barras',
'categoria',
'unidad_medida',
'precio_venta',
'impuesto'
];
$exampleData = [
[
'nombre' => 'Samsung Galaxy A55',
'sku' => 'SAM-A55-BLK',
'codigo_barras' => '7502276853456',
'categoria' => 'Electrónica',
'unidad_medida' => 'Pieza',
'precio_venta' => 7500.00,
'impuesto' => 16
],
[
'nombre' => 'Cable UTP CAT6 (Metro)',
'sku' => 'UTP6-MTR',
'codigo_barras' => '750227686666',
'categoria' => 'Cables',
'unidad_medida' => 'Metro',
'precio_venta' => 50.00,
'impuesto' => 16
],
[
'nombre' => 'Laptop HP Pavilion 15',
'sku' => 'HP-LAP-15',
'codigo_barras' => '7502276854443',
'categoria' => 'Computadoras',
'unidad_medida' => 'Pieza',
'precio_venta' => 12000.00,
'impuesto' => 16
],
];
return Excel::download(
new class($headers, $exampleData) implements FromArray, WithHeadings {
use Exportable;
public function __construct(private array $headers, private array $data) {}
public function array(): array
{
return $this->data;
}
public function headings(): array
{
return $this->headers;
}
},
'plantilla_productos.xlsx'
);
}
}

View File

@ -0,0 +1,203 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\InventoryEntryRequest;
use App\Http\Requests\App\InventoryExitRequest;
use App\Http\Requests\App\InventoryMovementUpdateRequest;
use App\Http\Requests\App\InventoryTransferRequest;
use App\Models\InventoryMovement;
use App\Services\InventoryMovementService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Controlador para movimientos de inventario
*/
class InventoryMovementController extends Controller
{
public function __construct(
protected InventoryMovementService $movementService
) {}
/**
* Listar movimientos con filtros
*/
public function index(Request $request)
{
$query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier'])
->orderBy('created_at', 'desc');
if ($request->has('q') && $request->q){
$query->whereHas('inventory', function($qy) use ($request){
$qy->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
}
if ($request->has('movement_type')) {
$query->where('movement_type', $request->movement_type);
}
if ($request->has('inventory_id')) {
$query->where('inventory_id', $request->inventory_id);
}
if ($request->has('warehouse_id')) {
$query->where(function ($q) use ($request) {
$q->where('warehouse_from_id', $request->warehouse_id)
->orWhere('warehouse_to_id', $request->warehouse_id);
});
}
if ($request->has('from_date')) {
$query->whereDate('created_at', '>=', $request->from_date);
}
if ($request->has('to_date')) {
$query->whereDate('created_at', '<=', $request->to_date);
}
$movements = $query->paginate($request->get('per_page', 15));
return ApiResponse::OK->response(['movements' => $movements]);
}
/**
* Ver detalle de un movimiento
*/
public function show(int $id)
{
$movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier', 'serials', 'transferredSerials', 'exitedSerials'])
->find($id);
if (!$movement) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Movimiento no encontrado'
]);
}
return ApiResponse::OK->response([
'movement' => $movement
]);
}
/**
* Editar movimiento de inventario
* Revierte el movimiento original y aplica el nuevo
*/
public function update(InventoryMovementUpdateRequest $request, int $id)
{
try {
$movement = $this->movementService->updateMovement(
$id,
$request->validated()
);
return ApiResponse::OK->response([
'message' => 'Movimiento actualizado correctamente',
'movement' => $movement,
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
/**
* Registrar entrada de inventario
*/
public function entry(InventoryEntryRequest $request)
{
try {
$validated = $request->validated();
if(isset($validated['products'])){
$movements = $this->movementService->bulkEntry($validated);
return ApiResponse::CREATED->response([
'message' => 'Entrada registrada correctamente',
'movement' => $movements,
'total_products' => count($movements),
]);
} else {
$movement = $this->movementService->entry($validated);
return ApiResponse::CREATED->response([
'message' => 'Entrada registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseTo', 'supplier']),
]);
}
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
/**
* Registrar salida de inventario
*/
public function exit(InventoryExitRequest $request)
{
try {
$validated = $request->validated();
if(isset($validated['products'])){
$movements = $this->movementService->bulkExit($validated);
return ApiResponse::CREATED->response([
'message' => 'Salidas registradas correctamente',
'movements' => $movements,
'total_products' => count($movements),
]);
} else {
$movement = $this->movementService->exit($validated);
return ApiResponse::CREATED->response([
'message' => 'Salida registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom', 'exitedSerials']),
]);
}
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
/**
* Registrar traspaso entre almacenes
*/
public function transfer(InventoryTransferRequest $request)
{
try {
$validated = $request->validated();
if(isset($validated['products'])){
$movements = $this->movementService->bulkTransfer($validated);
return ApiResponse::CREATED->response([
'message' => 'Traspasos registrados correctamente',
'movements' => $movements,
'total_products' => count($movements),
]);
} else {
$movement = $this->movementService->transfer($validated);
return ApiResponse::CREATED->response([
'message' => 'Traspaso registrado correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']),
]);
}
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
}

View File

@ -0,0 +1,114 @@
<?php namespace App\Http\Controllers\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Http\Controllers\Controller;
use App\Models\Inventory;
use App\Models\InventorySerial;
use App\Models\Warehouse;
use App\Services\InventoryMovementService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Controlador para gestión de números de serie
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
* @version 1.0.0
*/
class InventorySerialController extends Controller
{
/**
* Listar seriales de un producto
*/
public function index(Inventory $inventario, Request $request)
{
$query = $inventario->serials();
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
} elseif ($request->boolean('main_warehouse')) {
$mainWarehouse = Warehouse::where('is_main', true)->first();
if ($mainWarehouse) {
$query->where(function ($q) use ($mainWarehouse) {
$q->where('warehouse_id', $mainWarehouse->id)
->orWhereNull('warehouse_id');
});
}
}
if ($request->has('q')) {
$query->where('serial_number', 'like', "%{$request->q}%");
}
$serials = $query->orderBy('serial_number', 'ASC')->with(['saleDetail.sale', 'warehouse'])
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'serials' => $serials,
'inventory' => $inventario->load('category'),
]);
}
public function search(Request $request)
{
$serialNumber = $request->input('serial_number');
$serial = InventorySerial::with(['inventory.price', 'inventory.category'])
->where('serial_number', $serialNumber)
->where('status', 'disponible')
->first();
return response()->json([
'status' => 'success',
'data' => ['serial' => $serial]
]);
}
/**
* Mostrar un serial específico
*/
public function show(Inventory $inventario, InventorySerial $serial)
{
// Verificar que el serial pertenece al inventario
if ($serial->inventory_id !== $inventario->id) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario'
]);
}
return ApiResponse::OK->response([
'serial' => $serial->load('saleDetail'),
'inventory' => $inventario->load('category'),
]);
}
/**
* Eliminar un serial
*/
public function destroy(Inventory $inventario, InventorySerial $serial)
{
// Verificar que el serial pertenece al inventario
if ($serial->inventory_id !== $inventario->id) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario'
]);
}
$serial->delete();
// Sincronizar stock
$inventario->syncStock();
return ApiResponse::OK->response([
'message' => 'Serial eliminado exitosamente',
'inventory' => $inventario->fresh(),
]);
}
}

View File

@ -0,0 +1,246 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\InvoiceStoreRequest;
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
{
/**
* Muestra los datos de la venta para el formulario de facturación.
*/
public function show(string $invoiceNumber)
{
$sale = Sale::where('invoice_number', $invoiceNumber)
->with([
'client',
'details.inventory.category',
'details.serials',
'user:id,name,email',
'invoiceRequests' => function ($query) {
$query->orderBy('requested_at', 'desc');
},
])->first();
if (!$sale) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Venta no encontrada'
]);
}
// Verificar si ya tiene solicitud pendiente o procesada
$existingRequest = $sale->invoiceRequests
->whereIn('status', [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED])
->first();
if ($existingRequest) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Esta venta ya tiene una solicitud de facturación ' .
($existingRequest->status === InvoiceRequest::STATUS_PENDING ? 'pendiente' : 'procesada'),
'invoice_request' => $existingRequest,
]);
}
// 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.
*/
public function store(InvoiceStoreRequest $request, string $invoiceNumber)
{
$sale = Sale::where('invoice_number', $invoiceNumber)
->with('details.serials')
->first();
if (!$sale) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Venta no encontrada'
]);
}
$existingRequest = InvoiceRequest::where('sale_id', $sale->id)
->whereIn('status', [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED])
->first();
if ($existingRequest) {
return ApiResponse::NO_CONTENT->response([
'message' => 'Esta venta ya tiene una solicitud de facturación ' .
($existingRequest->status === InvoiceRequest::STATUS_PENDING ? 'pendiente' : 'procesada')
]);
}
// Verificar que la venta esté completada
if ($sale->status !== 'completed') {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Solo se pueden facturar ventas completadas'
]);
}
// Buscar si ya existe un cliente con ese RFC
$client = Client::where('rfc', strtoupper($request->rfc))->first();
if ($client) {
// 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([
'name' => $request->name,
'client_number' => strtoupper($request->rfc),
'email' => $request->email,
'phone' => $request->phone,
'address' => $request->address,
'rfc' => strtoupper($request->rfc),
'razon_social' => $request->razon_social,
'regimen_fiscal' => $request->regimen_fiscal,
'cp_fiscal' => $request->cp_fiscal,
'uso_cfdi' => $request->uso_cfdi,
]);
}
// Asociar cliente a la venta solo si no está ya asociado
if ($sale->client_id !== $client->id) {
$sale->update(['client_id' => $client->id]);
}
// Crear solicitud de facturación
$invoiceRequest = InvoiceRequest::create([
'sale_id' => $sale->id,
'client_id' => $client->id,
'status' => InvoiceRequest::STATUS_PENDING,
'requested_at' => now(),
]);
return ApiResponse::OK->response([
'message' => 'Solicitud de facturación guardada correctamente',
'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
*/
private function formatSaleData(Sale $sale): array
{
return [
'id' => $sale->id,
'invoice_number' => $sale->invoice_number,
'total' => $sale->total,
'subtotal' => $sale->subtotal,
'tax' => $sale->tax,
'payment_method' => $sale->payment_method,
'status' => $sale->status,
'created_at' => $sale->created_at,
'user' => $sale->user ? [
'id' => $sale->user->id,
'name' => $sale->user->name,
'email' => $sale->user->email,
] : null,
'items' => $sale->details->map(function ($detail) {
return [
'id' => $detail->id,
'product_name' => $detail->product_name,
'quantity' => $detail->quantity,
'unit_price' => $detail->unit_price,
'subtotal' => $detail->subtotal,
'category' => $detail->inventory->category->name ?? null,
'sku' => $detail->inventory->sku ?? null,
// Números de serie vendidos
'serial_numbers' => $detail->serials->map(function ($serial) {
return [
'serial_number' => $serial->serial_number,
'status' => $serial->status,
];
})->toArray(),
];
})->toArray(),
];
}
}

View File

@ -0,0 +1,284 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\InvoiceRequestProcessRequest;
use App\Http\Requests\App\InvoiceRequestRejectRequest;
use App\Http\Requests\App\InvoiceRequestUploadRequest;
use App\Models\InvoiceRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Controlador para gestión administrativa de solicitudes de factura
*/
class InvoiceRequestController extends Controller
{
/**
* Listar todas las solicitudes de factura con filtros y paginación
*/
public function index(Request $request)
{
$query = InvoiceRequest::with([
'sale:id,invoice_number,subtotal,tax,total,payment_method,status,created_at',
'sale.user:id,name,email',
'client:id,name,client_number,email,phone,rfc,razon_social',
'processedBy:id,name,email'
]);
// Filtro por estado
if ($request->has('status') && $request->status !== '') {
$query->where('status', $request->status);
}
// Filtro por rango de fechas
if ($request->has('date_from')) {
$query->whereDate('requested_at', '>=', $request->date_from);
}
if ($request->has('date_to')) {
$query->whereDate('requested_at', '<=', $request->date_to);
}
// Búsqueda por folio de venta
if ($request->has('invoice_number') && $request->invoice_number !== '') {
$query->whereHas('sale', function ($q) use ($request) {
$q->where('invoice_number', 'like', '%' . $request->invoice_number . '%');
});
}
// Búsqueda por cliente
if ($request->has('client_search') && $request->client_search !== '') {
$query->whereHas('client', function ($q) use ($request) {
$q->where('name', 'like', '%' . $request->client_search . '%')
->orWhere('rfc', 'like', '%' . $request->client_search . '%')
->orWhere('email', 'like', '%' . $request->client_search . '%');
});
}
// Ordenamiento
$sortBy = $request->get('sort_by', 'requested_at');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
// Paginación
$perPage = $request->get('per_page', 15);
$invoiceRequests = $query->paginate($perPage);
return ApiResponse::OK->response([
'invoice_requests' => $invoiceRequests
]);
}
/**
* Mostrar detalles de una solicitud específica
*/
public function show($id)
{
$invoiceRequest = InvoiceRequest::with([
'sale.user:id,name,email',
'sale.details:id,sale_id,inventory_id,product_name,quantity,unit_price,subtotal,discount_percentage,discount_amount',
'sale.details:inventory_id.category_id,name',
'sale.details.inventory:id,category_id,name,sku',
'sale.details.inventory.category:id,name',
'sale.details.serials:id,inventory_id,sale_detail_id,serial_number,status',
'client',
'processedBy:id,name,email'
])->find($id);
if (!$invoiceRequest) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Solicitud de factura no encontrada'
]);
}
return ApiResponse::OK->response([
'invoice_request' => $invoiceRequest
]);
}
/**
* Marcar una solicitud como procesada
*
*/
public function process(InvoiceRequestProcessRequest $request, $id)
{
$invoiceRequest = InvoiceRequest::find($id);
if (!$invoiceRequest) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Solicitud de factura no encontrada'
]);
}
if ($invoiceRequest->status !== InvoiceRequest::STATUS_PENDING) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Solo se pueden procesar solicitudes pendientes',
'current_status' => $invoiceRequest->status
]);
}
$invoiceRequest->markAsProcessed(
$request->user()->id,
$request->notes
);
$invoiceRequest->load([
'sale:id,invoice_number,total',
'client:id,name,rfc,email',
'processedBy:id,name'
]);
return ApiResponse::OK->response([
'message' => 'Solicitud marcada como procesada correctamente',
'invoice_request' => $invoiceRequest
]);
}
/**
* Rechazar una solicitud
*
*/
public function reject(InvoiceRequestRejectRequest $request, $id)
{
$invoiceRequest = InvoiceRequest::find($id);
if (!$invoiceRequest) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Solicitud de factura no encontrada'
]);
}
if ($invoiceRequest->status !== InvoiceRequest::STATUS_PENDING) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Solo se pueden rechazar solicitudes pendientes',
'current_status' => $invoiceRequest->status
]);
}
$invoiceRequest->markAsRejected(
$request->user()->id,
$request->notes
);
$invoiceRequest->load([
'sale:id,invoice_number,total',
'client:id,name,rfc,email',
'processedBy:id,name'
]);
return ApiResponse::OK->response([
'message' => 'Solicitud rechazada correctamente',
'invoice_request' => $invoiceRequest
]);
}
/**
* Obtener estadísticas de solicitudes de factura
*/
public function stats()
{
$stats = [
'pending' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PENDING)->count(),
'processed' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PROCESSED)->count(),
'rejected' => InvoiceRequest::where('status', InvoiceRequest::STATUS_REJECTED)->count(),
'total' => InvoiceRequest::count(),
'today_pending' => InvoiceRequest::where('status', InvoiceRequest::STATUS_PENDING)
->whereDate('requested_at', today())
->count(),
'this_month' => InvoiceRequest::whereMonth('requested_at', now()->month)
->whereYear('requested_at', now()->year)
->count(),
];
return ApiResponse::OK->response([
'stats' => $stats
]);
}
/**
* Subir archivos de factura (XML y PDF) y guardar UUID del CFDI
*/
public function uploadInvoiceFile(InvoiceRequestUploadRequest $request, $id)
{
$invoiceRequest = InvoiceRequest::find($id);
if (!$invoiceRequest) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Solicitud de factura no encontrada'
]);
}
// Validar que la solicitud esté pendiente o procesada (permitir actualizar facturas)
if (!in_array($invoiceRequest->status, [InvoiceRequest::STATUS_PENDING, InvoiceRequest::STATUS_PROCESSED])) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se pueden subir archivos a solicitudes rechazadas',
'current_status' => $invoiceRequest->status
]);
}
// Generar timestamp para nombres de archivo
$timestamp = now()->format('YmdHis');
$storagePath = 'invoices/' . now()->format('Y/m');
$updateData = [
'cfdi_uuid' => $request->cfdi_uuid,
];
// Procesar XML si se envió
if ($request->hasFile('invoice_xml')) {
// Eliminar XML anterior si existe
if ($invoiceRequest->invoice_xml_path) {
Storage::disk('public')->delete($invoiceRequest->invoice_xml_path);
}
$xmlFileName = "invoice-{$id}-{$timestamp}.xml";
$xmlPath = $request->file('invoice_xml')->storeAs(
$storagePath,
$xmlFileName,
'public'
);
$updateData['invoice_xml_path'] = $xmlPath;
}
// Procesar PDF (siempre requerido)
if ($request->hasFile('invoice_pdf')) {
// Eliminar PDF anterior si existe
if ($invoiceRequest->invoice_pdf_path) {
Storage::disk('public')->delete($invoiceRequest->invoice_pdf_path);
}
$pdfFileName = "invoice-{$id}-{$timestamp}.pdf";
$pdfPath = $request->file('invoice_pdf')->storeAs(
$storagePath,
$pdfFileName,
'public'
);
$updateData['invoice_pdf_path'] = $pdfPath;
}
// Actualizar registro
$invoiceRequest->update($updateData);
$invoiceRequest->load([
'sale:id,invoice_number',
'client:id,name,rfc',
]);
$files = [];
if (isset($xmlPath)) {
$files['xml_url'] = Storage::url($xmlPath);
}
if (isset($pdfPath)) {
$files['pdf_url'] = Storage::url($pdfPath);
}
return ApiResponse::OK->response([
'message' => 'Archivos de factura subidos correctamente',
'invoice_request' => $invoiceRequest,
'files' => $files
]);
}
}

View File

@ -0,0 +1,419 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Inventory;
use App\Models\InventoryMovement;
use Illuminate\Http\Request;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Color;
use Carbon\Carbon;
/**
* Controlador para generación de Kardex (Excel)
*/
class KardexController extends Controller
{
/**
* Generar Kardex en Excel para un producto o todos los movimientos
*/
public function export(Request $request)
{
// 1. VALIDACIÓN
$request->validate([
'inventory_id' => 'nullable|exists:inventories,id',
'warehouse_id' => 'nullable|exists:warehouses,id',
'fecha_inicio' => 'required|date',
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
'movement_type' => 'nullable|in:entry,exit,transfer,sale,return',
]);
$fechaInicio = Carbon::parse($request->fecha_inicio)->startOfDay();
$fechaFin = Carbon::parse($request->fecha_fin)->endOfDay();
// Si no hay inventory_id, mostrar todos los movimientos
if (!$request->inventory_id) {
return $this->exportAllMovements($request, $fechaInicio, $fechaFin);
}
$inventory = Inventory::with(['category', 'price'])->findOrFail($request->inventory_id);
// 2. CONSULTA DE MOVIMIENTOS
$query = InventoryMovement::where('inventory_id', $inventory->id)
->with(['warehouseFrom', 'warehouseTo', 'user'])
->whereBetween('created_at', [$fechaInicio, $fechaFin])
->orderBy('created_at', 'asc')
->orderBy('id', 'asc');
$warehouseId = $request->warehouse_id;
if ($warehouseId) {
$query->where(function ($q) use ($warehouseId) {
$q->where('warehouse_from_id', $warehouseId)
->orWhere('warehouse_to_id', $warehouseId);
});
}
if ($request->movement_type) {
$query->where('movement_type', $request->movement_type);
}
$movements = $query->get();
// 3. CONSTRUIR LÍNEAS DEL KARDEX
$kardexLines = [];
foreach ($movements as $movement) {
$kardexLines[] = [
'date' => $movement->created_at->format('d/m/Y H:i'),
'type' => $this->translateMovementType($movement->movement_type),
'warehouse_from' => $movement->warehouseFrom?->name ?? '-',
'warehouse_to' => $movement->warehouseTo?->name ?? '-',
'quantity' => $movement->quantity,
'unit_cost' => $movement->unit_cost ?? 0,
'notes' => $movement->notes ?? '',
'invoice_reference' => $movement->invoice_reference ?? '',
'user' => $movement->user?->name ?? '',
];
}
if (empty($kardexLines)) {
return response()->json(['message' => 'No se encontraron movimientos en el periodo especificado'], 404);
}
// 5. GENERAR EXCEL
$fileName = 'Kardex_' . ($inventory->sku ?? $inventory->id) . '_' . $fechaInicio->format('Ymd') . '_' . $fechaFin->format('Ymd') . '.xlsx';
$filePath = storage_path('app/temp/' . $fileName);
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Kardex');
// Fuente global
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
// Estilos
$styleLabel = [
'font' => ['bold' => true, 'size' => 10],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
];
$styleBox = [
'borders' => ['outline' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
];
$styleTableHeader = [
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '2E75B6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
];
$lastCol = 'J';
// --- TÍTULO ---
$sheet->mergeCells("A2:{$lastCol}2");
$sheet->setCellValue('A2', 'KARDEX DE PRODUCTO');
$sheet->getStyle('A2')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '2E75B6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(2)->setRowHeight(30);
// --- INFORMACIÓN DEL PRODUCTO ---
$row = 4;
$sheet->setCellValue("A{$row}", 'PRODUCTO:');
$sheet->getStyle("A{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("B{$row}:E{$row}");
$sheet->setCellValue("B{$row}", $inventory->name);
$sheet->getStyle("B{$row}:E{$row}")->applyFromArray($styleBox);
$sheet->setCellValue("G{$row}", 'SKU:');
$sheet->getStyle("G{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("H{$row}:{$lastCol}{$row}");
$sheet->setCellValue("H{$row}", $inventory->sku ?? 'N/A');
$sheet->getStyle("H{$row}:{$lastCol}{$row}")->applyFromArray($styleBox);
$row++;
$sheet->setCellValue("A{$row}", 'CATEGORÍA:');
$sheet->getStyle("A{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("B{$row}:E{$row}");
$sheet->setCellValue("B{$row}", $inventory->category?->name ?? 'Sin categoría');
$sheet->getStyle("B{$row}:E{$row}")->applyFromArray($styleBox);
$sheet->setCellValue("G{$row}", 'COSTO ACTUAL:');
$sheet->getStyle("G{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("H{$row}:{$lastCol}{$row}");
$sheet->setCellValue("H{$row}", '$' . number_format($inventory->price?->cost ?? 0, 2));
$sheet->getStyle("H{$row}:{$lastCol}{$row}")->applyFromArray($styleBox);
// --- PERÍODO ---
$row++;
Carbon::setLocale('es');
if ($fechaInicio->format('m/Y') === $fechaFin->format('m/Y')) {
$periodoTexto = 'del ' . $fechaInicio->format('d') . ' al ' . $fechaFin->format('d') . ' de ' . $fechaFin->translatedFormat('F \d\e Y');
} else {
$periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y');
}
$sheet->setCellValue("A{$row}", 'PERÍODO:');
$sheet->getStyle("A{$row}")->applyFromArray($styleLabel);
$sheet->mergeCells("B{$row}:{$lastCol}{$row}");
$sheet->setCellValue("B{$row}", $periodoTexto);
$sheet->getStyle("B{$row}:{$lastCol}{$row}")->applyFromArray($styleBox);
$sheet->getStyle("B{$row}")->getFont()->setSize(11);
// --- ENCABEZADOS DE TABLA ---
$row += 2;
$headerRow = $row;
$headers = [
'A' => 'FECHA',
'B' => 'TIPO',
'C' => "ALMACÉN\nORIGEN",
'D' => "ALMACÉN\nDESTINO",
'E' => 'CANTIDAD',
'F' => "COSTO\nUNITARIO",
'G' => 'REFERENCIA',
'H' => 'NOTAS',
'I' => 'USUARIO',
];
foreach ($headers as $col => $text) {
$sheet->setCellValue("{$col}{$row}", $text);
}
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray($styleTableHeader);
$sheet->getRowDimension($row)->setRowHeight(30);
// --- DATOS ---
$row++;
$totalQuantity = 0;
foreach ($kardexLines as $line) {
$sheet->setCellValue("A{$row}", $line['date']);
$sheet->setCellValue("B{$row}", $line['type']);
$sheet->setCellValue("C{$row}", $line['warehouse_from']);
$sheet->setCellValue("D{$row}", $line['warehouse_to']);
$sheet->setCellValue("E{$row}", $line['quantity']);
$sheet->setCellValue("F{$row}", $line['unit_cost'] ? '$' . number_format($line['unit_cost'], 2) : '');
$sheet->setCellValue("G{$row}", $line['invoice_reference']);
$sheet->setCellValue("H{$row}", $line['notes']);
$sheet->setCellValue("I{$row}", $line['user']);
$totalQuantity += $line['quantity'];
// Estilo de fila
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10],
]);
// Color alterno
if (($row - $headerRow) % 2 === 0) {
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'D9E2F3']],
]);
}
$row++;
}
// --- FILA DE TOTALES ---
$sheet->mergeCells("A{$row}:D{$row}");
$sheet->setCellValue("A{$row}", 'TOTALES');
$sheet->setCellValue("E{$row}", $totalQuantity);
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'font' => ['bold' => true, 'size' => 10],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '2E75B6']],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setColor(new Color('FFFFFF'));
// --- ANCHOS DE COLUMNA ---
$sheet->getColumnDimension('A')->setWidth(18);
$sheet->getColumnDimension('B')->setWidth(14);
$sheet->getColumnDimension('C')->setWidth(18);
$sheet->getColumnDimension('D')->setWidth(18);
$sheet->getColumnDimension('E')->setWidth(12);
$sheet->getColumnDimension('F')->setWidth(14);
$sheet->getColumnDimension('G')->setWidth(18);
$sheet->getColumnDimension('H')->setWidth(25);
$sheet->getColumnDimension('I')->setWidth(16);
$sheet->getColumnDimension('J')->setWidth(16);
// 6. GUARDAR Y DESCARGAR
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
}
/**
* Traducir tipo de movimiento
*/
private function translateMovementType(string $type): string
{
return match ($type) {
'entry' => 'Entrada',
'exit' => 'Salida',
'transfer' => 'Traspaso',
/* 'sale' => 'Venta',
'return' => 'Devolución', */
default => $type,
};
}
/**
* Exportar todos los movimientos (sin filtro de producto)
*/
private function exportAllMovements(Request $request, Carbon $fechaInicio, Carbon $fechaFin)
{
// CONSULTA DE MOVIMIENTOS
$query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user'])
->whereBetween('created_at', [$fechaInicio, $fechaFin])
->orderBy('created_at', 'asc')
->orderBy('id', 'asc');
if ($request->warehouse_id) {
$query->where(function ($q) use ($request) {
$q->where('warehouse_from_id', $request->warehouse_id)
->orWhere('warehouse_to_id', $request->warehouse_id);
});
}
if ($request->movement_type) {
$query->where('movement_type', $request->movement_type);
}
$movements = $query->get();
if ($movements->isEmpty()) {
return response()->json(['message' => 'No se encontraron movimientos en el periodo especificado'], 404);
}
// GENERAR EXCEL
$fileName = 'Movimientos_' . $fechaInicio->format('Ymd') . '_' . $fechaFin->format('Ymd') . '.xlsx';
$filePath = storage_path('app/temp/' . $fileName);
if (!file_exists(dirname($filePath))) mkdir(dirname($filePath), 0755, true);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Movimientos');
$sheet->getParent()->getDefaultStyle()->getFont()->setName('Arial');
$styleTableHeader = [
'font' => ['bold' => true, 'size' => 10, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '2E75B6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
];
$lastCol = 'J';
// TÍTULO
$sheet->mergeCells("A2:{$lastCol}2");
$sheet->setCellValue('A2', 'REPORTE DE MOVIMIENTOS DE INVENTARIO');
$sheet->getStyle('A2')->applyFromArray([
'font' => ['bold' => true, 'size' => 16, 'color' => ['rgb' => '2E75B6']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(2)->setRowHeight(30);
// PERÍODO
$row = 4;
Carbon::setLocale('es');
$periodoTexto = 'del ' . $fechaInicio->format('d/m/Y') . ' al ' . $fechaFin->format('d/m/Y');
$sheet->setCellValue("A{$row}", 'PERÍODO:');
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
$sheet->mergeCells("B{$row}:{$lastCol}{$row}");
$sheet->setCellValue("B{$row}", $periodoTexto);
$sheet->getStyle("B{$row}:{$lastCol}{$row}")->applyFromArray([
'borders' => ['outline' => ['borderStyle' => Border::BORDER_THIN]],
]);
// ENCABEZADOS
$row += 2;
$headerRow = $row;
$headers = [
'A' => 'FECHA',
'B' => 'PRODUCTO',
'C' => 'TIPO',
'D' => "ALMACÉN\nORIGEN",
'E' => "ALMACÉN\nDESTINO",
'F' => 'CANTIDAD',
'G' => "COSTO\nUNITARIO",
'H' => 'REFERENCIA',
'I' => 'NOTAS',
'J' => 'USUARIO',
];
foreach ($headers as $col => $text) {
$sheet->setCellValue("{$col}{$row}", $text);
}
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray($styleTableHeader);
$sheet->getRowDimension($row)->setRowHeight(30);
// DATOS
$row++;
foreach ($movements as $movement) {
$sheet->setCellValue("A{$row}", $movement->created_at->format('d/m/Y H:i'));
$sheet->setCellValue("B{$row}", $movement->inventory?->name ?? 'N/A');
$sheet->setCellValue("C{$row}", $this->translateMovementType($movement->movement_type));
$sheet->setCellValue("D{$row}", $movement->warehouseFrom?->name ?? '-');
$sheet->setCellValue("E{$row}", $movement->warehouseTo?->name ?? '-');
$sheet->setCellValue("F{$row}", $movement->quantity);
$sheet->setCellValue("G{$row}", $movement->unit_cost ? '$' . number_format($movement->unit_cost, 2) : '');
$sheet->setCellValue("H{$row}", $movement->invoice_reference ?? '');
$sheet->setCellValue("I{$row}", $movement->notes ?? '');
$sheet->setCellValue("J{$row}", $movement->user?->name ?? '');
// Estilo de fila
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10],
]);
// Color alterno
if (($row - $headerRow) % 2 === 0) {
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'D9E2F3']],
]);
}
$row++;
}
// ANCHOS DE COLUMNA
$sheet->getColumnDimension('A')->setWidth(18);
$sheet->getColumnDimension('B')->setWidth(30);
$sheet->getColumnDimension('C')->setWidth(14);
$sheet->getColumnDimension('D')->setWidth(18);
$sheet->getColumnDimension('E')->setWidth(18);
$sheet->getColumnDimension('F')->setWidth(12);
$sheet->getColumnDimension('G')->setWidth(14);
$sheet->getColumnDimension('H')->setWidth(18);
$sheet->getColumnDimension('I')->setWidth(25);
$sheet->getColumnDimension('J')->setWidth(16);
// GUARDAR Y DESCARGAR
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
return response()->download($filePath, $fileName)->deleteFileAfterSend(true);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\PriceUpdateRequest;
use App\Models\Price;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class PriceController extends Controller
{
public function index()
{
$prices = Price::with('inventory')->paginate();
return ApiResponse::OK->response(['prices' => $prices]);
}
public function show(Price $price)
{
return ApiResponse::OK->response(['model' => $price->load('inventory')]);
}
// Actualizar solo precio
public function update(PriceUpdateRequest $request, Price $precio)
{
$precio->update($request->validated());
return ApiResponse::OK->response(['model' => $precio->fresh('inventory')]);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Services\ReportService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ReportController extends Controller
{
public function __construct(
protected ReportService $reportService
) {}
/**
* Obtener el producto más vendido
*/
public function topSellingProduct(Request $request)
{
$request->validate([
'include_stock_value' => ['nullable', 'boolean'],
'from_date' => ['nullable', 'date_format:Y-m-d'],
'to_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:from_date', 'required_with:from_date'],
], [
'include_stock_value.boolean' => 'El parámetro de valor de stock debe ser verdadero o falso.',
'from_date.date_format' => 'La fecha inicial debe tener el formato Y-m-d (ejemplo: 2025-01-01).',
'to_date.date_format' => 'La fecha final debe tener el formato Y-m-d (ejemplo: 2025-01-31).',
'to_date.after_or_equal' => 'La fecha final debe ser igual o posterior a la fecha inicial.',
'to_date.required_with' => 'La fecha final es obligatoria cuando se proporciona fecha inicial.',
]);
try {
$product = $this->reportService->getTopSellingProduct(
fromDate: $request->input('from_date'),
toDate: $request->input('to_date')
);
if ($product === null) {
return ApiResponse::OK->response([
'product' => null,
'message' => 'No hay datos de ventas para el período especificado.'
]);
}
return ApiResponse::OK->response([
'product' => $product,
'message' => 'Producto más vendido obtenido exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al generar el reporte: ' . $e->getMessage()
]);
}
}
/**
* Obtener productos sin movimiento
*/
public function productsWithoutMovement(Request $request)
{
$request->validate([
'from_date' => ['required', 'date_format:Y-m-d'],
'to_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:from_date'],
], [
'from_date.required' => 'La fecha inicial es obligatoria.',
'from_date.date_format' => 'La fecha inicial debe tener el formato Y-m-d (ejemplo: 2025-01-01).',
'to_date.required' => 'La fecha final es obligatoria.',
'to_date.date_format' => 'La fecha final debe tener el formato Y-m-d (ejemplo: 2025-01-31).',
'to_date.after_or_equal' => 'La fecha final debe ser igual o posterior a la fecha inicial.',
]);
try {
$products = $this->reportService->getProductsWithoutMovement(
fromDate: $request->input('from_date'),
toDate: $request->input('to_date')
);
return ApiResponse::OK->response([
'products' => $products,
'message' => 'Reporte de productos sin movimiento generado exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al generar el reporte: ' . $e->getMessage()
]);
}
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\ReturnStoreRequest;
use App\Services\ReturnService;
use App\Models\Returns;
use App\Models\Sale;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ReturnController extends Controller
{
public function __construct(
protected ReturnService $returnService
) {}
/**
* Listar devoluciones
*/
public function index(Request $request)
{
$query = Returns::with([
'sale',
'user',
'cashRegister',
'details.inventory',
])->orderBy('created_at', 'desc');
if (!auth()->user()->hasRole('admin')) {
$query->where('user_id', auth()->id());
}
// Filtros
if ($request->has('q') && $request->q) {
$query->where('return_number', 'like', "%{$request->q}%")
->orWhereHas('sale', fn ($q) =>
$q->where('invoice_number', 'like', "%{$request->q}%")
);
}
if ($request->has('sale_id')) {
$query->where('sale_id', $request->sale_id);
}
if ($request->has('reason')) {
$query->where('reason', $request->reason);
}
if ($request->has('cash_register_id')) {
$query->where('cash_register_id', $request->cash_register_id);
}
if ($request->has('from') && $request->has('to')) {
$query->whereBetween('created_at', [$request->from, $request->to]);
}
$returns = $query->paginate(config('app.pagination', 15));
return ApiResponse::OK->response([
'returns' => $returns,
]);
}
/**
* Ver detalle de una devolución
*/
public function show(Returns $return)
{
$return->load([
'details.inventory',
'details.serials',
'sale.details',
'user',
'cashRegister',
]);
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para ver esta venta.'
]);
}
return ApiResponse::OK->response([
'model' => $return,
]);
}
/**
* Crear nueva devolución
*/
public function store(ReturnStoreRequest $request)
{
try {
$return = $this->returnService->createReturn($request->validated());
return ApiResponse::CREATED->response([
'model' => $return,
'message' => 'Devolución procesada exitosamente. Reembolso: $'.number_format($return->total, 2),
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage(),
]);
}
}
/**
* Cancelar una devolución (caso excepcional)
*/
public function cancel(Returns $return)
{
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para cancelar esta venta.'
]);
}
try {
$cancelledReturn = $this->returnService->cancelReturn($return);
return ApiResponse::OK->response([
'model' => $cancelledReturn,
'message' => 'Devolución cancelada exitosamente. Productos restaurados a vendido.',
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage(),
]);
}
}
/**
* Obtener items elegibles para devolución de una venta
*/
public function returnable(Sale $sale)
{
try {
$returnableItems = $this->returnService->getReturnableItems($sale);
return ApiResponse::OK->response([
'sale' => $sale->load('cashRegister'),
'returnable_items' => $returnableItems,
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\SaleStoreRequest;
use App\Services\SaleService;
use App\Models\Sale;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class SaleController extends Controller
{
public function __construct(
protected SaleService $saleService
) {}
public function index(Request $request)
{
$sales = Sale::with(['details.inventory', 'details.serials', 'user', 'client'])
->orderBy('created_at', 'desc');
// Filtrar por usuario: solo admin puede ver todas las ventas
if (!auth()->user()->hasRole('admin')) {
$sales->where('user_id', auth()->id());
}
if ($request->has('q') && $request->q) {
$sales->where(function ($query) use ($request) {
$query->where('invoice_number', 'like', "%{$request->q}%")
->orWhereHas('user', fn($q) =>
$q->where('name', 'like', "%{$request->q}%")
);
});
}
if ($request->has('cash_register_id')) {
$sales->where('cash_register_id', $request->cash_register_id);
}
if ($request->has('status')) {
$sales->where('status', $request->status);
}
return ApiResponse::OK->response([
'sales' => $sales->paginate(config('app.pagination')),
]);
}
public function show(Sale $sale)
{
// Solo admin puede ver ventas de otros usuarios
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para ver esta venta.'
]);
}
return ApiResponse::OK->response([
'model' => $sale->load(['details.inventory', 'user', 'client'])
]);
}
public function store(SaleStoreRequest $request)
{
$sale = $this->saleService->createSale($request->validated());
return ApiResponse::CREATED->response([
'model' => $sale,
]);
}
public function cancel(Sale $sale)
{
// Solo admin puede cancelar ventas de otros usuarios
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
return ApiResponse::FORBIDDEN->response([
'message' => 'No tienes permiso para cancelar esta venta.'
]);
}
try {
$cancelledSale = $this->saleService->cancelSale($sale);
return ApiResponse::OK->response([
'model' => $cancelledSale,
'message' => 'Venta cancelada exitosamente. Stock restaurado.'
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
}

View File

@ -0,0 +1,69 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\SubcategoryStoreRequest;
use App\Http\Requests\App\SubcategoryUpdateRequest;
use App\Models\Category;
use App\Models\Subcategory;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class SubcategoryController extends Controller
{
public function index(Category $category)
{
$subcategorias = $category->subcategories()
->orderBy('name')
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'subcategories' => $subcategorias,
]);
}
public function show(Category $category, Subcategory $subcategory)
{
return ApiResponse::OK->response([
'model' => $subcategory,
]);
}
public function store(SubcategoryStoreRequest $request, Category $category)
{
if($request->isBulk()){
$subcategorias = collect($request->validated())
->map(fn($data) => $category->subcategories()->create($data));
return ApiResponse::OK->response([
'models' => $subcategorias,
]);
}
$subcategoria = $category->subcategories()->create($request->validated());
return ApiResponse::OK->response([
'model' => $subcategoria,
]);
}
public function update(SubcategoryUpdateRequest $request, Category $category, Subcategory $subcategory)
{
$subcategory->update($request->validated());
return ApiResponse::OK->response([
'model' => $subcategory->fresh(),
]);
}
public function destroy(Category $category, Subcategory $subcategory)
{
if ($subcategory->inventories()->exists()) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se puede eliminar la subclasificación porque tiene productos asociados.'
]);
}
$subcategory->delete();
return ApiResponse::OK->response();
}
}

View File

@ -0,0 +1,124 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Supplier;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class SupplierController extends Controller
{
public function index(Request $request)
{
$query = Supplier::query();
// Filtro por búsqueda
if ($request->has('q')) {
$query->where(function($q) use ($request) {
$q->where('business_name', 'like', "%{$request->q}%")
->orWhere('rfc', 'like', "%{$request->q}%");
});
}
// Filtro por estado
if ($request->has('is_active')) {
$query->where('is_active', $request->is_active);
}
$suppliers = $query->orderBy('business_name')
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'suppliers' => $suppliers
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'business_name' => 'required|string|max:255',
'email' => 'nullable|email',
'phone' => 'nullable|string|max:10',
'rfc' => 'nullable|string|unique:suppliers,rfc',
'address' => 'nullable|string',
'postal_code' => 'nullable|string',
'notes' => 'nullable|string',
]);
$supplier = Supplier::create($validated);
return ApiResponse::CREATED->response([
'supplier' => $supplier
]);
}
public function show(Supplier $supplier)
{
return ApiResponse::OK->response([
'supplier' => $supplier->load('inventoryMovements')
]);
}
public function update(Request $request, Supplier $supplier)
{
$validated = $request->validate([
'business_name' => 'nullable|string|max:255',
'email' => 'nullable|email',
'phone' => 'nullable|string|max:10',
'rfc' => 'nullable|string|unique:suppliers,rfc,' . $supplier->id,
'address' => 'nullable|string',
'postal_code' => 'nullable|string',
'notes' => 'nullable|string',
]);
$supplier->update($validated);
return ApiResponse::OK->response([
'supplier' => $supplier->fresh()
]);
}
public function destroy(Supplier $supplier)
{
$supplier->delete();
return ApiResponse::OK->response();
}
/**
* Productos suministrados por el proveedor
*/
public function products(Supplier $supplier)
{
$products = $supplier->suppliedProducts()
->with(['category', 'price'])
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'products' => $products
]);
}
/**
* Historial de compras al proveedor
*/
public function purchases(Supplier $supplier, Request $request)
{
$query = $supplier->inventoryMovements()
->with(['inventory', 'warehouseTo', 'user'])
->orderBy('created_at', 'desc');
if ($request->has('from_date')) {
$query->whereDate('created_at', '>=', $request->from_date);
}
if ($request->has('to_date')) {
$query->whereDate('created_at', '<=', $request->to_date);
}
$purchases = $query->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'purchases' => $purchases,
'total_amount' => $supplier->total_purchases
]);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\UnitEquivalenceStoreRequest;
use App\Http\Requests\App\UnitEquivalenceUpdateRequest;
use App\Models\Inventory;
use App\Models\UnitEquivalence;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class UnitEquivalenceController extends Controller
{
public function index(Inventory $inventory)
{
$equivalences = $inventory->unitEquivalences()
->with('unitOfMeasure')
->get()
->map(function ($eq) use ($inventory) {
$basePrice = $inventory->price?->retail_price ?? 0;
return [
'id' => $eq->id,
'unit_of_measure_id' => $eq->unit_of_measure_id,
'unit_name' => $eq->unitOfMeasure->name,
'unit_abbreviation' => $eq->unitOfMeasure->abbreviation,
'conversion_factor' => $eq->conversion_factor,
'retail_price' => $eq->retail_price ?? round($basePrice * $eq->conversion_factor, 2),
'is_active' => $eq->is_active,
'created_at' => $eq->created_at,
];
});
return ApiResponse::OK->response([
'equivalences' => $equivalences,
'base_unit' => $inventory->unitOfMeasure,
]);
}
public function store(UnitEquivalenceStoreRequest $request, Inventory $inventory)
{
$equivalence = $inventory->unitEquivalences()->create($request->validated());
$equivalence->load('unitOfMeasure');
return ApiResponse::CREATED->response([
'message' => 'Equivalencia creada correctamente.',
'equivalence' => $equivalence,
]);
}
public function update(UnitEquivalenceUpdateRequest $request, Inventory $inventory, UnitEquivalence $equivalencia)
{
$equivalencia->update($request->validated());
return ApiResponse::OK->response([
'message' => 'Equivalencia actualizada correctamente.',
'equivalence' => $equivalencia->fresh('unitOfMeasure'),
]);
}
public function destroy(Inventory $inventory, UnitEquivalence $equivalencia)
{
$equivalencia->delete();
return ApiResponse::OK->response([
'message' => 'Equivalencia eliminada correctamente.',
]);
}
}

View File

@ -0,0 +1,87 @@
<?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 Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Descripción
*/
class UnitOfMeasurementController extends Controller
{
public function index(Request $request)
{
$query = UnitOfMeasurement::query();
// Filtro por búsqueda
if ($request->has('q')) {
$query->where(function($q) use ($request) {
$q->where('name', 'like', "%{$request->q}%")
->orWhere('abbreviation', 'like', "%{$request->q}%");
});
}
$units = $query->orderBy('name')->paginate(config('app.pagination'));
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,137 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\WarehouseStoreRequest;
use App\Http\Requests\App\WarehouseUpdateRequest;
use App\Models\Warehouse;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Controlador para gestión de almacenes
*/
class WarehouseController extends Controller
{
/**
* Listar almacenes
*/
public function index(Request $request)
{
$warehouse = Warehouse::query();
if ($request->has('active')) {
$warehouse->where('is_active', $request->boolean('active'));
}
if ($request->has('q') && $request->q) {
$warehouse->where(function($query) use ($request) {
$query->where('name', 'like', "%{$request->q}%")
->orWhere('code', 'like', "%{$request->q}%");
});
}
$warehouse->orderBy('id', 'ASC');
$warehouses = $request->boolean('all')
? $warehouse->get()
: $warehouse->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'warehouses' => $warehouses,
]);
}
/**
* Ver detalle de un almacén con su inventario
*/
public function show(int $id)
{
$warehouse = Warehouse::find($id);
if (!$warehouse) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Almacén no encontrado'
]);
}
$inventories = $warehouse->inventories()
->wherePivot('stock', '>', 0)
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'warehouse' => $warehouse,
'inventories' => $inventories,
]);
}
/**
* Crear almacén
*/
public function store(WarehouseStoreRequest $request)
{
$warehouse = Warehouse::create($request->validated());
return ApiResponse::CREATED->response([
'message' => 'Almacén creado correctamente',
'warehouse' => $warehouse,
]);
}
/**
* Actualizar almacén
*/
public function update(WarehouseUpdateRequest $request, int $id)
{
$warehouse = Warehouse::find($id);
if (!$warehouse) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Almacén no encontrado'
]);
}
$warehouse->update($request->validated());
return ApiResponse::OK->response([
'message' => 'Almacén actualizado correctamente',
'warehouse' => $warehouse,
]);
}
/**
* Eliminar almacén
*/
public function destroy(int $id)
{
$warehouse = Warehouse::find($id);
if (!$warehouse) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Almacén no encontrado'
]);
}
// Verificar si es el almacén principal
if ($warehouse->is_main) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se puede eliminar el almacén principal'
]);
}
// Verificar si tiene stock
$hasStock = $warehouse->inventories()->wherePivot('stock', '>', 0)->exists();
if ($hasStock) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se puede eliminar un almacén con stock'
]);
}
$warehouse->delete();
return ApiResponse::OK->response([
'message' => 'Almacén eliminado correctamente'
]);
}
}

View File

@ -0,0 +1,85 @@
<?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'],
'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'],
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'],
'customer_name' => ['required', 'string', 'max:255'],
]);
$result = $this->whatsAppService->sendInvoice(
phoneNumber: $validated['phone_number'],
pdfUrl: $validated['pdf_url'],
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
]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class BillStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'supplier_id' => ['nullable', 'exists:suppliers,id'],
'cost' => ['required', 'numeric', 'min:0'],
'deadline' => ['nullable', 'date'],
'paid' => ['boolean'],
'file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
];
}
/**
* Custom validation messages
*/
public function messages(): array
{
return [
'name.required' => 'El nombre de la factura es obligatorio.',
'supplier_id.exists' => 'El proveedor seleccionado no es válido.',
'cost.required' => 'El costo es obligatorio.',
'cost.numeric' => 'El costo debe ser un número válido.',
'cost.min' => 'El costo debe ser un valor positivo.',
'file.mimes' => 'El archivo debe ser PDF o imagen (jpg, jpeg, png).',
'file.max' => 'El archivo no puede superar los 10 MB.',
];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class BillUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'supplier_id' => ['nullable', 'exists:suppliers,id'],
'cost' => ['required', 'numeric', 'min:0'],
'deadline' => ['nullable', 'date'],
'paid' => ['boolean'],
'file' => ['sometimes', 'nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
];
}
/**
* Custom validation messages
*/
public function messages(): array
{
return [
'name.required' => 'El nombre de la factura es obligatorio.',
'supplier_id.exists' => 'El proveedor seleccionado no es válido.',
'cost.required' => 'El costo es obligatorio.',
'cost.numeric' => 'El costo debe ser un número válido.',
'cost.min' => 'El costo debe ser un valor positivo.',
'file.mimes' => 'El archivo debe ser PDF o imagen (jpg, jpeg, png).',
'file.max' => 'El archivo no puede superar los 10 MB.',
];
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class BundleStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'sku' => ['required', 'string', 'max:50', 'unique:bundles,sku'],
'barcode' => ['nullable', 'string', 'unique:bundles,barcode'],
// Componentes del kit (mínimo 2 productos)
'items' => ['required', 'array', 'min:2'],
'items.*.inventory_id' => ['required', 'exists:inventories,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
// Precio (opcional, se calcula automáticamente si no se provee)
'retail_price' => ['nullable', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0'],
];
}
/**
* Custom validation messages
*/
public function messages(): array
{
return [
'name.required' => 'El nombre del bundle es obligatorio.',
'sku.required' => 'El SKU es obligatorio.',
'sku.unique' => 'Este SKU ya está en uso.',
'items.required' => 'Debes agregar productos al bundle.',
'items.min' => 'Un bundle debe tener al menos 2 productos.',
'items.*.inventory_id.required' => 'Cada producto debe tener un ID válido.',
'items.*.inventory_id.exists' => 'Uno de los productos no existe.',
'items.*.quantity.required' => 'La cantidad es obligatoria.',
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.',
];
}
/**
* Validación adicional
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
// Validar que no haya productos duplicados
$inventoryIds = collect($this->items)->pluck('inventory_id')->toArray();
if (count($inventoryIds) !== count(array_unique($inventoryIds))) {
$validator->errors()->add(
'items',
'No se pueden agregar productos duplicados al bundle.'
);
}
});
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class BundleUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$bundleId = $this->route('bundle')?->id;
return [
'name' => ['nullable', 'string', 'max:255'],
'sku' => ['nullable', 'string', 'max:50', 'unique:bundles,sku,' . $bundleId],
'barcode' => ['nullable', 'string', 'unique:bundles,barcode,' . $bundleId],
// Componentes del kit (opcional en update)
'items' => ['nullable', 'array', 'min:2'],
'items.*.inventory_id' => ['required_with:items', 'exists:inventories,id'],
'items.*.quantity' => ['required_with:items', 'integer', 'min:1'],
// Precio
'retail_price' => ['nullable', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0'],
'recalculate_price' => ['nullable', 'boolean'],
];
}
/**
* Custom validation messages
*/
public function messages(): array
{
return [
'sku.unique' => 'Este SKU ya está en uso.',
'items.min' => 'Un bundle debe tener al menos 2 productos.',
'items.*.inventory_id.exists' => 'Uno de los productos no existe.',
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.',
];
}
/**
* Validación adicional
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
// Validar que no haya productos duplicados (si se proporcionan items)
if ($this->has('items')) {
$inventoryIds = collect($this->items)->pluck('inventory_id')->toArray();
if (count($inventoryIds) !== count(array_unique($inventoryIds))) {
$validator->errors()->add(
'items',
'No se pueden agregar productos duplicados al bundle.'
);
}
}
});
}
}

View File

@ -0,0 +1,39 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CategoryStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100', Rule::unique('categories', 'name')->withoutTrashed()],
'description' => ['nullable', 'string', 'max:225'],
'is_active' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'name.required' => 'El nombre es obligatorio.',
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'description.string' => 'La descripción debe ser una cadena de texto.',
'description.max' => 'La descripción no debe exceder los 225 caracteres.',
];
}
}

View File

@ -0,0 +1,39 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CategoryUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:100', Rule::unique('categories', 'name')->ignore($this->route('categoria'))->withoutTrashed()],
'description' => ['nullable', 'string', 'max:225'],
'is_active' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'description.string' => 'La descripción debe ser una cadena de texto.',
'description.max' => 'La descripción no debe exceder los 225 caracteres.',
'is_active.boolean' => 'El campo activo debe ser verdadero o falso.',
];
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace App\Http\Requests\App;
use App\Models\Inventory;
use Illuminate\Foundation\Http\FormRequest;
class InventoryEntryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
if ($this->has('products')) {
return [
'warehouse_id' => 'required|exists:warehouses,id',
'supplier_id' => 'nullable|exists:suppliers,id',
'invoice_reference' => 'required|string|max:255',
'notes' => 'nullable|string|max:1000',
// Validación del array de productos
'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id',
'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',
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
];
}
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'required|exists:warehouses,id',
'supplier_id' => 'nullable|exists:suppliers,id',
'quantity' => 'required|numeric|min:0.001',
'unit_cost' => 'required|numeric|min:0',
'invoice_reference' => 'required|string|max:255',
'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
];
}
public function messages(): array
{
return [
// Mensajes para entrada única
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'serial_numbers.*.string' => 'El número de serie debe ser texto',
'serial_numbers.*.distinct' => 'Los números de serie no pueden repetirse',
'serial_numbers.*.unique' => 'El número de serie ya existe en el sistema',
// Mensajes para entrada múltiple
'products.required' => 'Debe incluir al menos un producto',
'products.*.inventory_id.required' => 'El producto es requerido',
'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
'products.*.unit_cost.required' => 'El costo unitario es requerido',
'products.*.unit_cost.numeric' => 'El costo unitario debe ser un número',
'products.*.unit_cost.min' => 'El costo unitario no puede ser negativo',
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
'products.*.serial_numbers.*.distinct' => 'Los números de serie no pueden repetirse',
'products.*.serial_numbers.*.unique' => 'El número de serie ya existe en el sistema',
// Mensajes comunes
'warehouse_id.required' => 'El almacén es requerido',
'warehouse_id.exists' => 'El almacén no existe',
'quantity.required' => 'La cantidad es requerida',
'quantity.min' => 'La cantidad debe ser al menos 1',
'unit_cost.required' => 'El costo unitario es requerido',
'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: Validar equivalencia de unidad si se especifica
$unitOfMeasureId = $product['unit_of_measure_id'] ?? null;
if ($unitOfMeasureId && $unitOfMeasureId != $inventory->unit_of_measure_id) {
$hasEquivalence = $inventory->unitEquivalences()
->where('unit_of_measure_id', $unitOfMeasureId)
->where('is_active', true)
->exists();
if (! $hasEquivalence) {
$field = $this->has('products') ? "products.{$index}.unit_of_measure_id" : 'unit_of_measure_id';
$validator->errors()->add(
$field,
"El producto '{$inventory->name}' no tiene equivalencia configurada para esta unidad de medida."
);
}
if ($inventory->track_serials) {
$field = $this->has('products') ? "products.{$index}.unit_of_measure_id" : 'unit_of_measure_id';
$validator->errors()->add(
$field,
'No se pueden usar equivalencias de unidad para productos con rastreo de seriales.'
);
}
}
// VALIDACIÓN 3: 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,118 @@
<?php
namespace App\Http\Requests\App;
use App\Models\Inventory;
use Illuminate\Foundation\Http\FormRequest;
class InventoryExitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
// Si tiene "products" array, es salida múltiple
if ($this->has('products')) {
return [
'warehouse_id' => 'required|exists:warehouses,id',
'notes' => 'nullable|string|max:1000',
// Validación del array de productos
'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|numeric|min:0.001',
'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
];
}
// Salida única (formato original)
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'required|exists:warehouses,id',
'quantity' => 'required|numeric|min:0.001',
'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
];
}
public function messages(): array
{
return [
// Mensajes para salida única
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'serial_numbers.*.string' => 'El número de serie debe ser texto',
'serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes para salida múltiple
'products.required' => 'Debe incluir al menos un producto',
'products.*.inventory_id.required' => 'El producto es requerido',
'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
'products.*.serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes comunes
'warehouse_id.required' => 'El almacén es requerido',
'warehouse_id.exists' => 'El almacén no existe',
'quantity.required' => 'La cantidad es requerida',
'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

@ -0,0 +1,81 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InventoryImportRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'file' => [
'required',
'file',
'mimes:xlsx,xls,csv',
'max:10240', // 10MB máximo
],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'file.required' => 'Debe seleccionar un archivo para importar.',
'file.file' => 'El archivo no es válido.',
'file.mimes' => 'El archivo debe ser de tipo Excel (.xlsx, .xls) o CSV (.csv).',
'file.max' => 'El archivo no debe superar los 10MB.',
];
}
/**
* Reglas de validación para cada fila del Excel
* Solo valida información del catálogo de productos.
* Para agregar stock, usar movimientos de entrada (POST /movimientos/entrada).
*/
public static function rowRules(): array
{
return [
'nombre' => ['required', 'string', 'max:100'],
'sku' => ['nullable', 'string', 'max:50'],
'codigo_barras' => ['nullable', 'string', 'max:100'],
'categoria' => ['nullable', 'string', 'max:100'],
'precio_venta' => ['required', 'numeric', 'min:0.01'],
'impuesto' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
/**
* Mensajes personalizados de validación para las filas del Excel
*/
public static function rowMessages(): array
{
return [
'nombre.required' => 'El nombre del producto es requerido.',
'nombre.max' => 'El nombre no debe exceder los 100 caracteres.',
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
'codigo_barras.max' => 'El código de barras no debe exceder los 100 caracteres.',
'categoria.max' => 'El nombre de la categoría no debe exceder los 100 caracteres.',
'precio_venta.required' => 'El precio de venta es requerido.',
'precio_venta.numeric' => 'El precio de venta debe ser un número.',
'precio_venta.min' => 'El precio de venta debe ser mayor a 0.',
'impuesto.numeric' => 'El impuesto debe ser un número.',
'impuesto.min' => 'El impuesto no puede ser negativo.',
'impuesto.max' => 'El impuesto no puede exceder el 100%.',
];
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InventoryMovementUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'quantity' => 'sometimes|integer|min:1',
'unit_cost' => 'sometimes|numeric|min:0',
'warehouse_from_id' => 'sometimes|nullable|exists:warehouses,id',
'warehouse_to_id' => 'sometimes|nullable|exists:warehouses,id',
'invoice_reference' => 'sometimes|nullable|string|max:255',
'notes' => 'sometimes|nullable|string|max:500',
'serial_numbers' => ['nullable', 'array'],
'serial_numbers.*' => ['string', 'max:255'],
'supplier_id' => 'sometimes|nullable|exists:suppliers,id',
'unit_of_measure_id' => 'sometimes|nullable|exists:units_of_measurement,id',
];
}
public function messages(): array
{
return [
'quantity.required' => 'La cantidad es requerida',
'quantity.integer' => 'La cantidad debe ser un número entero',
'quantity.min' => 'La cantidad debe ser al menos 1',
'unit_cost.required' => 'El costo unitario es requerido',
'unit_cost.numeric' => 'El costo unitario debe ser un número',
'unit_cost.min' => 'El costo unitario no puede ser negativo',
'warehouse_from_id.required' => 'El almacén origen es requerido',
'warehouse_from_id.exists' => 'El almacén origen no existe',
'warehouse_from_id.different' => 'El almacén origen debe ser diferente al destino',
'warehouse_to_id.required' => 'El almacén destino es requerido',
'warehouse_to_id.exists' => 'El almacén destino no existe',
'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen',
'notes.max' => 'Las notas no pueden exceder 1000 caracteres',
'invoice_reference.max' => 'La referencia de factura no puede exceder 255 caracteres',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
];
}
}

View File

@ -0,0 +1,108 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InventoryStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
// Campos de Inventory
'name' => ['required', 'string', 'max:100'],
'key_sat' => ['nullable', 'string', 'max:20'],
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
'category_id' => ['nullable', 'exists:categories,id'],
'subcategory_id' => ['nullable', 'required_with:category_id', 'exists:subcategories,id'],
'unit_of_measure_id' => ['required', 'exists:units_of_measurement,id'],
'track_serials' => ['nullable', 'boolean'],
// Campos de Price
'cost' => ['nullable', 'numeric', 'min:0'],
'retail_price' => ['required', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
public function messages(): array
{
return [
// Mensajes de Inventory
'name.required' => 'El nombre es obligatorio.',
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'sku.string' => 'El SKU debe ser una cadena de texto.',
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
'sku.unique' => 'El SKU ya está en uso.',
'barcode.string' => 'El código de barras debe ser una cadena de texto.',
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.exists' => 'La clasificación seleccionada no es válida.',
'subcategory_id.required_with' => 'La subclasificación es obligatoria cuando se asigna una clasificación.',
'subcategory_id.exists' => 'La subclasificación seleccionada no es válida.',
// Mensajes de Price
'retail_price.required' => 'El precio de venta es obligatorio.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
/**
* Validación condicional: retail_price > cost solo si cost > 0
* Y validación track_serials con unidades decimales
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
// Validar que la subcategoría pertenezca a la categoría seleccionada
$categoryId = $this->input('category_id');
$subcategoryId = $this->input('subcategory_id');
if ($categoryId && $subcategoryId) {
$subcategory = \App\Models\Subcategory::find($subcategoryId);
if ($subcategory && (int) $subcategory->category_id !== (int) $categoryId) {
$validator->errors()->add(
'subcategory_id',
'La subclasificación no pertenece a la clasificación seleccionada.'
);
}
}
$cost = $this->input('cost');
$retailPrice = $this->input('retail_price');
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
$validator->errors()->add(
'retail_price',
'El precio de venta debe ser mayor que el costo.'
);
}
// Validar incompatibilidad track_serials + unidades decimales
if ($this->input('track_serials')) {
$unit = \App\Models\UnitOfMeasurement::find($this->input('unit_of_measure_id'));
if ($unit && $unit->allows_decimals) {
$validator->errors()->add(
'track_serials',
'No se pueden usar seriales con unidades fraccionarias (kg, L, m). Cambia a Unidad/Pieza/Caja o desactiva los seriales.'
);
}
}
});
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Http\Requests\App;
use App\Models\Inventory;
use Illuminate\Foundation\Http\FormRequest;
class InventoryTransferRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
// Si tiene "products" array, es traspaso múltiple
if ($this->has('products')) {
return [
'warehouse_from_id' => 'required|exists:warehouses,id',
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id',
'notes' => 'nullable|string|max:1000',
// Validación del array de productos
'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|numeric|min:0.001',
'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
];
}
// Traspaso único (formato original)
return [
'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|numeric|min:0.001',
'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
];
}
public function messages(): array
{
return [
// Mensajes para traspaso único
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'serial_numbers.*.string' => 'El número de serie debe ser texto',
'serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes para traspaso múltiple
'products.required' => 'Debe incluir al menos un producto',
'products.*.inventory_id.required' => 'El producto es requerido',
'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
'products.*.serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes comunes
'warehouse_from_id.required' => 'El almacén origen es requerido',
'warehouse_from_id.exists' => 'El almacén origen no existe',
'warehouse_to_id.required' => 'El almacén destino es requerido',
'warehouse_to_id.exists' => 'El almacén destino no existe',
'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen',
'quantity.required' => 'La cantidad es requerida',
'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,132 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InventoryUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
$inventoryId = $this->route('inventario')?->id;
return [
// Campos de Inventory
'name' => ['nullable', 'string', 'max:100'],
'key_sat' => ['nullable', 'string', 'max:20'],
'sku' => ['nullable', 'string', 'max:50'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
'category_id' => ['nullable', 'exists:categories,id'],
'subcategory_id' => ['nullable', 'required_with:category_id', 'exists:subcategories,id'],
'unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
'track_serials' => ['nullable', 'boolean'],
// Campos de Price
'cost' => ['nullable', 'numeric', 'min:0'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
public function messages(): array
{
return [
// Mensajes de Inventory
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'sku.string' => 'El SKU debe ser una cadena de texto.',
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
'sku.unique' => 'El SKU ya está en uso.',
'barcode.string' => 'El código de barras debe ser una cadena de texto.',
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.exists' => 'La clasificación seleccionada no es válida.',
'subcategory_id.required_with' => 'La subclasificación es obligatoria cuando se asigna una clasificación.',
'subcategory_id.exists' => 'La subclasificación seleccionada no es válida.',
// Mensajes de Price
'cost.numeric' => 'El costo debe ser un número.',
'cost.min' => 'El costo no puede ser negativo.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
/**
* Validación condicional: retail_price > cost solo si cost > 0
* Y validación track_serials con unidades decimales
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
/** @var \App\Models\Inventory $inventory */
$inventory = $this->route('inventario');
// Validar que la subcategoría pertenezca a la categoría seleccionada
$categoryId = $this->input('category_id', $inventory?->category_id);
$subcategoryId = $this->input('subcategory_id');
if ($subcategoryId && $categoryId) {
$subcategory = \App\Models\Subcategory::find($subcategoryId);
if ($subcategory && (int) $subcategory->category_id !== (int) $categoryId) {
$validator->errors()->add(
'subcategory_id',
'La subclasificación no pertenece a la clasificación seleccionada.'
);
}
}
// Bloquear cambio de unidad de medida si el producto ya tiene movimientos de inventario
if ($this->has('unit_of_measure_id') && $inventory) {
$newUnitId = $this->input('unit_of_measure_id');
$currentUnitId = $inventory->unit_of_measure_id;
if ((int) $newUnitId !== (int) $currentUnitId) {
$hasMovements = \App\Models\InventoryMovement::where('inventory_id', $inventory->id)->exists();
if ($hasMovements) {
$validator->errors()->add(
'unit_of_measure_id',
'No se puede cambiar la unidad de medida porque el producto ya tiene existencias registradas.'
);
}
}
}
$cost = $this->input('cost');
$retailPrice = $this->input('retail_price');
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
$validator->errors()->add(
'retail_price',
'El precio de venta debe ser mayor que el costo.'
);
}
// Validar incompatibilidad track_serials + unidades decimales
$trackSerials = $this->input('track_serials', $inventory?->track_serials);
$unitId = $this->input('unit_of_measure_id', $inventory?->unit_of_measure_id);
if ($trackSerials && $unitId) {
$unit = \App\Models\UnitOfMeasurement::find($unitId);
if ($unit && $unit->allows_decimals) {
$validator->errors()->add(
'track_serials',
'No se pueden usar seriales con unidades fraccionarias (kg, L, m). Cambia a Unidad/Pieza/Caja o desactiva los seriales.'
);
}
}
});
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InvoiceRequestProcessRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Autorización manejada por middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'notes' => ['nullable', 'string', 'max:500'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'notes.max' => 'Las notas no pueden exceder 500 caracteres',
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InvoiceRequestRejectRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Autorización manejada por middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'notes' => ['required', 'string', 'min:10', 'max:500'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'notes.required' => 'Debe proporcionar una razón para rechazar la solicitud',
'notes.min' => 'La razón debe tener al menos 10 caracteres',
'notes.max' => 'La razón no puede exceder 500 caracteres',
];
}
}

View File

@ -0,0 +1,53 @@
<?php namespace App\Http\Requests\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Foundation\Http\FormRequest;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class InvoiceRequestUploadRequest extends FormRequest
{
/**
* Determinar si el usuario está autorizado para realizar esta solicitud
*/
public function authorize(): bool
{
return true; // Autorización manejada por middleware
}
/**
* Obtener las reglas de validación que se aplican a la solicitud
*/
public function rules(): array
{
return [
'invoice_xml' => ['nullable', 'file', 'mimes:xml', 'max:2048'], // Max 2MB
'invoice_pdf' => ['required', 'file', 'mimes:pdf', 'max:5120'], // Max 5MB
'cfdi_uuid' => ['required', 'string', 'size:36', 'regex:/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'],
];
}
/**
* Mensajes personalizados de validación
*/
public function messages(): array
{
return [
'invoice_xml.mimes' => 'El archivo debe ser un XML válido',
'invoice_xml.max' => 'El archivo XML no puede ser mayor a 2MB',
'invoice_pdf.required' => 'El archivo PDF de la factura es obligatorio',
'invoice_pdf.mimes' => 'El archivo debe ser un PDF válido',
'invoice_pdf.max' => 'El archivo PDF no puede ser mayor a 5MB',
'cfdi_uuid.required' => 'El UUID del CFDI es obligatorio',
'cfdi_uuid.size' => 'El UUID del CFDI debe tener 36 caracteres',
'cfdi_uuid.regex' => 'El formato del UUID del CFDI no es válido',
];
}
}

View File

@ -0,0 +1,50 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InvoiceStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'address' => ['nullable', 'string', 'max:500'],
'rfc' => ['required', 'string', 'min:12', 'max:13', 'regex:/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i'],
'razon_social' => ['required', 'string', 'max:255'],
'regimen_fiscal' => ['required', 'string', 'max:100'],
'cp_fiscal' => ['required', 'string', 'size:5', 'regex:/^\d{5}$/'],
'uso_cfdi' => ['required', 'string', 'max:100'],
];
}
public function messages(): array
{
return [
'rfc.regex' => 'El RFC no tiene un formato válido',
'rfc.min' => 'El RFC debe tener al menos 12 caracteres',
'rfc.max' => 'El RFC debe tener máximo 13 caracteres',
'cp_fiscal.regex' => 'El código postal debe ser de 5 dígitos',
'cp_fiscal.size' => 'El código postal debe ser de 5 dígitos',
'name.required' => 'El nombre es obligatorio',
'email.required' => 'El correo electrónico es obligatorio',
'email.email' => 'El correo electrónico debe ser válido',
'razon_social.required' => 'La razón social es obligatoria',
'regimen_fiscal.required' => 'El régimen fiscal es obligatorio',
'uso_cfdi.required' => 'El uso de CFDI es obligatorio',
];
}
}

View File

@ -0,0 +1,59 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class PriceUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
'cost' => ['nullable', 'numeric', 'min:0'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
public function messages(): array
{
return [
'cost.numeric' => 'El costo debe ser un número.',
'cost.min' => 'El costo no puede ser negativo.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
/**
* Validación condicional: retail_price > cost solo si cost > 0
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$cost = $this->input('cost');
$retailPrice = $this->input('retail_price');
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
$validator->errors()->add(
'retail_price',
'El precio de venta debe ser mayor que el costo.'
);
}
});
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace App\Http\Requests\App;
use App\Models\InventorySerial;
use App\Models\ReturnDetail;
use App\Models\Sale;
use App\Models\SaleDetail;
use Illuminate\Foundation\Http\FormRequest;
class ReturnStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'sale_id' => ['required', 'exists:sales,id'],
'user_id' => ['required', 'exists:users,id'],
'cash_register_id' => ['nullable', 'exists:cash_registers,id'],
'reason' => ['required', 'in:defective,wrong_product,change_of_mind,damaged,other'],
'refund_method' => ['nullable', 'in:cash,credit_card,debit_card'],
'notes' => ['nullable', 'string', 'max:500'],
// Items a devolver
'items' => ['required', 'array', 'min:1'],
'items.*.sale_detail_id' => ['required', 'exists:sale_details,id'],
'items.*.quantity_returned' => ['required', 'integer', 'min:1'],
'items.*.serial_numbers' => ['nullable', 'array'],
'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'],
'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
];
}
public function messages(): array
{
return [
'sale_id.required' => 'La venta es obligatoria.',
'sale_id.exists' => 'La venta seleccionada no existe.',
'reason.required' => 'El motivo de devolución es obligatorio.',
'reason.in' => 'El motivo debe ser: defectuoso, producto incorrecto, cambio de opinión, dañado u otro.',
'refund_method.in' => 'El método de reembolso debe ser: efectivo, tarjeta de crédito o débito.',
'items.required' => 'Debe incluir al menos un producto a devolver.',
'items.min' => 'Debe incluir al menos un producto.',
'items.*.sale_detail_id.required' => 'El detalle de venta es obligatorio.',
'items.*.sale_detail_id.exists' => 'El detalle de venta no existe.',
'items.*.quantity_returned.required' => 'La cantidad devuelta es obligatoria.',
'items.*.quantity_returned.integer' => 'La cantidad debe ser un número entero.',
'items.*.quantity_returned.min' => 'La cantidad debe ser al menos 1.',
];
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
// 1. Validar que la venta exista y esté completada
$sale = Sale::find($this->sale_id);
if (! $sale) {
return;
}
if ($sale->status !== 'completed') {
$validator->errors()->add(
'sale_id',
'Solo se pueden devolver productos de ventas completadas.'
);
return;
}
// 2. Validar que refund_method coincida con payment_method (si se especifica)
if ($this->refund_method && $this->refund_method !== $sale->payment_method) {
$validator->errors()->add(
'refund_method',
"El método de reembolso debe coincidir con el método de pago original ({$sale->payment_method})."
);
}
// 3. Validar cada item
foreach ($this->items ?? [] as $index => $item) {
$saleDetail = SaleDetail::find($item['sale_detail_id']);
if (! $saleDetail) {
continue;
}
// Validar que el sale_detail pertenece a la venta
if ($saleDetail->sale_id !== $sale->id) {
$validator->errors()->add(
"items.{$index}.sale_detail_id",
'El producto no pertenece a esta venta.'
);
continue;
}
// Validar cantidades
$alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id)
->sum('quantity_returned');
$maxReturnable = $saleDetail->quantity - $alreadyReturned;
if ($item['quantity_returned'] > $maxReturnable) {
$validator->errors()->add(
"items.{$index}.quantity_returned",
"Solo puedes devolver {$maxReturnable} unidades de {$saleDetail->product_name}. ".
"Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas."
);
}
// Validar seriales solo si se proporcionaron (array vacío = sin serials)
if (! empty($item['serial_numbers'])) {
if (count($item['serial_numbers']) !== $item['quantity_returned']) {
$validator->errors()->add(
"items.{$index}.serial_numbers",
"Debes especificar exactamente {$item['quantity_returned']} números de serie."
);
}
foreach ($item['serial_numbers'] as $serialIndex => $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('sale_detail_id', $saleDetail->id)
->where('status', 'vendido')
->first();
if (! $serial) {
$validator->errors()->add(
"items.{$index}.serial_numbers.{$serialIndex}",
"El serial {$serialNumber} no pertenece a esta venta o ya fue devuelto."
);
}
}
}
}
// 4. Validar caja registradora si se especificó
if ($this->cash_register_id) {
$cashRegister = \App\Models\CashRegister::find($this->cash_register_id);
if ($cashRegister && ! $cashRegister->isOpen()) {
$validator->errors()->add(
'cash_register_id',
'La caja registradora debe estar abierta para procesar devoluciones.'
);
}
}
});
}
}

View File

@ -0,0 +1,124 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class SaleStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
// Datos de la venta
'client_id' => ['nullable', 'exists:clients,id'],
'client_number' => ['nullable', 'exists:clients,client_number'],
'user_id' => ['required', 'exists:users,id'],
'subtotal' => ['required', 'numeric', 'min:0'],
'tax' => ['required', 'numeric', 'min:0'],
'total' => ['required', 'numeric', 'min:0'],
'payment_method' => ['required', 'in:cash,credit_card,debit_card'],
// Campos para pagos en efectivo
'cash_received' => ['required_if:payment_method,cash', 'nullable', 'numeric', 'min:0'],
// Items del carrito
'items' => ['required', 'array', 'min:1'],
// Items pueden ser productos O bundles
'items.*.type' => ['nullable', 'in:product,bundle'],
'items.*.bundle_id' => ['required_if:items.*.type,bundle', 'exists:bundles,id'],
// Para productos normales
'items.*.inventory_id' => ['required_if:items.*.type,product', 'exists:inventories,id'],
'items.*.product_name' => ['required_if:items.*.type,product', 'string', 'max:255'],
'items.*.unit_price' => ['required_if:items.*.type,product', 'numeric', 'min:0'],
'items.*.subtotal' => ['required_if:items.*.type,product', 'numeric', 'min:0'],
// Comunes a ambos
'items.*.quantity' => ['required', 'numeric', 'min:0.001'],
'items.*.warehouse_id' => ['nullable', 'exists:warehouses,id'],
// Seriales
// Productos normales: ["SN-001", "SN-002"]
// Bundles: { inventory_id: ["SN-001", "SN-002"], ... }
'items.*.serial_numbers' => ['nullable', 'array'],
// Equivalencia de unidad de medida
'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
// Mensajes de Sale
'user_id.required' => 'El usuario es obligatorio.',
'user_id.exists' => 'El usuario seleccionado no existe.',
'subtotal.required' => 'El subtotal es obligatorio.',
'subtotal.numeric' => 'El subtotal debe ser un número.',
'subtotal.min' => 'El subtotal no puede ser negativo.',
'tax.required' => 'El impuesto es obligatorio.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'total.required' => 'El total es obligatorio.',
'total.numeric' => 'El total debe ser un número.',
'total.min' => 'El total no puede ser negativo.',
'payment_method.required' => 'El método de pago es obligatorio.',
'payment_method.in' => 'El método de pago debe ser: efectivo, tarjeta de crédito o débito.',
// Mensajes de cash_received
'cash_received.required_if' => 'El dinero recibido es obligatorio para pagos en efectivo.',
'cash_received.numeric' => 'El dinero recibido debe ser un número.',
'cash_received.min' => 'El dinero recibido no puede ser negativo.',
// Mensajes de Items
'items.required' => 'Debe incluir al menos un producto.',
'items.array' => 'Los items deben ser un arreglo.',
'items.min' => 'Debe incluir al menos un producto.',
'items.*.inventory_id.required' => 'El ID del producto es obligatorio.',
'items.*.inventory_id.exists' => 'El producto seleccionado no existe.',
'items.*.product_name.required' => 'El nombre del producto es obligatorio.',
'items.*.quantity.required' => 'La cantidad es obligatoria.',
'items.*.quantity.numeric' => 'La cantidad debe ser un número.',
'items.*.quantity.min' => 'La cantidad debe ser mayor a 0.',
'items.*.unit_price.required' => 'El precio unitario es obligatorio.',
'items.*.unit_price.numeric' => 'El precio unitario debe ser un número.',
'items.*.unit_price.min' => 'El precio unitario no puede ser negativo.',
'items.*.subtotal.required' => 'El subtotal del item es obligatorio.',
'items.*.subtotal.numeric' => 'El subtotal del item debe ser un número.',
'items.*.subtotal.min' => 'El subtotal del item no puede ser negativo.',
];
}
/**
* Validación adicional después de las reglas básicas
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
// Validar que el dinero recibido sea suficiente para pagos en efectivo
if ($this->payment_method === 'cash' && $this->cash_received !== null) {
if ($this->cash_received < $this->total) {
$validator->errors()->add(
'cash_received',
'El dinero recibido debe ser mayor o igual al total de la venta ($'.number_format($this->total, 2).').'
);
}
}
});
}
}

View File

@ -0,0 +1,56 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SubcategoryStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$uniqueRule = Rule::unique('subcategories', 'name')
->where('category_id', $this->route('category')->id)
->withoutTrashed();
if($this->isBulk()) {
return [
'*.name' => ['required', 'string', 'max:100', $uniqueRule],
'*.description' => ['nullable', 'string', 'max:255'],
'*.is_active' => ['nullable', 'boolean'],
];
}
return [
'name' => ['required', 'string', 'max:100', $uniqueRule],
'description' => ['nullable', 'string', 'max:255'],
'is_active' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'*.name.required' => 'El nombre es obligatorio.',
'*.name.string' => 'El nombre debe ser una cadena de texto.',
'*.name.max' => 'El nombre no debe exceder los 100 caracteres.',
'*.description.string' => 'La descripción debe ser una cadena de texto.',
'*.description.max' => 'La descripción no debe exceder los 255 caracteres.',
'name.required' => 'El nombre es obligatorio.',
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'description.string' => 'La descripción debe ser una cadena de texto.',
'description.max' => 'La descripción no debe exceder los 255 caracteres.',
];
}
/**
* Detecta si el payload es un arreglo de objetos
*/
public function isBulk(): bool
{
return is_array($this->all()) && array_is_list($this->all());
}
}

View File

@ -0,0 +1,32 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SubcategoryUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:100', Rule::unique('subcategories', 'name')->where('category_id', $this->route('category')->id)->ignore($this->route('subcategory'))->withoutTrashed()],
'description' => ['nullable', 'string', 'max:255'],
'is_active' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'description.string' => 'La descripción debe ser una cadena de texto.',
'description.max' => 'La descripción no debe exceder los 255 caracteres.',
'is_active.boolean' => 'El campo activo debe ser verdadero o falso.',
];
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UnitEquivalenceStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'unit_of_measure_id' => [
'required',
'exists:units_of_measurement,id',
Rule::unique('unit_equivalences')->where(function ($query) {
return $query->where('inventory_id', $this->route('inventory')->id);
}),
],
'conversion_factor' => ['required', 'numeric', 'min:0.001'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'is_active' => ['boolean'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$inventory = $this->route('inventory');
if ($inventory->track_serials) {
$validator->errors()->add(
'unit_of_measure_id',
'No se pueden crear equivalencias para productos con rastreo de seriales.'
);
}
if ($this->unit_of_measure_id == $inventory->unit_of_measure_id) {
$validator->errors()->add(
'unit_of_measure_id',
'No se puede crear una equivalencia con la unidad base del producto.'
);
}
});
}
public function messages(): array
{
return [
'unit_of_measure_id.required' => 'La unidad de medida es obligatoria.',
'unit_of_measure_id.exists' => 'La unidad de medida seleccionada no existe.',
'unit_of_measure_id.unique' => 'Ya existe una equivalencia para esta unidad en este producto.',
'conversion_factor.required' => 'El factor de conversión es obligatorio.',
'conversion_factor.numeric' => 'El factor de conversión debe ser un número.',
'conversion_factor.min' => 'El factor de conversión debe ser mayor a 0.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class UnitEquivalenceUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'conversion_factor' => ['sometimes', 'numeric', 'min:0.001'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
];
}
public function messages(): array
{
return [
'conversion_factor.numeric' => 'El factor de conversión debe ser un número.',
'conversion_factor.min' => 'El factor de conversión debe ser mayor a 0.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
];
}
}

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,74 @@
<?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('unit')?->id ?? $this->route('unit');
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) {
$unit = $this->route('unit');
if ($unit && !($unit instanceof UnitOfMeasurement)) {
$unit = UnitOfMeasurement::find($unit);
}
if (!$unit) {
return;
}
// Si se intenta cambiar allows_decimals
if ($this->has('allows_decimals') && (bool) $this->allows_decimals !== (bool) $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

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class WarehouseStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => 'required|string|max:50|unique:warehouses,code',
'name' => 'required|string|max:255',
'is_active' => 'boolean',
'is_main' => 'boolean',
];
}
public function messages(): array
{
return [
'code.required' => 'El código es requerido',
'code.unique' => 'El código ya existe',
'name.required' => 'El nombre es requerido',
];
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class WarehouseUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => [
'sometimes',
'string',
'max:50',
Rule::unique('warehouses', 'code')->ignore($this->route('id')),
],
'name' => 'sometimes|string|max:255',
'is_active' => 'boolean',
'is_main' => 'boolean',
];
}
public function messages(): array
{
return [
'code.unique' => 'El código ya existe',
];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\ClientTiers;
use Illuminate\Foundation\Http\FormRequest;
class ClientTierStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'tier_name' => ['required', 'string', 'max:100', 'unique:client_tiers,tier_name'],
'min_purchase_amount' => ['required', 'numeric', 'min:0'],
'max_purchase_amount' => ['nullable', 'numeric', 'gt:min_purchase_amount'],
'discount_percentage' => ['required', 'numeric', 'min:0', 'max:100'],
'is_active' => ['boolean'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'tier_name.required' => 'El nombre del tier es requerido',
'tier_name.unique' => 'Ya existe un tier con este nombre',
'min_purchase_amount.required' => 'El monto mínimo es requerido',
'min_purchase_amount.min' => 'El monto mínimo debe ser mayor o igual a 0',
'max_purchase_amount.gt' => 'El monto máximo debe ser mayor al monto mínimo',
'discount_percentage.required' => 'El porcentaje de descuento es requerido',
'discount_percentage.min' => 'El descuento debe ser mayor o igual a 0',
'discount_percentage.max' => 'El descuento no puede ser mayor a 100%',
];
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\ClientTiers;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ClientTierUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$tierId = $this->route('tier')->id ?? $this->route('id');
return [
'tier_name' => [
'sometimes',
'required',
'string',
'max:100',
Rule::unique('client_tiers', 'tier_name')->ignore($tierId),
],
'min_purchase_amount' => ['sometimes', 'required', 'numeric', 'min:0'],
'max_purchase_amount' => ['nullable', 'numeric', 'gt:min_purchase_amount'],
'discount_percentage' => ['sometimes', 'required', 'numeric', 'min:0', 'max:100'],
'is_active' => ['boolean'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'tier_name.required' => 'El nombre del tier es requerido',
'tier_name.unique' => 'Ya existe un tier con este nombre',
'min_purchase_amount.required' => 'El monto mínimo es requerido',
'min_purchase_amount.min' => 'El monto mínimo debe ser mayor o igual a 0',
'max_purchase_amount.gt' => 'El monto máximo debe ser mayor al monto mínimo',
'discount_percentage.required' => 'El porcentaje de descuento es requerido',
'discount_percentage.min' => 'El descuento debe ser mayor o igual a 0',
'discount_percentage.max' => 'El descuento no puede ser mayor a 100%',
];
}
}

View File

@ -0,0 +1,275 @@
<?php
namespace App\Imports;
use App\Models\Inventory;
use App\Models\Price;
use App\Models\Category;
use App\Http\Requests\App\InventoryImportRequest;
use App\Models\UnitOfMeasurement;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\Importable;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\WithMapping;
/**
* Import de productos desde Excel
*
* Solo crea/actualiza el CATÁLOGO de productos.
* Para agregar stock, usar movimientos de entrada (POST /movimientos/entrada).
*
* Formato esperado del Excel:
* - nombre: Nombre del producto (requerido)
* - sku: Código SKU (opcional, único)
* - codigo_barras: Código de barras (opcional, único)
* - categoria: Nombre de la categoría (opcional)
* - precio_venta: Precio de venta (requerido, mayor a 0)
* - impuesto: Porcentaje de impuesto (opcional, 0-100)
*/
class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, SkipsEmptyRows, WithMapping
{
use Importable;
private $errors = [];
private $imported = 0;
private $updated = 0;
private $skipped = 0;
/**
* Mapea y transforma los datos de cada fila antes de la validación
*/
public function map($row): array
{
return [
'nombre' => $row['nombre'] ?? null,
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
'codigo_barras' => isset($row['codigo_barras']) ? (string) $row['codigo_barras'] : null,
'categoria' => $row['categoria'] ?? null,
'unidad_medida' => $row['unidad_medida'] ?? null,
'precio_venta' => $row['precio_venta'] ?? null,
'impuesto' => $row['impuesto'] ?? null,
];
}
/**
* Procesa cada fila del Excel
*/
public function model(array $row)
{
// Ignorar filas completamente vacías
if (empty($row['nombre']) && empty($row['sku']) && empty($row['precio_venta'])) {
return null;
}
try {
$existingInventory = null;
if(!empty($row['sku'])) {
$existingInventory = Inventory::where('sku', trim($row['sku']))->first();
}
if(!$existingInventory && !empty($row['codigo_barras'])) {
$existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first();
}
// Si el producto ya existe, actualizar información
if ($existingInventory) {
return $this->updateExistingProduct($existingInventory, $row);
}
// Producto nuevo
return $this->createNewProduct($row);
} catch (\Exception $e) {
$this->skipped++;
$this->errors[] = "Error en fila: " . $e->getMessage();
return null;
}
}
private function createNewProduct(array $row)
{
try {
// Validar nombre del producto
if (!isset($row['nombre']) || empty(trim($row['nombre']))) {
$this->skipped++;
$this->errors[] = "Fila sin nombre de producto";
return null;
}
// Validar precio de venta
$precioVenta = (float) $row['precio_venta'];
if ($precioVenta <= 0) {
$this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta debe ser mayor a 0";
return null;
}
// Buscar o crear categoría si se proporciona
$categoryId = null;
if (!empty($row['categoria'])) {
$category = Category::firstOrCreate(
['name' => trim($row['categoria'])],
['is_active' => true]
);
$categoryId = $category->id;
}
// Buscar unidad de medida (requerida)
$unitId = null;
if (!empty($row['unidad_medida'])) {
$unit = UnitOfMeasurement::where('name', trim($row['unidad_medida']))
->orWhere('abbreviation', trim($row['unidad_medida']))
->first();
if ($unit) {
$unitId = $unit->id;
} else {
$this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': Unidad de medida '{$row['unidad_medida']}' no encontrada";
return null;
}
} else {
// Si no se proporciona, usar 'Pieza' por defecto
$unit = UnitOfMeasurement::where('name', 'Pieza')->first();
if ($unit) {
$unitId = $unit->id;
} else {
$this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': No se proporcionó unidad de medida y no existe 'Pieza' por defecto";
return null;
}
}
// Crear el producto en inventario
$inventory = new Inventory();
$inventory->name = trim($row['nombre']);
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$inventory->category_id = $categoryId;
$inventory->unit_of_measure_id = $unitId;
$inventory->is_active = true;
$inventory->track_serials = false;
$inventory->save();
// Crear el precio del producto
Price::create([
'inventory_id' => $inventory->id,
'cost' => 0,
'retail_price' => $precioVenta,
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
]);
$this->imported++;
return $inventory;
} catch (\Exception $e) {
$this->skipped++;
$this->errors[] = "Error creando producto '{$row['nombre']}': " . $e->getMessage();
return null;
}
}
/**
* Actualiza un producto existente con nueva información
*/
private function updateExistingProduct(Inventory $inventory, array $row)
{
try {
// Actualizar información básica del producto
if (!empty($row['nombre'])) {
$inventory->name = trim($row['nombre']);
}
if (!empty($row['codigo_barras'])) {
$inventory->barcode = trim($row['codigo_barras']);
}
// Actualizar categoría si se proporciona
if (!empty($row['categoria'])) {
$category = Category::firstOrCreate(
['name' => trim($row['categoria'])],
['is_active' => true]
);
$inventory->category_id = $category->id;
}
// Actualizar unidad de medida si se proporciona
if (!empty($row['unidad_medida'])) {
$unit = UnitOfMeasurement::where('name', trim($row['unidad_medida']))
->orWhere('abbreviation', trim($row['unidad_medida']))
->first();
if ($unit) {
$inventory->unit_of_measure_id = $unit->id;
}
}
$inventory->save();
// Actualizar precio de venta e impuesto (NO el costo)
if ($inventory->price) {
$updateData = [];
if (!empty($row['precio_venta'])) {
$updateData['retail_price'] = (float) $row['precio_venta'];
}
if (isset($row['impuesto'])) {
$updateData['tax'] = (float) $row['impuesto'];
}
if (!empty($updateData)) {
$inventory->price->update($updateData);
}
}
$this->updated++;
return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo
} catch (\Exception $e) {
$this->skipped++;
$this->errors[] = "Error actualizando producto '{$inventory->name}': " . $e->getMessage();
return null;
}
}
/**
* Reglas de validación para cada fila
*/
public function rules(): array
{
return InventoryImportRequest::rowRules();
}
/**
* Mensajes personalizados de validación
*/
public function customValidationMessages()
{
return InventoryImportRequest::rowMessages();
}
/**
* Chunk size for reading
*/
public function chunkSize(): int
{
return 100;
}
/**
* Obtener estadísticas de la importación
*/
public function getStats(): array
{
return [
'imported' => $this->imported,
'updated' => $this->updated,
'skipped' => $this->skipped,
'errors' => $this->errors,
];
}
}

37
app/Models/Bill.php Normal file
View File

@ -0,0 +1,37 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
class Bill extends Model
{
protected $fillable = [
'name',
'supplier_id',
'cost',
'file_path',
'deadline',
'paid',
];
protected $casts = [
'cost' => 'decimal:2',
'paid' => 'boolean',
];
protected $appends = ['file_url'];
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function getFileUrlAttribute(): ?string
{
if (!$this->file_path) return null;
return asset('storage/' . $this->file_path);
}
}

107
app/Models/Bundle.php Normal file
View File

@ -0,0 +1,107 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Bundle extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'sku',
'barcode',
];
protected $appends = ['available_stock', 'total_cost'];
/**
* Componentes del kit (items individuales)
*/
public function items()
{
return $this->hasMany(BundleItem::class);
}
/**
* Productos que componen el kit (relación many-to-many)
*/
public function inventories()
{
return $this->belongsToMany(Inventory::class, 'bundle_items')
->withPivot('quantity')
->withTimestamps();
}
/**
* Precio del kit
*/
public function price()
{
return $this->hasOne(BundlePrice::class);
}
/**
* Stock disponible del kit en el almacén principal (único para venta)
* = mínimo(stock_almacén_principal_componente / cantidad_requerida)
*/
public function getAvailableStockAttribute(): int
{
$mainWarehouseId = Warehouse::where('is_main', true)->value('id');
if (! $mainWarehouseId) {
return 0;
}
return $this->stockInWarehouse($mainWarehouseId);
}
/**
* Stock disponible en un almacén específico
*/
public function stockInWarehouse(int $warehouseId): int
{
if ($this->items->isEmpty()) {
return 0;
}
$minStock = PHP_INT_MAX;
foreach ($this->items as $item) {
$inventory = $item->inventory;
$warehouseStock = $inventory->stockInWarehouse($warehouseId);
$possibleKits = $warehouseStock > 0 ? floor($warehouseStock / $item->quantity) : 0;
$minStock = min($minStock, $possibleKits);
}
return $minStock === PHP_INT_MAX ? 0 : (int) $minStock;
}
/**
* Costo total del kit (suma de costos de componentes)
*/
public function getTotalCostAttribute(): float
{
$total = 0;
foreach ($this->items as $item) {
$componentCost = $item->inventory->price->cost ?? 0;
$total += $componentCost * $item->quantity;
}
return round($total, 2);
}
/**
* Validar si el kit tiene stock disponible
*/
public function hasStock(int $quantity = 1, ?int $warehouseId = null): bool
{
if ($warehouseId) {
return $this->stockInWarehouse($warehouseId) >= $quantity;
}
return $this->available_stock >= $quantity;
}
}

32
app/Models/BundleItem.php Normal file
View File

@ -0,0 +1,32 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BundleItem extends Model
{
protected $fillable = [
'bundle_id',
'inventory_id',
'quantity',
];
protected $casts = [
'quantity' => 'integer',
];
/**
* Bundle al que pertenece este item
*/
public function bundle()
{
return $this->belongsTo(Bundle::class);
}
/**
* Producto componente
*/
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
}

View File

@ -0,0 +1,27 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BundlePrice extends Model
{
protected $fillable = [
'bundle_id',
'cost',
'retail_price',
'tax',
];
protected $casts = [
'cost' => 'decimal:2',
'retail_price' => 'decimal:2',
'tax' => 'decimal:2',
];
/**
* Bundle al que pertenece este precio
*/
public function bundle()
{
return $this->belongsTo(Bundle::class);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CashRegister extends Model
{
protected $fillable = [
'user_id',
'opened_at',
'closed_at',
'initial_cash',
'final_cash',
'expected_cash',
'difference',
'total_sales',
'sales_count',
'total_returns',
'cash_returns',
'card_returns',
'returns_count',
'total_discounts',
'notes',
'status',
];
protected $casts = [
'opened_at' => 'datetime',
'closed_at' => 'datetime',
'initial_cash' => 'decimal:2',
'final_cash' => 'decimal:2',
'expected_cash' => 'decimal:2',
'difference' => 'decimal:2',
'total_sales' => 'decimal:2',
'total_returns' => 'decimal:2',
'cash_returns' => 'decimal:2',
'card_returns' => 'decimal:2',
'total_discounts' => 'decimal:2',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function sales()
{
return $this->hasMany(Sale::class);
}
public function returns()
{
return $this->hasMany(Returns::class, 'cash_register_id');
}
/**
* Verificar si la caja está abierta
*/
public function isOpen(): bool
{
return $this->status === 'open';
}
/**
* Calcular totales por método de pago
*/
public function getTotalsByPaymentMethod()
{
return $this->sales()
->where('status', 'completed')
->selectRaw('payment_method, SUM(total) as total, COUNT(*) as count')
->groupBy('payment_method')
->get();
}
}

40
app/Models/Category.php Normal file
View File

@ -0,0 +1,40 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class Category extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function subcategories()
{
return $this->hasMany(Subcategory::class);
}
public function inventories()
{
return $this->hasMany(Inventory::class);
}
}

78
app/Models/Client.php Normal file
View File

@ -0,0 +1,78 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Client extends Model
{
protected $fillable = [
'name',
'client_number',
'email',
'phone',
'address',
'rfc',
'razon_social',
'regimen_fiscal',
'cp_fiscal',
'uso_cfdi',
'tier_id',
'total_purchases',
'total_transactions',
'lifetime_returns',
'last_purchase_at',
];
protected $casts = [
'total_purchases' => 'decimal:2',
'total_transactions' => 'integer',
'lifetime_returns' => 'decimal:2',
'last_purchase_at' => 'datetime',
];
public function sales(): HasMany
{
return $this->hasMany(Sale::class);
}
/**
* Nivel o rango del cliente
*/
public function tier(): BelongsTo
{
return $this->belongsTo(ClientTier::class, 'tier_id');
}
/**
* Historial de cambios de nivel del cliente
*/
public function tierHistory(): HasMany
{
return $this->hasMany(ClientTierHistory::class)->orderBy('changed_at', 'desc');
}
/**
* Descuento aplicable según el nivel del cliente
*/
public function getDiscountPercentageAttribute(): float
{
return $this->tier?->discount_percentage ?? 0;
}
/**
* Compras netas del cliente (total compras - devoluciones)
*/
public function getNetPurchasesAttribute(): float
{
return $this->total_purchases - $this->lifetime_returns;
}
/**
* Solicitudes de factura del cliente
*/
public function invoiceRequests(): HasMany
{
return $this->hasMany(InvoiceRequest::class);
}
}

76
app/Models/ClientTier.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClientTier extends Model
{
protected $fillable = [
'tier_name',
'min_purchase_amount',
'max_purchase_amount',
'discount_percentage',
'is_active',
];
protected $casts = [
'min_purchase_amount' => 'decimal:2',
'max_purchase_amount' => 'decimal:2',
'discount_percentage' => 'decimal:2',
'is_active' => 'boolean',
];
/**
* Clientes que pertenecen a este nivel
*/
public function clients(): HasMany
{
return $this->hasMany(Client::class, 'tier_id');
}
/**
* Niveles nuevos en el historial
*/
public function tierHistories(): HasMany
{
return $this->hasMany(ClientTierHistory::class, 'new_tier_id');
}
/**
* Niveles antiguos en el historial
*/
public function oldTierHistories(): HasMany
{
return $this->hasMany(ClientTierHistory::class, 'old_tier_id');
}
/**
* Alcance de consulta para niveles activos
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Verifica si un monto de compra califica para este nivel
*/
public function qualifies(float $amount): bool
{
if (!$this->is_active) {
return false;
}
if ($amount < $this->min_purchase_amount) {
return false;
}
if ($this->max_purchase_amount !== null && $amount > $this->max_purchase_amount) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientTierHistory extends Model
{
protected $table = 'client_tier_history';
public $timestamps = false;
protected $fillable = [
'client_id',
'old_tier_id',
'new_tier_id',
'total_at_change',
'reason',
'changed_at',
];
protected $casts = [
'total_at_change' => 'decimal:2',
'changed_at' => 'datetime',
];
/**
* Cliente con historial de niveles
*/
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
/**
* Nivel anterior
*/
public function oldTier(): BelongsTo
{
return $this->belongsTo(ClientTier::class, 'old_tier_id');
}
/**
* Nivel nuevo
*/
public function newTier(): BelongsTo
{
return $this->belongsTo(ClientTier::class, 'new_tier_id');
}
}

189
app/Models/Inventory.php Normal file
View File

@ -0,0 +1,189 @@
<?php
namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Services\InventoryMovementService;
use Illuminate\Database\Eloquent\Model;
/**
* Modelo de Inventario (Catálogo de productos)
*
* El stock NO vive aquí, vive en inventory_warehouse
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class Inventory extends Model
{
protected $fillable = [
'category_id',
'subcategory_id',
'unit_of_measure_id',
'name',
'key_sat',
'sku',
'barcode',
'track_serials',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
'track_serials' => 'boolean',
];
protected $appends = ['has_serials', 'inventory_value', 'stock'];
public function warehouses()
{
return $this->belongsToMany(Warehouse::class, 'inventory_warehouse')
->withPivot('stock', 'min_stock', 'max_stock')
->withTimestamps();
}
/**
* Stock total en todos los almacenes (ahora soporta decimales)
*/
public function getStockAttribute(): float
{
return (float) $this->warehouses()->sum('inventory_warehouse.stock');
}
/**
* Alias para compatibilidad
*/
public function getTotalStockAttribute(): float
{
return $this->stock;
}
/**
* Stock en un almacén específico (ahora soporta decimales)
*/
public function stockInWarehouse(int $warehouseId): float
{
return (float) ($this->warehouses()
->where('warehouse_id', $warehouseId)
->value('inventory_warehouse.stock') ?? 0);
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function subcategory()
{
return $this->belongsTo(Subcategory::class);
}
public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function price()
{
return $this->hasOne(Price::class);
}
public function serials()
{
return $this->hasMany(InventorySerial::class);
}
/**
* Obtener seriales disponibles
*/
public function availableSerials()
{
return $this->hasMany(InventorySerial::class)
->where('status', 'disponible');
}
public function unitEquivalences()
{
return $this->hasMany(UnitEquivalence::class);
}
/**
* Obtener factor de conversión para una unidad dada.
* Retorna 1.0 si es la unidad base del producto.
*/
public function getConversionFactor(int $unitOfMeasureId): float
{
if ($this->unit_of_measure_id === $unitOfMeasureId) {
return 1.0;
}
$equivalence = $this->unitEquivalences()
->where('unit_of_measure_id', $unitOfMeasureId)
->where('is_active', true)
->first();
if (! $equivalence) {
throw new \Exception(
"No existe equivalencia activa para la unidad seleccionada en el producto '{$this->name}'."
);
}
return (float) $equivalence->conversion_factor;
}
/**
* Convertir cantidad de cualquier unidad a unidades base
*/
public function convertToBaseUnits(float $quantity, int $unitOfMeasureId): float
{
return round($quantity * $this->getConversionFactor($unitOfMeasureId), 3);
}
/**
* Convertir costo por unidad de entrada a costo por unidad base
*/
public function convertCostToBaseUnit(float $costPerUnit, int $unitOfMeasureId): float
{
$factor = $this->getConversionFactor($unitOfMeasureId);
if ($factor <= 0) {
throw new \Exception('Factor de conversión inválido.');
}
return round($costPerUnit / $factor, 2);
}
/**
* Stock basado en seriales disponibles (para productos con track_serials)
*/
public function getAvailableStockAttribute(): int
{
return $this->availableSerials()->count();
}
public function getHasSerialsAttribute(): bool
{
return isset($this->attributes['serials_count']) && $this->attributes['serials_count'] > 0;
}
/**
* Valor total del inventario (stock * costo)
*/
public function getInventoryValueAttribute(): float
{
return $this->stock * ($this->price?->cost ?? 0);
}
/**
* Sincronizar stock basado en seriales disponibles
* Delega al servicio para mantener la lógica centralizada
*/
public function syncStock(): void
{
app(InventoryMovementService::class)->syncStockFromSerials($this);
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Models;
use App\Observers\InventoryMovementObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
use Notsoweb\LaravelCore\Traits\Models\Extended;
#[ObservedBy([InventoryMovementObserver::class])]
class InventoryMovement extends Model
{
use Extended;
const UPDATED_AT = null;
protected $fillable = [
'inventory_id',
'warehouse_from_id',
'warehouse_to_id',
'movement_type',
'quantity',
'unit_of_measure_id',
'unit_quantity',
'unit_cost',
'unit_cost_original',
'supplier_id',
'reference_type',
'reference_id',
'user_id',
'notes',
'invoice_reference',
];
protected $casts = [
'quantity' => 'decimal:3',
'unit_quantity' => 'decimal:3',
'unit_cost' => 'decimal:2',
'unit_cost_original' => 'decimal:2',
'created_at' => 'datetime',
];
// Relaciones
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
public function supplier()
{
return $this->belongsTo(Supplier::class);
}
public function warehouseFrom()
{
return $this->belongsTo(Warehouse::class, 'warehouse_from_id');
}
public function warehouseTo()
{
return $this->belongsTo(Warehouse::class, 'warehouse_to_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function serials()
{
return $this->hasMany(InventorySerial::class, 'movement_id');
}
public function transferredSerials()
{
return $this->hasMany(InventorySerial::class, 'transfer_movement_id');
}
public function exitedSerials()
{
return $this->hasMany(InventorySerial::class, 'exit_movement_id');
}
// Relación polimórfica para la referencia
public function reference()
{
return $this->morphTo();
}
// Scopes
public function scopeByType($query, string $type)
{
return $query->where('movement_type', $type);
}
public function scopeEntry($query)
{
return $query->where('movement_type', 'entry');
}
public function scopeExit($query)
{
return $query->where('movement_type', 'exit');
}
public function scopeTransfer($query)
{
return $query->where('movement_type', 'transfer');
}
}

View File

@ -0,0 +1,145 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Modelo para números de serie de inventario
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
* @version 1.0.0
*/
class InventorySerial extends Model
{
protected $fillable = [
'inventory_id',
'warehouse_id',
'movement_id',
'transfer_movement_id',
'exit_movement_id',
'serial_number',
'status',
'sale_detail_id',
'return_detail_id',
'notes',
];
protected $casts = [
'status' => 'string',
];
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
public function movement()
{
return $this->belongsTo(InventoryMovement::class);
}
public function transferMovement()
{
return $this->belongsTo(InventoryMovement::class, 'transfer_movement_id');
}
public function exitMovement()
{
return $this->belongsTo(InventoryMovement::class, 'exit_movement_id');
}
public function markAsExited(int $exitMovementId): void
{
$this->update([
'status' => 'salida',
'exit_movement_id' => $exitMovementId,
]);
}
public function restoreFromExit(): void
{
$this->update([
'status' => 'disponible',
'exit_movement_id' => null,
]);
}
public function isExited(): bool
{
return $this->status === 'salida';
}
public function saleDetail()
{
return $this->belongsTo(SaleDetail::class);
}
public function returnDetail()
{
return $this->belongsTo(ReturnDetail::class);
}
/**
* Verificar si el serial está disponible
*/
public function isAvailable(): bool
{
return $this->status === 'disponible';
}
/**
* Marcar como vendido
*/
public function markAsSold(int $saleDetailId, ?int $warehouseId = null): void {
$this->update([
'status' => 'vendido',
'sale_detail_id' => $saleDetailId,
'warehouse_id' => $warehouseId,
]);
}
/**
* Marcar como disponible (ej: cancelación de venta)
*/
public function markAsAvailable(): void
{
$this->update([
'status' => 'disponible',
'sale_detail_id' => null,
]);
}
/**
* Marcar como devuelto
*/
public function markAsReturned(int $returnDetailId): void
{
$this->update([
'status' => 'devuelto',
'return_detail_id' => $returnDetailId,
]);
}
/**
* Restaurar a disponible desde devuelto
*/
public function restoreFromReturn(): void
{
$this->update([
'status' => 'disponible',
'sale_detail_id' => null,
]);
}
/**
* Verificar si el serial está devuelto
*/
public function isReturned(): bool
{
return $this->status === 'devuelto';
}
}

View File

@ -0,0 +1,47 @@
<?php namespace App\Models;
use App\Observers\InventoryWarehouseObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy([InventoryWarehouseObserver::class])]
class InventoryWarehouse extends Model
{
protected $table = 'inventory_warehouse';
protected $fillable = [
'inventory_id',
'warehouse_id',
'stock',
'min_stock',
'max_stock',
];
protected $casts = [
'stock' => 'decimal:3',
'min_stock' => 'decimal:3',
'max_stock' => 'decimal:3',
];
// Relaciones
public function inventory() {
return $this->belongsTo(Inventory::class);
}
public function warehouse() {
return $this->belongsTo(Warehouse::class);
}
// Métodos útiles
public function isLowStock(): bool {
return $this->min_stock && $this->stock <= $this->min_stock;
}
public function isOverStock(): bool {
return $this->max_stock && $this->stock >= $this->max_stock;
}
public function hasAvailableStock(float $quantity): bool {
return $this->stock >= $quantity;
}
}

View File

@ -0,0 +1,112 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class InvoiceRequest extends Model
{
protected $fillable = [
'sale_id',
'client_id',
'status',
'requested_at',
'processed_at',
'processed_by',
'notes',
'invoice_xml_path',
'invoice_pdf_path',
'cfdi_uuid',
];
protected $casts = [
'requested_at' => 'datetime',
'processed_at' => 'datetime',
];
/**
* Atributos adicionales para serializar
*/
protected $appends = ['invoice_xml_url', 'invoice_pdf_url'];
const STATUS_PENDING = 'pending';
const STATUS_PROCESSED = 'processed';
const STATUS_REJECTED = 'rejected';
public function sale()
{
return $this->belongsTo(Sale::class);
}
public function client()
{
return $this->belongsTo(Client::class);
}
public function processedBy()
{
return $this->belongsTo(User::class, 'processed_by');
}
/**
* Marcar como procesada
*/
public function markAsProcessed(int $userId, ?string $notes = null): bool
{
return $this->update([
'status' => self::STATUS_PROCESSED,
'processed_at' => now(),
'processed_by' => $userId,
'notes' => $notes ?? $this->notes,
]);
}
/**
* Marcar como rechazada
*/
public function markAsRejected(int $userId, ?string $notes = null): bool
{
return $this->update([
'status' => self::STATUS_REJECTED,
'processed_at' => now(),
'processed_by' => $userId,
'notes' => $notes ?? $this->notes,
]);
}
/**
* Obtener la URL completa del archivo XML
*/
public function getInvoiceXmlUrlAttribute()
{
if (!$this->invoice_xml_path) {
return null;
}
return Storage::disk('public')->url($this->invoice_xml_path);
}
/**
* Obtener la URL completa del archivo PDF
*/
public function getInvoicePdfUrlAttribute()
{
if (!$this->invoice_pdf_path) {
return null;
}
return Storage::disk('public')->url($this->invoice_pdf_path);
}
}

35
app/Models/Price.php Normal file
View File

@ -0,0 +1,35 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class Price extends Model
{
protected $fillable = [
'inventory_id',
'cost',
'retail_price',
'tax',
];
protected $casts = [
'cost' => 'decimal:2',
'retail_price' => 'decimal:2',
'tax' => 'decimal:2',
];
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReturnDetail extends Model
{
protected $fillable = [
'return_id',
'sale_detail_id',
'inventory_id',
'product_name',
'quantity_returned',
'unit_of_measure_id',
'unit_quantity_returned',
'unit_price',
'subtotal',
];
protected $casts = [
'quantity_returned' => 'decimal:3',
'unit_quantity_returned' => 'decimal:3',
'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2',
];
/**
* Relación con devolución principal
*/
public function return()
{
return $this->belongsTo(Returns::class, 'return_id');
}
/**
* Relación con detalle de venta original
*/
public function saleDetail()
{
return $this->belongsTo(SaleDetail::class);
}
/**
* Relación con inventario
*/
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
/**
* Seriales devueltos en este detalle
*/
public function serials()
{
return $this->hasMany(InventorySerial::class, 'return_detail_id');
}
/**
* Obtener números de serie devueltos
*/
public function getSerialNumbersAttribute(): array
{
return $this->serials()->pluck('serial_number')->toArray();
}
}

78
app/Models/Returns.php Normal file
View File

@ -0,0 +1,78 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Returns extends Model
{
use SoftDeletes;
protected $fillable = [
'sale_id',
'user_id',
'cash_register_id',
'return_number',
'subtotal',
'tax',
'total',
'discount_refund',
'refund_method',
'reason',
'notes'
];
protected $casts = [
'subtotal' => 'decimal:2',
'tax' => 'decimal:2',
'total' => 'decimal:2',
'discount_refund' => 'decimal:2',
'created_at' => 'datetime',
];
/**
* Relación con la venta original
*/
public function sale()
{
return $this->belongsTo(Sale::class);
}
/**
* Relación con usuario que procesó la devolución
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Relación con caja registradora
*/
public function cashRegister()
{
return $this->belongsTo(CashRegister::class);
}
/**
* Detalles de la devolución
*/
public function details()
{
return $this->hasMany(ReturnDetail::class, 'return_id');
}
/**
* Obtener texto descriptivo de la razón
*/
public function getReasonTextAttribute(): string
{
return match($this->reason) {
'defective' => 'Producto defectuoso',
'wrong_product' => 'Producto incorrecto',
'change_of_mind' => 'Cambio de opinión',
'damaged' => 'Producto dañado',
'other' => 'Otra razón',
default => 'No especificado',
};
}
}

105
app/Models/Sale.php Normal file
View File

@ -0,0 +1,105 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class Sale extends Model
{
protected $fillable = [
'user_id',
'client_id',
'cash_register_id',
'invoice_number',
'subtotal',
'tax',
'total',
'discount_percentage',
'discount_amount',
'client_tier_name',
'cash_received',
'change',
'payment_method',
'status',
];
protected $casts = [
'subtotal' => 'decimal:2',
'tax' => 'decimal:2',
'total' => 'decimal:2',
'discount_percentage' => 'decimal:2',
'discount_amount' => 'decimal:2',
'cash_received' => 'decimal:2',
'change' => 'decimal:2',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function details()
{
return $this->hasMany(SaleDetail::class);
}
public function cashRegister()
{
return $this->belongsTo(CashRegister::class);
}
public function client()
{
return $this->belongsTo(Client::class);
}
/**
* Devoluciones asociadas a esta venta
*/
public function returns()
{
return $this->hasMany(Returns::class, 'sale_id');
}
/**
* Total devuelto de esta venta
*/
public function getTotalReturnedAttribute(): float
{
return (float) $this->returns()->sum('total');
}
/**
* Verificar si la venta tiene devoluciones
*/
public function hasReturns(): bool
{
return $this->returns()->exists();
}
/**
* Total neto (total - devoluciones)
*/
public function getNetTotalAttribute(): float
{
return (float) ($this->total - $this->getTotalReturnedAttribute());
}
/**
* Solicitudes de factura asociadas a esta venta
*/
public function invoiceRequests()
{
return $this->hasMany(InvoiceRequest::class);
}
}

148
app/Models/SaleDetail.php Normal file
View File

@ -0,0 +1,148 @@
<?php
namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class SaleDetail extends Model
{
protected $fillable = [
'sale_id',
'inventory_id',
'bundle_id',
'bundle_sale_group',
'warehouse_id',
'unit_of_measure_id',
'unit_quantity',
'product_name',
'quantity',
'unit_price',
'subtotal',
'discount_percentage',
'discount_amount',
];
protected $casts = [
'quantity' => 'decimal:3',
'unit_quantity' => 'decimal:3',
'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2',
'discount_percentage' => 'decimal:2',
'discount_amount' => 'decimal:2',
];
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
}
public function sale()
{
return $this->belongsTo(Sale::class);
}
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function serials()
{
return $this->hasMany(InventorySerial::class, 'sale_detail_id');
}
/**
* Obtener números de serie vendidos
*/
public function getSerialNumbersAttribute(): array
{
return $this->serials()->pluck('serial_number')->toArray();
}
/**
* Devoluciones de este detalle
*/
public function returnDetails()
{
return $this->hasMany(ReturnDetail::class);
}
/**
* Cantidad total devuelta
*/
public function getQuantityReturnedAttribute(): int
{
return $this->returnDetails()->sum('quantity_returned');
}
/**
* Cantidad restante (no devuelta)
*/
public function getQuantityRemainingAttribute(): int
{
return $this->quantity - $this->getQuantityReturnedAttribute();
}
/**
* Verificar si se puede devolver más
*/
public function canReturn(): bool
{
return $this->getQuantityRemainingAttribute() > 0;
}
/**
* Cantidad máxima que se puede devolver
*/
public function getMaxReturnableQuantityAttribute(): int
{
return $this->getQuantityRemainingAttribute();
}
/**
* Bundle al que pertenece este sale_detail (si es componente de un kit)
*/
public function bundle()
{
return $this->belongsTo(Bundle::class);
}
/**
* Verificar si este sale_detail es parte de un kit
*/
public function isPartOfBundle(): bool
{
return ! is_null($this->bundle_id);
}
/**
* Obtener todos los sale_details del mismo kit vendido
* (todos los componentes con el mismo bundle_sale_group)
*/
public function bundleComponents()
{
if (! $this->isPartOfBundle()) {
return collect([]);
}
return SaleDetail::where('sale_id', $this->sale_id)
->where('bundle_sale_group', $this->bundle_sale_group)
->get();
}
}

View File

@ -0,0 +1,30 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Subcategory extends Model
{
use SoftDeletes;
protected $fillable = [
'category_id',
'name',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function category()
{
return $this->belongsTo(Category::class);
}
public function inventories()
{
return $this->hasMany(Inventory::class);
}
}

31
app/Models/Supplier.php Normal file
View File

@ -0,0 +1,31 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Descripción
*/
class Supplier extends Model
{
protected $fillable = [
'business_name',
'rfc',
'email',
'phone',
'address',
'postal_code',
'notes'
];
public function inventoryMovements()
{
return $this->hasMany(InventoryMovement::class)->where('movement_type', 'entry');
}
public function suppliedProducts()
{
return $this->hasManyThrough(Inventory::class, InventoryMovement::class, 'supplier_id', 'id', 'id', 'product_id')
->where('inventory_movements.movement_type', 'entry')
->distinct();
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UnitEquivalence extends Model
{
protected $fillable = [
'inventory_id',
'unit_of_measure_id',
'conversion_factor',
'retail_price',
'is_active',
];
protected $casts = [
'conversion_factor' => 'decimal:3',
'retail_price' => 'decimal:2',
'is_active' => 'boolean',
];
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

View File

@ -0,0 +1,50 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Unidad de Medida
*
* Define las unidades de medida disponibles para los productos
* (Unidad, Kilogramo, Litro, Metro, etc.)
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class UnitOfMeasurement extends Model
{
protected $table = 'units_of_measurement';
protected $fillable = [
'name',
'abbreviation',
'allows_decimals',
'is_active',
];
protected $casts = [
'allows_decimals' => 'boolean',
'is_active' => 'boolean',
];
/**
* Scope para unidades activas
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Relación con inventarios
*/
public function inventories()
{
return $this->hasMany(Inventory::class, 'unit_of_measure_id');
}
}

61
app/Models/Warehouse.php Normal file
View File

@ -0,0 +1,61 @@
<?php
namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Modelo para almacenes
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class Warehouse extends Model
{
protected $fillable = ['code', 'name', 'is_active', 'is_main'];
protected $casts = [
'is_active' => 'boolean',
'is_main' => 'boolean',
];
// Relaciones
public function inventories()
{
return $this->belongsToMany(Inventory::class, 'inventory_warehouse')
->withPivot('stock', 'min_stock', 'max_stock')
->withTimestamps();
}
public function inventorySerials()
{
return $this->hasMany(InventorySerial::class);
}
public function movementsFrom()
{
return $this->hasMany(InventoryMovement::class, 'warehouse_from_id');
}
public function movementsTo()
{
return $this->hasMany(InventoryMovement::class, 'warehouse_to_id');
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeMain($query)
{
return $query->where('is_main', true);
}
}

View File

@ -0,0 +1,39 @@
<?php namespace App\Observers;
use App\Models\InventoryMovement;
use App\Models\UserEvent;
class InventoryMovementObserver
{
/**
* Manipulador del evento "created" del modelo InventoryMovement
*/
public function created(InventoryMovement $inventoryMovement): void
{
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
}
/**
* Manipulador del evento "deleted" del modelo InventoryMovement
*/
public function deleted(InventoryMovement $inventoryMovement): void
{
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
}
/**
* Manipulador del evento "restored" del modelo InventoryMovement
*/
public function restored(InventoryMovement $inventoryMovement): void
{
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
}
/**
* Manipulador del evento "force deleted" del modelo InventoryMovement
*/
public function forceDeleted(InventoryMovement $inventoryMovement): void
{
UserEvent::report(model: $inventoryMovement, event: __FUNCTION__, key: 'movement_type');
}
}

View File

@ -0,0 +1,69 @@
<?php namespace App\Observers;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Models\InventoryWarehouse;
use App\Models\User;
use App\Notifications\UserNotification;
/**
* Observer de stock por almacén
*
* Envía notificación a administradores cuando el stock de un producto
* cruza el umbral mínimo (de disponible a bajo stock).
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class InventoryWarehouseObserver
{
/**
* Roles que reciben alertas de stock bajo
*/
protected array $notifiableRoles = ['developer', 'admin'];
/**
* Umbral por defecto cuando min_stock no está configurado
*/
protected int $defaultThreshold = 5;
/**
* Detectar cuando el stock cruza el umbral mínimo hacia abajo
*/
public function updated(InventoryWarehouse $inventoryWarehouse): void
{
$previousStock = (float) $inventoryWarehouse->getOriginal('stock');
$currentStock = (float) $inventoryWarehouse->stock;
$threshold = (float) ($inventoryWarehouse->min_stock ?? $this->defaultThreshold);
// Solo notificar si el stock CRUZÓ el umbral (no en cada update mientras ya esté bajo)
if ($previousStock >= $threshold && $currentStock < $threshold) {
$this->notifyLowStock($inventoryWarehouse, $currentStock);
}
}
/**
* Enviar notificación de stock bajo a usuarios con rol relevante
*/
protected function notifyLowStock(InventoryWarehouse $inventoryWarehouse, float $currentStock): void
{
$inventoryWarehouse->load('inventory', 'warehouse');
$productName = $inventoryWarehouse->inventory?->name ?? 'Producto desconocido';
$warehouseName = $inventoryWarehouse->warehouse?->name ?? 'Almacén desconocido';
$users = User::role($this->notifiableRoles)->get();
foreach ($users as $user) {
$user->notify(new UserNotification(
title: 'Stock bajo: ' . $productName,
description: "El producto \"{$productName}\" tiene {$currentStock} unidad(es) disponible(s) en \"{$warehouseName}\".",
type: 'warning',
timeout: 30,
save: true,
));
}
}
}

View File

@ -0,0 +1,175 @@
<?php
namespace App\Services;
use App\Models\Bundle;
use App\Models\BundleItem;
use App\Models\BundlePrice;
use Illuminate\Support\Facades\DB;
class BundleService
{
/**
* Crear un nuevo kit/bundle con sus componentes y precio
*/
public function createBundle(array $data): Bundle
{
return DB::transaction(function () use ($data) {
// 1. Crear el bundle principal
$bundle = Bundle::create([
'name' => $data['name'],
'sku' => $data['sku'],
'barcode' => $data['barcode'] ?? null,
]);
// 2. Agregar componentes al kit
foreach ($data['items'] as $item) {
BundleItem::create([
'bundle_id' => $bundle->id,
'inventory_id' => $item['inventory_id'],
'quantity' => $item['quantity'],
]);
}
// 3. Calcular costos y crear precio
$bundle->load('items.inventory.price');
$totalCost = 0;
$totalRetailPrice = 0;
foreach ($bundle->items as $item) {
$totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity;
$totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity;
}
// Permitir override de precio (para promociones)
$finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice;
$tax = $data['tax'] ?? 16.00;
BundlePrice::create([
'bundle_id' => $bundle->id,
'cost' => $totalCost,
'retail_price' => $finalRetailPrice,
'tax' => $tax,
]);
return $bundle->fresh(['items.inventory.price', 'price']);
});
}
/**
* Actualizar un kit existente
*/
public function updateBundle(Bundle $bundle, array $data): Bundle
{
return DB::transaction(function () use ($bundle, $data) {
// 1. Actualizar datos básicos del bundle
$bundle->update([
'name' => $data['name'] ?? $bundle->name,
'sku' => $data['sku'] ?? $bundle->sku,
'barcode' => $data['barcode'] ?? $bundle->barcode,
]);
// 2. Actualizar componentes si se proporcionan
if (isset($data['items'])) {
// Eliminar componentes actuales
$bundle->items()->delete();
// Crear nuevos componentes
foreach ($data['items'] as $item) {
BundleItem::create([
'bundle_id' => $bundle->id,
'inventory_id' => $item['inventory_id'],
'quantity' => $item['quantity'],
]);
}
}
// 3. Recalcular o actualizar precio
if (isset($data['recalculate_price']) && $data['recalculate_price']) {
// Recalcular precio basado en componentes actuales
$bundle->load('items.inventory.price');
$totalCost = 0;
$totalRetailPrice = 0;
foreach ($bundle->items as $item) {
$totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity;
$totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity;
}
$finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice;
$tax = $data['tax'] ?? 16.00;
$bundle->price->update([
'cost' => $totalCost,
'retail_price' => $finalRetailPrice,
'tax' => $tax,
]);
} elseif (isset($data['retail_price'])) {
// Solo actualizar precio sin recalcular componentes
$bundle->price->update([
'retail_price' => $data['retail_price'],
'tax' => $data['tax'] ?? 16.00,
]);
}
return $bundle->fresh(['items.inventory.price', 'price']);
});
}
/**
* Validar disponibilidad de stock del kit
*/
public function validateBundleAvailability(Bundle $bundle, int $quantity, ?int $warehouseId = null): void
{
if (!$bundle->hasStock($quantity, $warehouseId)) {
$availableStock = $warehouseId
? $bundle->stockInWarehouse($warehouseId)
: $bundle->available_stock;
throw new \Exception(
"Stock insuficiente del kit '{$bundle->name}'. " .
"Disponibles: {$availableStock}, Requeridos: {$quantity}"
);
}
}
/**
* Obtener precio total de componentes (sin promoción)
*/
public function getComponentsValue(Bundle $bundle): float
{
$total = 0;
foreach ($bundle->items as $item) {
$componentPrice = $item->inventory->price->retail_price ?? 0;
$total += $componentPrice * $item->quantity;
}
return round($total, 2);
}
/**
* Eliminar (soft delete) un bundle
*/
public function deleteBundle(Bundle $bundle): bool
{
return $bundle->delete();
}
/**
* Restaurar un bundle eliminado
*/
public function restoreBundle(int $bundleId): ?Bundle
{
$bundle = Bundle::withTrashed()->find($bundleId);
if ($bundle && $bundle->trashed()) {
$bundle->restore();
return $bundle->fresh(['items.inventory.price', 'price']);
}
return null;
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Services;
use App\Models\CashRegister;
use App\Models\Sale;
use App\Models\Returns;
class CashRegisterService
{
/**
* Abrir caja
*/
public function openRegister(array $data)
{
// Verificar que el usuario no tenga una caja abierta
$openRegister = CashRegister::where('user_id', $data['user_id'])
->where('status', 'open')
->first();
if ($openRegister) {
throw new \Exception('Ya tienes una caja abierta. Debes cerrarla antes de abrir una nueva.');
}
return CashRegister::create([
'user_id' => $data['user_id'],
'opened_at' => now(),
'initial_cash' => $data['initial_cash'] ?? 0,
'status' => 'open',
]);
}
/**
* Cerrar caja
*/
public function closeRegister(CashRegister $register, array $data)
{
$sales = Sale::where('cash_register_id', $register->id)
->where('status', 'completed')
->get();
// Calcular devoluciones
$returns = Returns::where('cash_register_id', $register->id)->get();
// Calcular efectivo real (recibido - devuelto)
$cashSales = $sales->where('payment_method', 'cash')
->sum(function ($sale) {
return ($sale->cash_received ?? 0) - ($sale->change ?? 0);
});
// Efectivo de devoluciones
$cashReturns = $returns->where('refund_method', 'cash')->sum('total');
// Devoluciones por tarjeta
$cardReturns = $returns->whereIn('refund_method', ['credit_card', 'debit_card'])->sum('total');
$totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received');
$totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change');
$cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total');
$totalSales = $sales->sum('total');
$totalReturns = $returns->sum('total');
// Efectivo esperado (ajustado por devoluciones)
$expectedCash = $register->initial_cash + $cashSales - $cashReturns;
// Diferencia (sobrante o faltante)
$difference = $data['final_cash'] - $expectedCash;
// Cerrar caja
$register->update([
'closed_at' => now(),
'final_cash' => $data['final_cash'],
'expected_cash' => $expectedCash,
'difference' => $difference,
'total_sales' => $totalSales,
'cash_sales' => $cashSales,
'card_sales' => $cardSales,
'total_cash_received' => $totalCashReceived,
'total_change_given' => $totalChangeGiven,
// Campos nuevos de devoluciones
'total_returns' => $totalReturns,
'cash_returns' => $cashReturns,
'card_returns' => $cardReturns,
'returns_count' => $returns->count(),
'notes' => $data['notes'] ?? null,
'status' => 'closed',
]);
return $register;
}
/**
* Obtener resumen de caja actual
*/
public function getCurrentSummary(CashRegister $register)
{
$sales = Sale::where('cash_register_id', $register->id)
->where('status', 'completed')
->get();
$returns = Returns::where('cash_register_id', $register->id)->get();
// Calcular efectivo real en caja (recibido - devuelto)
$cashSales = $sales->where('payment_method', 'cash')
->sum(function ($sale) {
return ($sale->cash_received ?? 0) - ($sale->change ?? 0);
});
// Devoluciones
$cashReturns = $returns->where('refund_method', 'cash')->sum('total');
$cardReturns = $returns->whereIn('refund_method', ['credit_card', 'debit_card'])->sum('total');
$totalReturns = $returns->sum('total');
// Confirmación, envio de los totales
$totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received');
$totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change');
$cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total');
$totalSales = $sales->sum('total');
$transactionCount = $sales->count();
return [
'id' => $register->id,
'user_id' => $register->user_id,
'status' => $register->status,
'opened_at' => $register->opened_at,
'closed_at' => $register->closed_at,
'initial_cash' => (float) $register->initial_cash,
// Totales calculados
'total_sales' => (float) $totalSales,
'transaction_count' => $transactionCount,
'cash_sales' => (float) $cashSales,
'card_sales' => (float) $cardSales,
// Totales de devoluciones
'total_returns' => (float) $totalReturns,
'cash_returns' => (float) $cashReturns,
'card_returns' => (float) $cardReturns,
'returns_count' => $returns->count(),
//Desglose de efectivo
'total_cash_received' => (float) $totalCashReceived,
'total_change_given' => (float) $totalChangeGiven,
// Efectivo esperado (ajustado por devoluciones en efectivo)
'expected_cash' => (float) ($register->initial_cash + $cashSales - $cashReturns),
// Ventas netas (después de devoluciones)
'net_sales' => (float) ($totalSales - $totalReturns),
];
}
}

View File

@ -0,0 +1,166 @@
<?php
namespace App\Services;
use App\Models\Client;
use App\Models\ClientTier;
use App\Models\ClientTierHistory;
class ClientTierService
{
/**
* Calcular y actualizar el tier del cliente basado en sus compras totales
*/
public function recalculateClientTier(Client $client): ?ClientTier
{
// Calcular compras netas (total - devoluciones)
$netPurchases = $client->total_purchases - $client->lifetime_returns;
// Buscar el tier apropiado ordenado por monto mínimo descendente
$newTier = ClientTier::active()
->where('min_purchase_amount', '<=', $netPurchases)
->where(function ($query) use ($netPurchases) {
$query->whereNull('max_purchase_amount')
->orWhere('max_purchase_amount', '>=', $netPurchases);
})
->orderBy('min_purchase_amount', 'desc')
->first();
// Si cambió el tier, registrar en historial
if ($newTier && $client->tier_id !== $newTier->id) {
$this->recordTierChange($client, $newTier, $netPurchases);
$client->tier_id = $newTier->id;
$client->save();
}
return $newTier;
}
/**
* Registrar cambio de tier en el historial
*/
protected function recordTierChange(Client $client, ClientTier $newTier, float $totalAtChange): void
{
ClientTierHistory::create([
'client_id' => $client->id,
'old_tier_id' => $client->tier_id,
'new_tier_id' => $newTier->id,
'total_at_change' => $totalAtChange,
'reason' => 'Actualización automática por cambio en compras acumuladas',
]);
}
/**
* Obtener descuento aplicable para un cliente
*/
public function getApplicableDiscount(Client $client): float
{
if (!$client->tier) {
return 0.00;
}
return $client->tier->discount_percentage;
}
/**
* Actualizar estadísticas de compra del cliente
*/
public function updateClientPurchaseStats(Client $client, float $amount, int $quantity = 1): void
{
$client->total_purchases += $amount;
$client->total_transactions += 1;
$client->last_purchase_at = now();
$client->save();
// Recalcular tier después de actualizar stats
$this->recalculateClientTier($client);
}
/**
* Revertir estadísticas de compra del cliente (para devoluciones)
*/
public function revertClientPurchaseStats(Client $client, float $amount): void
{
$client->lifetime_returns += $amount;
$client->save();
// Recalcular tier después de registrar devolución
$this->recalculateClientTier($client);
}
/**
* Obtener todos los tiers activos
*/
public function getActiveTiers()
{
return ClientTier::active()
->orderBy('min_purchase_amount')
->get();
}
/**
* Obtener tier apropiado para un monto específico
*/
public function getTierForAmount(float $amount): ?ClientTier
{
return ClientTier::active()
->where('min_purchase_amount', '<=', $amount)
->where(function ($query) use ($amount) {
$query->whereNull('max_purchase_amount')
->orWhere('max_purchase_amount', '>=', $amount);
})
->orderBy('min_purchase_amount', 'desc')
->first();
}
/**
* Obtener estadísticas del cliente
*/
public function getClientStats(Client $client): array
{
return [
'client_number' => $client->client_number,
'total_purchases' => $client->total_purchases,
'lifetime_returns' => $client->lifetime_returns,
'net_purchases' => $client->net_purchases,
'total_transactions' => $client->total_transactions,
'last_purchase_at' => $client->last_purchase_at,
'average_purchase' => $client->total_transactions > 0
? $client->total_purchases / $client->total_transactions
: 0,
'current_tier' => $client->tier ? [
'id' => $client->tier->id,
'name' => $client->tier->tier_name,
'discount' => $client->tier->discount_percentage,
] : null,
'next_tier' => $this->getNextTier($client),
];
}
/**
* Obtener información del siguiente tier disponible
*/
protected function getNextTier(Client $client): ?array
{
$currentAmount = $client->net_purchases;
$currentTierId = $client->tier_id;
$nextTier = ClientTier::active()
->where('min_purchase_amount', '>', $currentAmount)
->orderBy('min_purchase_amount')
->first();
if ($nextTier) {
return [
'id' => $nextTier->id,
'name' => $nextTier->tier_name,
'discount' => $nextTier->discount_percentage,
'required_amount' => $nextTier->min_purchase_amount,
'remaining_amount' => $nextTier->min_purchase_amount - $currentAmount,
];
}
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
<?php namespace App\Services;
use App\Models\Inventory;
use App\Models\Price;
use Illuminate\Support\Facades\DB;
class ProductService
{
public function createProduct(array $data)
{
return DB::transaction(function () use ($data) {
$inventory = Inventory::create([
'name' => $data['name'],
'key_sat' => $data['key_sat'] ?? null,
'sku' => $data['sku'],
'barcode' => $data['barcode'] ?? null,
'category_id' => $data['category_id'],
'subcategory_id' => $data['subcategory_id'] ?? null,
'unit_of_measure_id' => $data['unit_of_measure_id'],
'track_serials' => $data['track_serials'] ?? false,
]);
Price::create([
'inventory_id' => $inventory->id,
'cost' => $data['cost'] ?? 0,
'retail_price' => $data['retail_price'],
'tax' => $data['tax'] ?? 16.00,
]);
return $inventory->load(['category', 'price', 'unitOfMeasure']);
});
}
public function updateProduct(Inventory $inventory, array $data)
{
return DB::transaction(function () use ($inventory, $data) {
// Actualizar campos de Inventory solo si están presentes
$inventoryData = array_filter([
'name' => $data['name'] ?? null,
'key_sat' => $data['key_sat'] ?? null,
'sku' => $data['sku'] ?? null,
'barcode' => $data['barcode'] ?? null,
'category_id' => $data['category_id'] ?? null,
'subcategory_id' => $data['subcategory_id'] ?? null,
'unit_of_measure_id' => $data['unit_of_measure_id'] ?? null,
'track_serials' => $data['track_serials'] ?? null,
], fn($value) => $value !== null);
if (!empty($inventoryData)) {
$inventory->update($inventoryData);
}
// Actualizar campos de Price solo si están presentes
$priceData = array_filter([
'cost' => $data['cost'] ?? null,
'retail_price' => $data['retail_price'] ?? null,
'tax' => $data['tax'] ?? null,
], fn($value) => $value !== null);
if (!empty($priceData)) {
$inventory->price()->updateOrCreate(
['inventory_id' => $inventory->id],
$priceData
);
}
return $inventory->fresh(['category', 'price', 'unitOfMeasure']);
});
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Services;
use App\Models\Inventory;
use App\Models\SaleDetail;
use Illuminate\Support\Facades\DB;
class ReportService
{
/**
* Obtener el producto más vendido
*
* @param string|null $fromDate Fecha inicial (formato: Y-m-d)
* @param string|null $toDate Fecha final (formato: Y-m-d)
* @return array|null Retorna el producto más vendido o null si no hay ventas
*/
public function getTopSellingProduct(?string $fromDate = null, ?string $toDate = null): ?array
{
$query = SaleDetail::query()
->selectRaw('
inventories.id,
inventories.name,
inventories.sku,
categories.name as category_name,
SUM(sale_details.quantity) as total_quantity_sold,
SUM(sale_details.subtotal) as total_revenue,
COUNT(DISTINCT sale_details.sale_id) as times_sold,
MAX(sales.created_at) as last_sale_date,
inventories.created_at as added_date
')
->join('inventories', 'sale_details.inventory_id', '=', 'inventories.id')
->join('categories', 'inventories.category_id', '=', 'categories.id')
->join('sales', 'sale_details.sale_id', '=', 'sales.id')
->where('sales.status', 'completed')
->whereNull('sales.deleted_at');
// Aplicar filtro de fechas si se proporcionan ambas
if ($fromDate && $toDate) {
$query->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate]);
}
$result = $query
->groupBy('inventories.id', 'inventories.name', 'inventories.sku',
'categories.name', 'inventories.created_at')
->orderByDesc('total_quantity_sold')
->first();
return $result ? $result->toArray() : null;
}
/**
* Obtener productos sin movimiento
*/
public function getProductsWithoutMovement(?string $fromDate = null, ?string $toDate = null)
{
// Obtener IDs de productos que SÍ tienen ventas
$inventoriesWithSales = SaleDetail::query()
->join('sales', 'sale_details.sale_id', '=', 'sales.id')
->where('sales.status', 'completed')
->whereNull('sales.deleted_at')
->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate])
->distinct()
->pluck('sale_details.inventory_id')
->toArray();
// Construir query para productos SIN ventas usando relaciones de Eloquent
$query = Inventory::query()
->with(['category', 'price'])
->where('is_active', true)
->whereNotIn('id', $inventoriesWithSales)
->orderBy('created_at');
return $query->paginate(config('app.pagination', 10));
}
}

View File

@ -0,0 +1,344 @@
<?php
namespace App\Services;
use App\Models\CashRegister;
use App\Models\Client;
use App\Models\Inventory;
use App\Models\InventorySerial;
use App\Models\ReturnDetail;
use App\Models\Returns;
use App\Models\Sale;
use App\Models\SaleDetail;
use Illuminate\Support\Facades\DB;
class ReturnService
{
public function __construct(
protected ClientTierService $clientTierService,
protected InventoryMovementService $movementService
) {}
/**
* Crear una nueva devolución con sus detalles
*/
public function createReturn(array $data): Returns
{
return DB::transaction(function () use ($data) {
// 1. Validaciones de negocio
$sale = Sale::with(['details.serials', 'cashRegister'])->findOrFail($data['sale_id']);
if ($sale->status !== 'completed') {
throw new \Exception('Solo se pueden devolver productos de ventas completadas.');
}
// Validar que la caja esté abierta si se especificó cash_register_id
if (isset($data['cash_register_id'])) {
$cashRegister = CashRegister::findOrFail($data['cash_register_id']);
if (! $cashRegister->isOpen()) {
throw new \Exception('La caja registradora debe estar abierta para procesar devoluciones.');
}
}
// 2. Calcular totales
$subtotal = 0;
$tax = 0;
foreach ($data['items'] as &$item) {
$saleDetail = SaleDetail::findOrFail($item['sale_detail_id']);
$inventory = Inventory::find($saleDetail->inventory_id);
// Conversión de equivalencia de unidades en devolución
$inputUnitId = $item['unit_of_measure_id'] ?? null;
$inputQuantityReturned = (float) $item['quantity_returned'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
$baseQuantityReturned = $usesEquivalence
? $inventory->convertToBaseUnits($inputQuantityReturned, $inputUnitId)
: $inputQuantityReturned;
// Guardar la cantidad convertida para uso posterior
$item['_base_quantity_returned'] = $baseQuantityReturned;
$item['_uses_equivalence'] = $usesEquivalence;
$item['_input_unit_id'] = $inputUnitId;
$item['_input_quantity_returned'] = $inputQuantityReturned;
// Validar que no se devuelva más de lo vendido (restando lo ya devuelto)
$alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id)
->sum('quantity_returned');
$maxReturnable = $saleDetail->quantity - $alreadyReturned;
if ($baseQuantityReturned > $maxReturnable) {
throw new \Exception(
"Solo puedes devolver {$maxReturnable} unidades base de {$saleDetail->product_name}. ".
"Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas."
);
}
$itemSubtotal = $saleDetail->unit_price * $baseQuantityReturned;
$subtotal += $itemSubtotal;
}
unset($item);
// Calcular impuesto proporcional
if ($sale->subtotal > 0) {
$taxRate = $sale->tax / $sale->subtotal;
$tax = $subtotal * $taxRate;
}
$total = $subtotal + $tax;
// Calcular descuento devuelto proporcional si la venta tenía descuento
$discountRefund = 0;
if ($sale->discount_amount > 0 && $sale->total > 0) {
$returnPercentage = $total / $sale->total;
$discountRefund = round($sale->discount_amount * $returnPercentage, 2);
}
// 3. Crear registro de devolución
$return = Returns::create([
'sale_id' => $sale->id,
'user_id' => $data['user_id'],
'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']),
'return_number' => $this->generateReturnNumber(),
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'discount_refund' => $discountRefund,
'refund_method' => $data['refund_method'] ?? $sale->payment_method,
'reason' => $data['reason'],
'notes' => $data['notes'] ?? null,
]);
// 4. Crear detalles y restaurar seriales
foreach ($data['items'] as $item) {
$saleDetail = SaleDetail::findOrFail($item['sale_detail_id']);
$baseQuantityReturned = $item['_base_quantity_returned'];
$usesEquivalence = $item['_uses_equivalence'];
$returnDetail = ReturnDetail::create([
'return_id' => $return->id,
'sale_detail_id' => $saleDetail->id,
'inventory_id' => $saleDetail->inventory_id,
'product_name' => $saleDetail->product_name,
'quantity_returned' => $baseQuantityReturned,
'unit_of_measure_id' => $usesEquivalence ? $item['_input_unit_id'] : null,
'unit_quantity_returned' => $usesEquivalence ? $item['_input_quantity_returned'] : null,
'unit_price' => $saleDetail->unit_price,
'subtotal' => $saleDetail->unit_price * $baseQuantityReturned,
]);
$inventory = $saleDetail->inventory;
if ($inventory->track_serials) {
// Validación de cantidad de seriales
if (! empty($item['serial_numbers'])) {
if (count($item['serial_numbers']) != $baseQuantityReturned) {
throw new \Exception(
'La cantidad de seriales proporcionados no coincide con la cantidad a devolver '.
"para {$saleDetail->product_name}."
);
}
}
// Gestionar seriales
if (! empty($item['serial_numbers'])) {
// Seriales específicos proporcionados
foreach ($item['serial_numbers'] as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('sale_detail_id', $saleDetail->id)
->where('status', 'vendido')
->first();
if (! $serial) {
throw new \Exception(
"El serial {$serialNumber} no pertenece a esta venta o ya fue devuelto."
);
}
// Marcar como devuelto y vincular a devolución
$serial->markAsReturned($returnDetail->id);
// Luego restaurar a disponible
$serial->restoreFromReturn();
}
} else {
// Seleccionar automáticamente los primeros N seriales vendidos
$serials = InventorySerial::where('sale_detail_id', $saleDetail->id)
->where('status', 'vendido')
->limit($baseQuantityReturned)
->get();
if ($serials->count() < $baseQuantityReturned) {
throw new \Exception(
"No hay suficientes seriales disponibles para devolver de {$saleDetail->product_name}"
);
}
foreach ($serials as $serial) {
$serial->markAsReturned($returnDetail->id);
$serial->restoreFromReturn();
}
}
// Sincronizar el stock del inventario
$saleDetail->inventory->syncStock();
} else {
// Restaurar stock en el almacén
$warehouseId = $saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $baseQuantityReturned);
}
}
// 5. Actualizar estadísticas del cliente si existe
if ($sale->client_id) {
$client = Client::find($sale->client_id);
if ($client) {
$this->clientTierService->revertClientPurchaseStats($client, $return->total);
}
}
// 6. Actualizar cash register con descuento devuelto y returns
if ($return->cash_register_id) {
$cashRegister = CashRegister::find($return->cash_register_id);
if ($cashRegister) {
// Actualizar tracking de devoluciones
$cashRegister->increment('total_returns', $return->total);
$cashRegister->increment('returns_count', 1);
// Actualizar tracking de descuentos devueltos (restar de total_discounts)
if ($discountRefund > 0) {
$cashRegister->decrement('total_discounts', $discountRefund);
}
// Actualizar por método de pago
if ($return->refund_method === 'cash') {
$cashRegister->increment('cash_returns', $return->total);
} else {
$cashRegister->increment('card_returns', $return->total);
}
}
}
// 7. Retornar con relaciones cargadas
return $return->load([
'details.inventory',
'details.serials',
'sale.client.tier',
'user',
]);
});
}
/**
* Cancelar una devolución (caso excepcional, restaurar venta)
*/
public function cancelReturn(Returns $return): Returns
{
return DB::transaction(function () use ($return) {
// Restaurar seriales a estado vendido
foreach ($return->details as $detail) {
if ($detail->inventory->track_serials) {
$serials = InventorySerial::where('return_detail_id', $detail->id)->get();
foreach ($serials as $serial) {
$serial->update([
'status' => 'vendido',
'sale_detail_id' => $detail->sale_detail_id,
'return_detail_id' => null,
]);
}
// Sincronizar stock
$detail->inventory->syncStock();
} else {
// Revertir stock (la devolución lo había incrementado)
$warehouseId = $detail->saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, -$detail->quantity_returned);
}
}
// Eliminar la devolución (soft delete)
$return->delete();
return $return->fresh(['details.inventory', 'details.serials', 'sale']);
});
}
/**
* Obtener items elegibles para devolución de una venta
*/
public function getReturnableItems(Sale $sale): array
{
if ($sale->status !== 'completed') {
throw new \Exception('Solo se pueden ver items de ventas completadas.');
}
$returnableItems = [];
foreach ($sale->details as $detail) {
$alreadyReturned = ReturnDetail::where('sale_detail_id', $detail->id)
->sum('quantity_returned');
$maxReturnable = $detail->quantity - $alreadyReturned;
if ($maxReturnable > 0) {
$availableSerials = [];
if ($detail->inventory->track_serials) {
// Obtener seriales vendidos que aún no han sido devueltos
$availableSerials = InventorySerial::where('sale_detail_id', $detail->id)
->where('status', 'vendido')
->get()
->map(fn ($serial) => [
'serial_number' => $serial->serial_number,
'status' => $serial->status,
]);
}
$returnableItems[] = [
'sale_detail_id' => $detail->id,
'inventory_id' => $detail->inventory_id,
'product_name' => $detail->product_name,
'quantity_sold' => $detail->quantity,
'quantity_already_returned' => $alreadyReturned,
'quantity_returnable' => $maxReturnable,
'unit_price' => $detail->unit_price,
'available_serials' => $availableSerials,
];
}
}
return $returnableItems;
}
/**
* Generar número de devolución único
* Formato: RET-YYYYMMDD-0001
*/
private function generateReturnNumber(): string
{
$prefix = 'DEV-';
$date = now()->format('Ymd');
$lastReturn = Returns::whereDate('created_at', today())
->orderBy('id', 'desc')
->first();
$sequential = $lastReturn
? (intval(substr($lastReturn->return_number, -4)) + 1)
: 1;
return $prefix.$date.'-'.str_pad($sequential, 4, '0', STR_PAD_LEFT);
}
/**
* Obtener caja registradora activa del usuario
*/
private function getCurrentCashRegister($userId): ?int
{
$register = CashRegister::where('user_id', $userId)
->where('status', 'open')
->first();
return $register?->id;
}
}

View File

@ -0,0 +1,425 @@
<?php
namespace App\Services;
use App\Models\Bundle;
use App\Models\CashRegister;
use App\Models\Client;
use App\Models\Inventory;
use App\Models\InventorySerial;
use App\Models\Sale;
use App\Models\SaleDetail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class SaleService
{
public function __construct(
protected ClientTierService $clientTierService,
protected InventoryMovementService $movementService
) {}
/**
* Crear una nueva venta con sus detalles
*/
public function createSale(array $data)
{
return DB::transaction(function () use ($data) {
// Obtener cliente si existe
$client = null;
if (isset($data['client_id'])) {
$client = Client::find($data['client_id']);
} elseif (isset($data['client_number'])) {
$client = Client::where('client_number', $data['client_number'])->first();
}
// Calcular descuento si el cliente tiene tier
$discountPercentage = 0;
$discountAmount = 0;
$clientTierName = null;
if ($client && $client->tier) {
$discountPercentage = $this->clientTierService->getApplicableDiscount($client);
$clientTierName = $client->tier->tier_name;
// Calcular descuento solo sobre el subtotal (sin IVA)
$discountAmount = round($data['subtotal'] * ($discountPercentage / 100), 2);
// Recalcular total: subtotal - descuento + IVA
$data['total'] = ($data['subtotal'] - $discountAmount) + $data['tax'];
}
// Calcular el cambio si es pago en efectivo
$cashReceived = null;
$change = null;
if ($data['payment_method'] === 'cash' && isset($data['cash_received'])) {
$cashReceived = $data['cash_received'];
$change = $cashReceived - $data['total'];
}
// 1. Crear la venta principal
$sale = Sale::create([
'user_id' => $data['user_id'],
'client_id' => $client?->id ?? $data['client_id'] ?? null,
'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']),
'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(),
'subtotal' => $data['subtotal'],
'tax' => $data['tax'],
'total' => $data['total'],
'discount_percentage' => $discountPercentage,
'discount_amount' => $discountAmount,
'client_tier_name' => $clientTierName,
'cash_received' => $cashReceived,
'change' => $change,
'payment_method' => $data['payment_method'],
'status' => $data['status'] ?? 'completed',
]);
// 2. Expandir bundles en componentes individuales
$expandedItems = $this->expandBundlesIntoComponents($data['items']);
// 2.1. Validar stock de TODOS los items (componentes + productos normales)
$this->validateStockForAllItems($expandedItems);
// 3. Crear los detalles de la venta y asignar seriales
foreach ($expandedItems as $item) {
// Calcular descuento por detalle si aplica
$itemDiscountAmount = 0;
if ($discountPercentage > 0) {
$itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2);
}
// Obtener el inventario para conversión de unidades
$inventory = Inventory::find($item['inventory_id']);
// Conversión de equivalencia de unidades
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $item['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name}).");
}
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
// Crear detalle de venta
$saleDetail = SaleDetail::create([
'sale_id' => $sale->id,
'inventory_id' => $item['inventory_id'],
'bundle_id' => $item['bundle_id'] ?? null,
'bundle_sale_group' => $item['bundle_sale_group'] ?? null,
'warehouse_id' => $item['warehouse_id'] ?? null,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'product_name' => $item['product_name'],
'quantity' => $baseQuantity,
'unit_price' => $item['unit_price'],
'subtotal' => $item['subtotal'],
'discount_percentage' => $discountPercentage,
'discount_amount' => $itemDiscountAmount,
]);
if ($inventory->track_serials) {
$serialWarehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
// Si se proporcionaron números de serie específicos
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
foreach ($item['serial_numbers'] as $serialNumber) {
$serial = InventorySerial::where('inventory_id', $inventory->id)
->where('serial_number', $serialNumber)
->where('warehouse_id', $serialWarehouseId)
->where('status', 'disponible')
->first();
if ($serial) {
$serial->markAsSold($saleDetail->id, $serialWarehouseId);
} else {
throw new \Exception("Serial {$serialNumber} no disponible en el almacén");
}
}
} else {
// Asignar automáticamente los primeros N seriales disponibles en el almacén
$serials = InventorySerial::where('inventory_id', $inventory->id)
->where('warehouse_id', $serialWarehouseId)
->where('status', 'disponible')
->limit($baseQuantity)
->get();
if ($serials->count() < $baseQuantity) {
throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}");
}
foreach ($serials as $serial) {
$serial->markAsSold($saleDetail->id, $serialWarehouseId);
}
}
// Sincronizar el stock
$inventory->syncStock();
} else {
// Obtener almacén (del item o el principal)
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
$this->movementService->validateStock($inventory->id, $warehouseId, $baseQuantity);
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$baseQuantity);
}
}
// 3. Actualizar estadísticas del cliente si existe
if ($client && $sale->status === 'completed') {
$this->clientTierService->updateClientPurchaseStats($client, $sale->total);
}
// 4. Actualizar cash register con descuentos
if ($sale->cash_register_id && $discountAmount > 0) {
$cashRegister = CashRegister::find($sale->cash_register_id);
if ($cashRegister) {
$cashRegister->increment('total_discounts', $discountAmount);
}
}
// 5. Retornar la venta con sus relaciones cargadas
return $sale->load(['details.inventory', 'details.serials', 'user', 'client.tier']);
});
}
/**
* Cancelar una venta y restaurar el stock
*/
public function cancelSale(Sale $sale)
{
return DB::transaction(function () use ($sale) {
// Verificar que la venta esté completada
if ($sale->status !== 'completed') {
throw new \Exception('Solo se pueden cancelar ventas completadas.');
}
// Verificar que la venta no tenga devoluciones procesadas
if ($sale->returns()->exists()) {
throw new \Exception('No se puede cancelar una venta que tiene devoluciones procesadas.');
}
// Restaurar seriales a disponible
foreach ($sale->details as $detail) {
if ($detail->inventory->track_serials) {
// Restaurar seriales
$serials = InventorySerial::where('sale_detail_id', $detail->id)->get();
foreach ($serials as $serial) {
$serial->markAsAvailable();
}
$detail->inventory->syncStock();
} else {
// Restaurar stock en el almacén
$warehouseId = $detail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, $detail->quantity);
}
}
// Revertir estadísticas del cliente si existe
if ($sale->client_id) {
$client = Client::find($sale->client_id);
if ($client) {
// Restar de total_purchases y decrementar transacciones
$client->decrement('total_purchases', $sale->total);
$client->decrement('total_transactions', 1);
// Recalcular tier después de cancelación
$this->clientTierService->recalculateClientTier($client);
}
}
// Revertir descuentos del cash register
if ($sale->cash_register_id && $sale->discount_amount > 0) {
$cashRegister = CashRegister::find($sale->cash_register_id);
if ($cashRegister) {
$cashRegister->decrement('total_discounts', $sale->discount_amount);
}
}
// Marcar venta como cancelada
$sale->update(['status' => 'cancelled']);
return $sale->fresh(['details.inventory', 'details.serials', 'user', 'client.tier']);
});
}
/**
* Generar número de factura único
* Formato: INV-YYYYMMDD-0001
*/
private function generateInvoiceNumber(): string
{
$prefix = 'INV-';
$date = now()->format('Ymd');
// Obtener la última venta del día
$lastSale = Sale::whereDate('created_at', today())
->orderBy('id', 'desc')
->first();
// Incrementar secuencial
$sequential = $lastSale ? (intval(substr($lastSale->invoice_number, -4)) + 1) : 1;
return $prefix.$date.'-'.str_pad($sequential, 4, '0', STR_PAD_LEFT);
}
private function getCurrentCashRegister($userId)
{
$register = CashRegister::where('user_id', $userId)
->where('status', 'open')
->first();
return $register ? $register->id : null;
}
/**
* Expandir bundles en componentes individuales
*/
private function expandBundlesIntoComponents(array $items): array
{
$expanded = [];
foreach ($items as $item) {
// Detectar si es un bundle
if (isset($item['type']) && $item['type'] === 'bundle') {
// Es un kit, expandir en componentes
$bundle = Bundle::with(['items.inventory.price', 'price'])->findOrFail($item['bundle_id']);
$bundleQuantity = $item['quantity'];
$bundleSaleGroup = Str::uuid()->toString();
// Calcular precio por unidad de kit (para distribuir)
$bundleTotalPrice = $bundle->price->retail_price;
$bundleComponentsValue = $this->calculateBundleComponentsValue($bundle);
foreach ($bundle->items as $bundleItem) {
$componentInventory = $bundleItem->inventory;
$componentQuantity = $bundleItem->quantity * $bundleQuantity;
// Calcular precio proporcional del componente
$componentValue = ($componentInventory->price->retail_price ?? 0) * $bundleItem->quantity;
$priceRatio = $bundleComponentsValue > 0 ? $componentValue / $bundleComponentsValue : 0;
$componentUnitPrice = round($bundleTotalPrice * $priceRatio / $bundleItem->quantity, 2);
$expanded[] = [
'inventory_id' => $componentInventory->id,
'bundle_id' => $bundle->id,
'bundle_sale_group' => $bundleSaleGroup,
'warehouse_id' => $item['warehouse_id'] ?? null,
'product_name' => $componentInventory->name,
'quantity' => $componentQuantity,
'unit_price' => $componentUnitPrice,
'subtotal' => $componentUnitPrice * $componentQuantity,
'serial_numbers' => $item['serial_numbers'][$componentInventory->id] ?? null,
];
}
} else {
// Producto normal, agregar tal cual
$expanded[] = $item;
}
}
return $expanded;
}
/**
* Validar stock de todos los items (antes de crear sale_details)
*/
private function validateStockForAllItems(array $items): void
{
// Agrupar cantidad total por producto+almacén para evitar que un bundle y un producto
// suelto pasen la validación individualmente pero no haya stock suficiente en conjunto
$aggregated = [];
$serialsByProduct = [];
foreach ($items as $item) {
$inventoryId = $item['inventory_id'];
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
$key = "{$inventoryId}_{$warehouseId}";
if (! isset($aggregated[$key])) {
$aggregated[$key] = [
'inventory_id' => $inventoryId,
'warehouse_id' => $warehouseId,
'quantity' => 0,
];
}
// Convertir a unidades base si usa equivalencia
$inventory = Inventory::find($inventoryId);
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $item['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
$aggregated[$key]['quantity'] += $baseQuantity;
// Acumular seriales específicos por producto
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
if (! isset($serialsByProduct[$inventoryId])) {
$serialsByProduct[$inventoryId] = [];
}
$serialsByProduct[$inventoryId] = array_merge(
$serialsByProduct[$inventoryId],
$item['serial_numbers']
);
}
}
// Validar stock total agrupado
foreach ($aggregated as $entry) {
$inventory = Inventory::find($entry['inventory_id']);
if ($inventory->track_serials) {
if (! empty($serialsByProduct[$entry['inventory_id']])) {
// Validar que los seriales específicos existan, estén disponibles y en el almacén correcto
foreach ($serialsByProduct[$entry['inventory_id']] as $serialNumber) {
$serial = InventorySerial::where('inventory_id', $inventory->id)
->where('serial_number', $serialNumber)
->where('warehouse_id', $entry['warehouse_id'])
->where('status', 'disponible')
->first();
if (! $serial) {
throw new \Exception(
"Serial {$serialNumber} no disponible en el almacén para {$inventory->name}"
);
}
}
} else {
// Validar que haya suficientes seriales disponibles en el almacén
$availableSerials = InventorySerial::where('inventory_id', $inventory->id)
->where('warehouse_id', $entry['warehouse_id'])
->where('status', 'disponible')
->count();
if ($availableSerials < $entry['quantity']) {
throw new \Exception(
"Stock insuficiente de seriales para {$inventory->name}. ".
"Disponibles: {$availableSerials}, Requeridos: {$entry['quantity']}"
);
}
}
} else {
// Validar stock total en almacén
$this->movementService->validateStock($inventory->id, $entry['warehouse_id'], $entry['quantity']);
}
}
}
/**
* Obtener precio total de componentes de un bundle
*/
private function calculateBundleComponentsValue(Bundle $bundle): float
{
$total = 0;
foreach ($bundle->items as $item) {
$componentPrice = $item->inventory->price->retail_price ?? 0;
$total += $componentPrice * $item->quantity;
}
return round($total, 2);
}
}

View File

@ -0,0 +1,95 @@
<?php namespace App\Services;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
/**
* Servicio para gestión de almacenes
*/
class WarehouseService
{
/**
* Obtener almacén principal
*/
public function getMainWarehouse(): ?Warehouse
{
return Warehouse::main()->first();
}
/**
* Actualizar almacén
*/
public function updateWarehouse(Warehouse $warehouse, array $data): Warehouse
{
return DB::transaction(function () use ($warehouse, $data) {
// Si se marca como principal, desactivar otros principales
if (isset($data['is_main']) && $data['is_main']) {
Warehouse::where('id', '!=', $warehouse->id)
->where('is_main', true)
->update(['is_main' => false]);
}
// No permitir desactivar el último almacén principal
if (isset($data['is_main']) && !$data['is_main'] && $warehouse->is_main) {
if (Warehouse::main()->count() === 1) {
throw new \Exception('No se puede desactivar el único almacén principal. Asigna otro almacén como principal primero.');
}
}
$warehouse->update($data);
return $warehouse->fresh();
});
}
/**
* Activar/Desactivar almacén
*/
public function toggleActive(Warehouse $warehouse): Warehouse
{
// No permitir desactivar el almacén principal
if ($warehouse->is_main && $warehouse->is_active) {
throw new \Exception('No se puede desactivar el almacén principal.');
}
$warehouse->update(['is_active' => !$warehouse->is_active]);
return $warehouse->fresh();
}
/**
* Obtener stock de un almacén
*/
public function getWarehouseStock(Warehouse $warehouse)
{
return $warehouse->inventories()
->with(['category', 'price'])
->select('inventories.*')
->get()
->map(function ($inventory) use ($warehouse) {
$pivot = $inventory->warehouses()
->where('warehouse_id', $warehouse->id)
->first();
return [
'inventory_id' => $inventory->id,
'name' => $inventory->name,
'sku' => $inventory->sku,
'category' => $inventory->category?->name,
'stock' => $pivot?->pivot->stock ?? 0,
'min_stock' => $pivot?->pivot->min_stock,
'max_stock' => $pivot?->pivot->max_stock,
'cost' => $inventory->price?->cost,
'retail_price' => $inventory->price?->retail_price,
];
});
}
/**
* Validar que existe almacén principal
*/
public function ensureMainWarehouse(): void
{
if (!Warehouse::main()->exists()) {
throw new \Exception('No existe un almacén principal configurado.');
}
}
}

View File

@ -0,0 +1,187 @@
<?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 string $token;
protected string $companyName;
protected string $email = 'juan.zapata@golsystems.com.mx';
public function __construct()
{
$this->apiUrl = config('services.whatsapp.api_url', 'https://whatsapp.golsystems.mx/api/send-whatsapp');
$this->orgId = config('services.whatsapp.org_id', 1);
$this->token = config('services.whatsapp.token');
$this->companyName = config('services.whatsapp.company_name', 'PDV');
if (!$this->token) {
throw new \Exception('El token de WhatsApp no está configurado. Agrega WHATSAPP_TOKEN en tu archivo .env');
}
}
/**
* Enviar documento por WhatsApp usando plantilla facturas_pdv
*/
public function sendDocument(
string $phoneNumber,
string $documentUrl,
string $filename,
string $userEmail,
string $ticket,
string $customerName
): array {
try {
// Construir el mensaje de WhatsApp usando plantilla
$whatsappMessage = json_encode([
'messaging_product' => 'whatsapp',
'to' => $this->cleanPhoneNumber($phoneNumber),
'type' => 'template',
'template' => [
'name' => 'facturas_pdv',
'language' => [
'code' => 'es_MX',
],
'components' => [
[
'type' => 'header',
'parameters' => [
[
'type' => 'document',
'document' => [
'filename' => $filename,
'link' => $documentUrl,
],
],
],
],
[
'type' => 'body',
'parameters' => [
[
'type' => 'text',
'parameter_name' => 'nombre_cliente',
'text' => $customerName,
],
[
'type' => 'text',
'parameter_name' => 'nombre_empresa',
'text' => $this->companyName,
],
[
'type' => 'text',
'parameter_name' => 'referencia_factura',
'text' => $ticket,
],
],
],
],
],
]);
// 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 ' . $this->token,
])
->post($this->apiUrl, $payload);
// Registrar en logs
Log::channel('single')->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('single')->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('single')->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 $invoiceNumber,
string $customerName
): array {
return $this->sendDocument(
phoneNumber: $phoneNumber,
documentUrl: $pdfUrl,
filename: "{$invoiceNumber}.pdf",
userEmail: $this->email,
ticket: $invoiceNumber,
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

@ -12,6 +12,7 @@
"laravel/pulse": "^1.4",
"laravel/reverb": "^1.4",
"laravel/tinker": "^2.10",
"maatwebsite/excel": "^3.1",
"notsoweb/laravel-core": "dev-main",
"spatie/laravel-permission": "^6.16",
"tightenco/ziggy": "^2.5"
@ -74,6 +75,10 @@
"@php artisan migrate:fresh --seeder=DevSeeder",
"@php artisan passport:client --personal --name=Holos"
],
"db:update": [
"@php artisan migrate",
"@php artisan migrate --path=database/migrations/seed"
],
"db:prod": [
"@php artisan migrate:fresh --seed",
"@php artisan passport:client --personal --name=Holos"

2576
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -35,4 +35,11 @@
],
],
'whatsapp' => [
'api_url' => env('WHATSAPP_API_URL', 'https://whatsapp.golsystems.mx/api/send-whatsapp'),
'org_id' => env('WHATSAPP_ORG_ID', 1),
'token' => env('WHATSAPP_TOKEN'),
'company_name' => env('APP_NAME', 'PDV'),
],
];

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categories');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('inventories', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->string('sku')->unique();
$table->integer('stock')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('inventories');
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('prices', function (Blueprint $table) {
$table->id();
$table->foreignId('inventory_id')->constrained()->onDelete('cascade');
$table->decimal('cost', 10, 2);
$table->decimal('retail_price', 10, 2);
$table->decimal('tax', 5, 2);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('prices');
}
};

Some files were not shown because too many files have changed in this diff Show More