add: creación del modulo de tiers de clientes
This commit is contained in:
parent
5f0d4ec28e
commit
4357c560df
@ -4,10 +4,17 @@
|
||||
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();
|
||||
@ -52,14 +59,21 @@ public function store(Request $request)
|
||||
]);
|
||||
|
||||
try{
|
||||
|
||||
$client = Client::create($request->only([
|
||||
$data = $request->only([
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'rfc',
|
||||
]));
|
||||
]);
|
||||
|
||||
// Generar client_number automáticamente
|
||||
$data['client_number'] = $this->clientTierService->generateClientNumber();
|
||||
|
||||
$client = Client::create($data);
|
||||
|
||||
// Cargar relación tier
|
||||
$client->load('tier');
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'client' => $client,
|
||||
@ -124,4 +138,84 @@ public function destroy(Client $client)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
110
app/Http/Controllers/App/ClientTierController.php
Normal file
110
app/Http/Controllers/App/ClientTierController.php
Normal 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
|
||||
{
|
||||
/**
|
||||
* Display a listing of client tiers
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$tiers = ClientTier::withCount('clients')
|
||||
->orderBy('min_purchase_amount')
|
||||
->get();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tiers' => $tiers
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created tier
|
||||
*/
|
||||
public function store(ClientTierStoreRequest $request)
|
||||
{
|
||||
$tier = ClientTier::create($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier,
|
||||
'message' => 'Tier creado correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified tier
|
||||
*/
|
||||
public function show(ClientTier $tier)
|
||||
{
|
||||
$tier->loadCount('clients');
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified tier
|
||||
*/
|
||||
public function update(ClientTierUpdateRequest $request, ClientTier $tier)
|
||||
{
|
||||
$tier->update($request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier,
|
||||
'message' => 'Tier actualizado correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified tier (soft delete if has clients, else force delete)
|
||||
*/
|
||||
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.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle tier active status
|
||||
*/
|
||||
public function toggleActive(ClientTier $tier)
|
||||
{
|
||||
$tier->update(['is_active' => !$tier->is_active]);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tier' => $tier,
|
||||
'message' => 'Estado del tier actualizado correctamente.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active tiers only
|
||||
*/
|
||||
public function active()
|
||||
{
|
||||
$tiers = ClientTier::active()
|
||||
->orderBy('min_purchase_amount')
|
||||
->get();
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'tiers' => $tiers
|
||||
]);
|
||||
}
|
||||
}
|
||||
226
app/Http/Controllers/App/ExcelController.php
Normal file
226
app/Http/Controllers/App/ExcelController.php
Normal file
@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Client;
|
||||
use App\Models\Sale;
|
||||
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;
|
||||
|
||||
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->where('discount_amount', '>', 0)
|
||||
->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()
|
||||
->where('discount_amount', '>', 0)
|
||||
->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->setCellValue('C' . $row, $totalClientes);
|
||||
$sheet->setCellValue('E' . $row, 'TOTAL VENTAS:');
|
||||
$sheet->setCellValue('G' . $row, $totalVentas);
|
||||
$sheet->setCellValue('I' . $row, 'TOTAL DESCUENTOS:');
|
||||
$sheet->setCellValue('J' . $row, '$' . number_format($totalDescuentos, 2));
|
||||
|
||||
$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' => 'TIER',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
47
app/Http/Requests/ClientTiers/ClientTierStoreRequest.php
Normal file
47
app/Http/Requests/ClientTiers/ClientTierStoreRequest.php
Normal 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%',
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Http/Requests/ClientTiers/ClientTierUpdateRequest.php
Normal file
56
app/Http/Requests/ClientTiers/ClientTierUpdateRequest.php
Normal 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%',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ class CashRegister extends Model
|
||||
'cash_returns',
|
||||
'card_returns',
|
||||
'returns_count',
|
||||
'total_discounts',
|
||||
'notes',
|
||||
'status',
|
||||
];
|
||||
@ -35,6 +36,7 @@ class CashRegister extends Model
|
||||
'total_returns' => 'decimal:2',
|
||||
'cash_returns' => 'decimal:2',
|
||||
'card_returns' => 'decimal:2',
|
||||
'total_discounts' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function user()
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
<?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',
|
||||
@ -14,10 +17,54 @@ class Client extends Model
|
||||
'regimen_fiscal',
|
||||
'cp_fiscal',
|
||||
'uso_cfdi',
|
||||
'tier_id',
|
||||
'total_purchases',
|
||||
'total_transactions',
|
||||
'lifetime_returns',
|
||||
'last_purchase_at',
|
||||
];
|
||||
|
||||
public function sales()
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
76
app/Models/ClientTier.php
Normal file
76
app/Models/ClientTier.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
app/Models/ClientTierHistory.php
Normal file
51
app/Models/ClientTierHistory.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ class Returns extends Model
|
||||
'subtotal',
|
||||
'tax',
|
||||
'total',
|
||||
'discount_refund',
|
||||
'refund_method',
|
||||
'reason',
|
||||
'notes'
|
||||
@ -24,6 +25,7 @@ class Returns extends Model
|
||||
'subtotal' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'discount_refund' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
@ -23,6 +23,9 @@ class Sale extends Model
|
||||
'subtotal',
|
||||
'tax',
|
||||
'total',
|
||||
'discount_percentage',
|
||||
'discount_amount',
|
||||
'client_tier_name',
|
||||
'cash_received',
|
||||
'change',
|
||||
'payment_method',
|
||||
@ -33,6 +36,8 @@ class Sale extends Model
|
||||
'subtotal' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'discount_percentage' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'cash_received' => 'decimal:2',
|
||||
'change' => 'decimal:2',
|
||||
];
|
||||
|
||||
@ -22,11 +22,15 @@ class SaleDetail extends Model
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
'discount_percentage',
|
||||
'discount_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'discount_percentage' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function sale()
|
||||
|
||||
177
app/Services/ClientTierService.php
Normal file
177
app/Services/ClientTierService.php
Normal file
@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientTier;
|
||||
use App\Models\ClientTierHistory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClientTierService
|
||||
{
|
||||
/**
|
||||
* Generar número de cliente único secuencial
|
||||
*/
|
||||
public function generateClientNumber(): string
|
||||
{
|
||||
$lastClient = Client::orderBy('id', 'desc')->first();
|
||||
$nextNumber = $lastClient ? ((int) substr($lastClient->client_number, 4)) + 1 : 1;
|
||||
|
||||
return 'CLI-' . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Returns;
|
||||
use App\Models\ReturnDetail;
|
||||
use App\Models\Sale;
|
||||
@ -12,6 +13,12 @@
|
||||
|
||||
class ReturnService
|
||||
{
|
||||
protected ClientTierService $clientTierService;
|
||||
|
||||
public function __construct(ClientTierService $clientTierService)
|
||||
{
|
||||
$this->clientTierService = $clientTierService;
|
||||
}
|
||||
/**
|
||||
* Crear una nueva devolución con sus detalles
|
||||
*/
|
||||
@ -65,6 +72,13 @@ public function createReturn(array $data): Returns
|
||||
|
||||
$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,
|
||||
@ -74,6 +88,7 @@ public function createReturn(array $data): Returns
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'discount_refund' => $discountRefund,
|
||||
'refund_method' => $data['refund_method'] ?? $sale->payment_method,
|
||||
'reason' => $data['reason'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
@ -154,11 +169,41 @@ public function createReturn(array $data): Returns
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Retornar con relaciones cargadas
|
||||
// 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',
|
||||
'sale.client.tier',
|
||||
'user',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php namespace App\Services;
|
||||
|
||||
use App\Models\CashRegister;
|
||||
use App\Models\Client;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
use App\Models\Inventory;
|
||||
@ -9,6 +10,12 @@
|
||||
|
||||
class SaleService
|
||||
{
|
||||
protected ClientTierService $clientTierService;
|
||||
|
||||
public function __construct(ClientTierService $clientTierService)
|
||||
{
|
||||
$this->clientTierService = $clientTierService;
|
||||
}
|
||||
/**
|
||||
* Crear una nueva venta con sus detalles
|
||||
*
|
||||
@ -16,6 +23,26 @@ class SaleService
|
||||
public function createSale(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// Obtener cliente si existe
|
||||
$client = isset($data['client_id']) ? Client::find($data['client_id']) : null;
|
||||
|
||||
// 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 sobre el subtotal + tax
|
||||
$totalBeforeDiscount = $data['subtotal'] + $data['tax'];
|
||||
$discountAmount = round($totalBeforeDiscount * ($discountPercentage / 100), 2);
|
||||
|
||||
// Recalcular total con descuento
|
||||
$data['total'] = $totalBeforeDiscount - $discountAmount;
|
||||
}
|
||||
|
||||
// Calcular el cambio si es pago en efectivo
|
||||
$cashReceived = null;
|
||||
$change = null;
|
||||
@ -28,11 +55,15 @@ public function createSale(array $data)
|
||||
// 1. Crear la venta principal
|
||||
$sale = Sale::create([
|
||||
'user_id' => $data['user_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'],
|
||||
@ -41,6 +72,12 @@ public function createSale(array $data)
|
||||
|
||||
// 2. Crear los detalles de la venta y asignar seriales
|
||||
foreach ($data['items'] as $item) {
|
||||
// Calcular descuento por detalle si aplica
|
||||
$itemDiscountAmount = 0;
|
||||
if ($discountPercentage > 0) {
|
||||
$itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2);
|
||||
}
|
||||
|
||||
// Crear detalle de venta
|
||||
$saleDetail = SaleDetail::create([
|
||||
'sale_id' => $sale->id,
|
||||
@ -49,6 +86,8 @@ public function createSale(array $data)
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'subtotal' => $item['subtotal'],
|
||||
'discount_percentage' => $discountPercentage,
|
||||
'discount_amount' => $itemDiscountAmount,
|
||||
]);
|
||||
|
||||
// Obtener el inventario
|
||||
@ -96,8 +135,21 @@ public function createSale(array $data)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Retornar la venta con sus relaciones cargadas
|
||||
return $sale->load(['details.inventory', 'details.serials', 'user']);
|
||||
// 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']);
|
||||
});
|
||||
}
|
||||
|
||||
@ -133,10 +185,31 @@ public function cancelSale(Sale $sale)
|
||||
}
|
||||
}
|
||||
|
||||
// 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']);
|
||||
return $sale->fresh(['details.inventory', 'details.serials', 'user', 'client.tier']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?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('client_tiers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('tier_name');
|
||||
$table->decimal('min_purchase_amount', 10, 2);
|
||||
$table->decimal('max_purchase_amount', 10, 2)->nullable();
|
||||
$table->decimal('discount_percentage', 5, 2);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_tiers');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
<?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::table('clients', function (Blueprint $table) {
|
||||
$table->string('client_number')->unique()->after('name');
|
||||
$table->foreignId('tier_id')->nullable()->constrained('client_tiers')->after('email');
|
||||
$table->decimal('total_purchases', 15, 2)->default(0)->after('tier_id');
|
||||
$table->integer('total_transactions')->default(0)->after('total_purchases');
|
||||
$table->decimal('lifetime_returns', 10, 2)->default(0)->after('total_transactions');
|
||||
$table->timestamp('last_purchase_at')->nullable()->after('lifetime_returns');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('clients', function (Blueprint $table) {
|
||||
$table->dropForeign(['tier_id']);
|
||||
$table->dropColumn(['client_number', 'tier_id', 'total_purchases', 'total_transactions', 'lifetime_returns', 'last_purchase_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?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::table('sales', function (Blueprint $table) {
|
||||
$table->decimal('discount_percentage', 5, 2)->default(0)->after('total');
|
||||
$table->decimal('discount_amount', 10, 2)->default(0)->after('discount_percentage');
|
||||
$table->string('client_tier_name')->nullable()->after('discount_amount');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sales', function (Blueprint $table) {
|
||||
$table->dropColumn(['discount_percentage', 'discount_amount', 'client_tier_name']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?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::table('sale_details', function (Blueprint $table) {
|
||||
$table->decimal('discount_percentage', 5, 2)->default(0)->after('subtotal');
|
||||
$table->decimal('discount_amount', 10, 2)->default(0)->after('discount_percentage');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sale_details', function (Blueprint $table) {
|
||||
$table->dropColumn(['discount_percentage', 'discount_amount']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?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::table('returns', function (Blueprint $table) {
|
||||
$table->decimal('discount_refund', 10, 2)->default(0)->after('total');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('returns', function (Blueprint $table) {
|
||||
$table->dropColumn('discount_refund');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?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::table('cash_registers', function (Blueprint $table) {
|
||||
$table->decimal('total_discounts', 10, 2)->default(0)->after('returns_count');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cash_registers', function (Blueprint $table) {
|
||||
$table->dropColumn('total_discounts');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
<?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('client_tier_history', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('client_id')->constrained('clients');
|
||||
$table->foreignId('old_tier_id')->nullable()->constrained('client_tiers');
|
||||
$table->foreignId('new_tier_id')->constrained('client_tiers');
|
||||
$table->decimal('total_at_change', 10, 2);
|
||||
$table->text('reason')->nullable();
|
||||
$table->timestamp('changed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_tier_history');
|
||||
}
|
||||
};
|
||||
61
database/seeders/ClientTierSeeder.php
Normal file
61
database/seeders/ClientTierSeeder.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\ClientTier;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ClientTierSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tiers = [
|
||||
[
|
||||
'tier_name' => 'Bronce',
|
||||
'min_purchase_amount' => 0.00,
|
||||
'max_purchase_amount' => 10000.00,
|
||||
'discount_percentage' => 0.00,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'tier_name' => 'Plata',
|
||||
'min_purchase_amount' => 15000.00,
|
||||
'max_purchase_amount' => 20000.00,
|
||||
'discount_percentage' => 5.00,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'tier_name' => 'Oro',
|
||||
'min_purchase_amount' => 25000.00,
|
||||
'max_purchase_amount' => 40000.00,
|
||||
'discount_percentage' => 10.00,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'tier_name' => 'Platino',
|
||||
'min_purchase_amount' => 60000.00,
|
||||
'max_purchase_amount' => null, // Sin límite superior
|
||||
'discount_percentage' => 15.00,
|
||||
'is_active' => true,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($tiers as $tier) {
|
||||
ClientTier::create($tier);
|
||||
}
|
||||
|
||||
$this->command->info('Client tiers creados exitosamente');
|
||||
$this->command->table(
|
||||
['Tier', 'Desde', 'Hasta', 'Descuento'],
|
||||
collect($tiers)->map(fn($t) => [
|
||||
$t['tier_name'],
|
||||
'$' . number_format($t['min_purchase_amount'], 2),
|
||||
$t['max_purchase_amount'] ? '$' . number_format($t['max_purchase_amount'], 2) : 'Sin límite',
|
||||
$t['discount_percentage'] . '%',
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@
|
||||
use App\Http\Controllers\App\CashRegisterController;
|
||||
use App\Http\Controllers\App\CategoryController;
|
||||
use App\Http\Controllers\App\ClientController;
|
||||
use App\Http\Controllers\App\ClientTierController;
|
||||
use App\Http\Controllers\App\ExcelController;
|
||||
use App\Http\Controllers\App\InventoryController;
|
||||
use App\Http\Controllers\App\PriceController;
|
||||
use App\Http\Controllers\App\ReportController;
|
||||
@ -72,10 +74,29 @@
|
||||
Route::prefix('reports')->group(function () {
|
||||
Route::get('top-selling-product', [ReportController::class, 'topSellingProduct']);
|
||||
Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']);
|
||||
Route::get('client-discounts/excel', [ExcelController::class, 'clientDiscountsReport']);
|
||||
});
|
||||
|
||||
//CLIENTES
|
||||
Route::resource('clients', ClientController::class);
|
||||
|
||||
// ESTADÍSTICAS Y DESCUENTOS DE CLIENTES
|
||||
Route::prefix('clients')->group(function () {
|
||||
Route::get('/{client}/stats', [ClientController::class, 'stats']);
|
||||
Route::get('/{client}/tier-history', [ClientController::class, 'tierHistory']);
|
||||
Route::get('/{client}/sales-with-discounts', [ClientController::class, 'salesWithDiscounts']);
|
||||
});
|
||||
|
||||
// RANGOS DE CLIENTES
|
||||
Route::prefix('client-tiers')->group(function () {
|
||||
Route::get('/', [ClientTierController::class, 'index']);
|
||||
Route::get('/active', [ClientTierController::class, 'active']);
|
||||
Route::get('/{tier}', [ClientTierController::class, 'show']);
|
||||
Route::post('/', [ClientTierController::class, 'store']);
|
||||
Route::put('/{tier}', [ClientTierController::class, 'update']);
|
||||
Route::delete('/{tier}', [ClientTierController::class, 'destroy']);
|
||||
Route::patch('/{tier}/toggle-active', [ClientTierController::class, 'toggleActive']);
|
||||
});
|
||||
});
|
||||
|
||||
/** Rutas públicas */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user