FEAT: Implementar nuevas funcionalidades para el manejo de cortes de caja y ventas

This commit is contained in:
Juan Felipe Zapata Moreno 2025-11-23 22:45:51 -06:00
parent 6e53e2da0f
commit 304a7c3958
8 changed files with 481 additions and 108 deletions

View File

@ -38,62 +38,6 @@ public function index(Request $request)
]);
}
public function closeCashClose(Request $request)
{
$request->validate([
'exit' => 'sometimes|numeric|min:0',
]);
$cashClose = CashClose::open()->first();
if (!$cashClose) {
return ApiResponse::NOT_FOUND->response([
'message' => 'No hay un corte de caja abierto para cerrar.',
]);
}
$totalSales = Sale::where('cash_close_id', $cashClose->id)->sum('total_amount');
$paymentMethods = Sale::where('cash_close_id', $cashClose->id)
->select('payment_method', DB::raw('SUM(total_amount) as total'))
->groupBy('payment_method')
->pluck('total', 'payment_method');
$exit = $request->input('exit', 0);
$cashClose->update([
'closed_at' => now(),
'income' => $totalSales,
'exit' => $exit,
'income_cash' => $paymentMethods->get('cash', 0),
'income_card' => $paymentMethods->get('card', 0),
'income_transfer' => $paymentMethods->get('transfer', 0),
'status' => 'closed',
]);
$balanceFinal = $cashClose->initial_balance + $totalSales - $exit;
return ApiResponse::OK->response([
'message' => 'Corte de caja cerrado exitosamente.',
'cash_close' => $cashClose->fresh('user'),
'resumen' => [
'periodo' => [
'apertura' => $cashClose->opened_at,
'cierre' => $cashClose->closed_at,
],
'totales' => [
'fondo_inicial' => $cashClose->initial_balance,
'total_ventas' => $totalSales,
'efectivo' => $paymentMethods->get('cash', 0),
'tarjeta' => $paymentMethods->get('card', 0),
'transferencia' => $paymentMethods->get('transfer', 0),
'egresos' => $exit,
'balance_final' => $balanceFinal,
],
],
]);
}
public function report(Request $request)
{
$request->validate([
@ -235,4 +179,225 @@ public function exportReport(Request $request)
$filename
);
}
/**
* Preview de ventas disponibles para crear corte por rango de fechas
*/
public function previewSalesRange(Request $request)
{
$request->validate([
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
]);
// Buscar ventas sin corte en el rango especificado
$availableSales = Sale::with([
'client:id,name,paternal,maternal',
'saleItems.simCard:id,iccid,msisdn',
'saleItems.package:id,name,price'
])
->whereNull('cash_close_id')
->whereBetween('sale_date', [$request->start_date, $request->end_date])
->orderBy('sale_date', 'asc')
->get();
if ($availableSales->isEmpty()) {
return ApiResponse::NOT_FOUND->response([
'message' => 'No se encontraron ventas disponibles en el rango de fechas especificado.',
'total_sales' => 0,
]);
}
// Calcular totales por método de pago
$paymentMethods = $availableSales->groupBy('payment_method')->map(function ($sales, $method) {
return [
'method' => $method,
'count' => $sales->count(),
'total' => $sales->sum('total_amount'),
];
})->values();
$totalAmount = $availableSales->sum('total_amount');
return ApiResponse::OK->response([
'message' => 'Ventas disponibles encontradas',
'periodo' => [
'start_date' => $request->start_date,
'end_date' => $request->end_date,
],
'resumen' => [
'total_ventas' => $availableSales->count(),
'monto_total' => $totalAmount,
'por_metodo_pago' => $paymentMethods,
],
'ventas' => $availableSales->map(function ($sale) {
return [
'id' => $sale->id,
'sale_date' => $sale->sale_date,
'client' => $sale->client->full_name ?? 'Sin cliente',
'payment_method' => $sale->payment_method,
'total_amount' => $sale->total_amount,
'items_count' => $sale->saleItems->count(),
];
}),
]);
}
/**
* Crear corte de caja desde un rango de fechas
*/
public function createFromRange(Request $request)
{
$request->validate([
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'initial_balance' => 'sometimes|numeric|min:0',
'exit' => 'sometimes|numeric|min:0',
'notes' => 'sometimes|string|max:1000',
]);
try {
DB::beginTransaction();
// Buscar ventas sin corte en el rango especificado
$availableSales = Sale::whereNull('cash_close_id')
->whereBetween('sale_date', [$request->start_date, $request->end_date])
->get();
if ($availableSales->isEmpty()) {
return ApiResponse::NOT_FOUND->response([
'message' => 'No se encontraron ventas disponibles para crear el corte de caja.',
]);
}
// Verificar que ninguna venta ya esté en otro corte (doble verificación)
$salesWithClose = Sale::whereBetween('sale_date', [$request->start_date, $request->end_date])
->whereNotNull('cash_close_id')
->with('cashClose:id,status,opened_at,closed_at')
->get();
if ($salesWithClose->isNotEmpty()) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Algunas ventas en este rango ya están asignadas a otros cortes.',
'conflicting_sales' => $salesWithClose->map(fn($s) => [
'sale_id' => $s->id,
'sale_date' => $s->sale_date,
'cash_close_id' => $s->cash_close_id,
'cash_close_status' => $s->cashClose->status ?? null,
]),
], 422);
}
// Calcular totales
$totalAmount = $availableSales->sum('total_amount');
$paymentMethods = $availableSales->groupBy('payment_method');
$incomeCash = $paymentMethods->get('cash', collect())->sum('total_amount');
$incomeCard = $paymentMethods->get('card', collect())->sum('total_amount');
$incomeTransfer = $paymentMethods->get('transfer', collect())->sum('total_amount');
// Crear el corte de caja
$cashClose = CashClose::create([
'user_id' => auth()->id(),
'opened_at' => $request->start_date,
'closed_at' => $request->end_date,
'initial_balance' => $request->input('initial_balance', 0),
'income' => $totalAmount,
'exit' => $request->input('exit', 0),
'income_cash' => $incomeCash,
'income_card' => $incomeCard,
'income_transfer' => $incomeTransfer,
'status' => 'closed',
'type' => 'manual',
'notes' => $request->input('notes'),
]);
// Asignar las ventas al corte creado
Sale::whereIn('id', $availableSales->pluck('id'))
->update(['cash_close_id' => $cashClose->id]);
DB::commit();
$balanceFinal = $cashClose->initial_balance + $totalAmount - $cashClose->exit;
return ApiResponse::CREATED->response([
'message' => 'Corte de caja creado exitosamente',
'cash_close' => $cashClose->fresh('user'),
'resumen' => [
'periodo' => [
'inicio' => $cashClose->opened_at,
'fin' => $cashClose->closed_at,
],
'totales' => [
'fondo_inicial' => $cashClose->initial_balance,
'total_ventas' => $totalAmount,
'cantidad_ventas' => $availableSales->count(),
'efectivo' => $incomeCash,
'tarjeta' => $incomeCard,
'transferencia' => $incomeTransfer,
'egresos' => $cashClose->exit,
'balance_final' => $balanceFinal,
],
],
]);
} catch (\Exception $e) {
DB::rollBack();
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al crear el corte de caja',
'error' => $e->getMessage(),
]);
}
}
/**
* Deshacer (eliminar) un corte de caja
* Las ventas se liberan automáticamente (cash_close_id = null)
*/
public function undoCashClose(CashClose $cashClose)
{
try {
DB::beginTransaction();
// Verificar que el corte existe
if (!$cashClose) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Corte de caja no encontrado.',
]);
}
// Obtener información antes de eliminar
$salesCount = $cashClose->sales()->count();
$cashCloseId = $cashClose->id;
$cashCloseType = $cashClose->type;
// Liberar las ventas (setear cash_close_id a null)
Sale::where('cash_close_id', $cashClose->id)
->update(['cash_close_id' => null]);
// Eliminar el corte (soft delete)
$cashClose->delete();
DB::commit();
return ApiResponse::OK->response([
'message' => 'Corte de caja eliminado exitosamente',
'info' => [
'cash_close_id' => $cashCloseId,
'type' => $cashCloseType,
'sales_liberated' => $salesCount,
'nota' => 'Las ventas han sido liberadas y están disponibles para ser asignadas a otro corte.',
],
]);
} catch (\Exception $e) {
DB::rollBack();
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al eliminar el corte de caja',
'error' => $e->getMessage(),
]);
}
}
}

View File

@ -11,7 +11,6 @@
use App\Enums\SimCardStatus;
use App\Http\Requests\Netbien\SaleStoreRequest;
use App\Http\Requests\Netbien\SaleUpdateRequest;
use App\Services\CashCloseService;
use Illuminate\Support\Facades\DB;
use Notsoweb\ApiResponse\Enums\ApiResponse;
@ -67,11 +66,9 @@ public function store(SaleStoreRequest $request)
$total += $package->price;
}
$cashClose = CashCloseService::getOrCreateOpenCashClose();
$sale = Sale::create([
'client_id' => $client->id,
'cash_close_id' => $cashClose->id,
'cash_close_id' => null,
'total_amount' => $total,
'payment_method' => $request->payment_method,
'sale_date' => now(),

View File

@ -13,6 +13,8 @@
use App\Models\Client;
use App\Models\ClientSim;
use App\Models\Packages;
use App\Models\Sale;
use App\Models\SaleItem;
use App\Enums\SimCardStatus;
use Illuminate\Http\Request;
use PhpOffice\PhpSpreadsheet\IOFactory;
@ -105,11 +107,13 @@ public function destroy(SimCard $simCard)
/* ---------------------Importar Excel--------------------------- */
private $packageCache = [];
private $columnMap = [];
private $stats = [
'created' => 0,
'assigned' => 0,
'packages_created' => 0,
'clients_created' => 0,
'sales_created' => 0,
'errors' => [],
];
@ -129,15 +133,20 @@ public function import(Request $request)
DB::beginTransaction();
try {
// Leer encabezados de la primera fila
$this->buildColumnMap($rows[0]);
foreach ($rows as $index => $row) {
if ($index === 0) continue;
if ($index === 0) continue; // Saltar encabezados
try {
$this->processRow([
'iccid' => $row[4] ?? null,
'msisdn' => $row[5] ?? null,
'estado_de_la_sim' => $row[9] ?? null,
'usuario' => $row[8] ?? null,
'iccid' => $this->getColumnValue($row, 'ICCID'),
'msisdn' => $this->getColumnValue($row, 'MSISDN'),
'paquetes' => $this->getColumnValue($row, 'PAQUETES'),
'usuario' => $this->getColumnValue($row, 'USUARIO'),
'fecha_venta' => $this->getColumnValue($row, 'FECHA_VENTA'),
'metodo_pago' => $this->getColumnValue($row, 'METODO_PAGO'),
], $index + 1);
} catch (\Exception $e) {
// Capturar información detallada del error antes de revertir
@ -149,10 +158,12 @@ public function import(Request $request)
'error' => $e->getMessage(),
'fila' => $index + 1,
'datos_fila' => [
'iccid' => $row[4] ?? null,
'msisdn' => $row[5] ?? null,
'estado_de_la_sim' => $row[9] ?? null,
'usuario' => $row[8] ?? null,
'iccid' => $this->getColumnValue($row, 'ICCID'),
'msisdn' => $this->getColumnValue($row, 'MSISDN'),
'paquetes' => $this->getColumnValue($row, 'PAQUETES'),
'usuario' => $this->getColumnValue($row, 'USUARIO'),
'fecha_venta' => $this->getColumnValue($row, 'FECHA_VENTA'),
'metodo_pago' => $this->getColumnValue($row, 'METODO_PAGO'),
],
'stats' => $this->stats,
], 500);
@ -204,14 +215,171 @@ private function processRow(array $row, int $rowNumber = 0)
$this->stats['created']++;
}
// Determinar si es una venta (tiene usuario Y paquete)
$hasUsuario = !empty($row['usuario']) &&
strtolower(trim($row['usuario'])) !== 'si' &&
strtolower(trim($row['usuario'])) !== 'no';
$hasPaquete = !empty($row['paquetes']);
if ($hasUsuario && $hasPaquete) {
// Es una venta - procesar como venta completa
$this->processSale($sim, $row);
} else {
// No es venta - solo asignar paquete y/o cliente si existen
if ($hasPaquete) {
$this->processPackageFromText($sim, $row);
// Asignar cliente
}
if ($hasUsuario) {
$this->assignToClient($sim, $row);
}
}
}
private function processSale(SimCard $sim, array $row)
{
// Parsear el paquete desde el texto
$estadoSim = trim($row['paquetes'] ?? '');
$packageInfo = $this->parsePackageText($estadoSim);
if (!$packageInfo) {
$this->stats['errors'][] = [
'iccid' => $sim->iccid,
'estado_sim' => $estadoSim,
'reason' => 'No se pudo parsear el paquete para la venta'
];
return;
}
// Obtener o crear el paquete
$package = $this->getOrCreatePackage(
$packageInfo['type'],
$packageInfo['price']
);
// Buscar o crear el cliente
$usuario = trim($row['usuario'] ?? '');
$client = Client::where('full_name', $usuario)->first()
?? Client::where('full_name', 'LIKE', "%{$usuario}%")->first()
?? Client::where(function ($query) use ($usuario) {
$query->whereRaw("CONCAT(name, ' ', IFNULL(paternal,''), ' ', IFNULL(maternal,'')) LIKE ?", ["%{$usuario}%"]);
})->first();
if (!$client) {
$nameParts = $this->splitFullName($usuario);
$client = Client::create([
'full_name' => $usuario,
'name' => $nameParts['name'],
'paternal' => $nameParts['paternal'],
'maternal' => $nameParts['maternal'],
]);
$this->stats['clients_created']++;
}
// Parsear fecha de venta
$saleDate = $this->parseSaleDate($row['fecha_venta'] ?? null);
// Validar y normalizar método de pago
$paymentMethod = $this->normalizePaymentMethod($row['metodo_pago'] ?? null);
// Crear la venta
$sale = Sale::create([
'client_id' => $client->id,
'cash_close_id' => null, // Se dejará null para importaciones
'total_amount' => $package->price,
'payment_method' => $paymentMethod,
'sale_date' => $saleDate,
]);
// Crear el item de venta
SaleItem::create([
'sale_id' => $sale->id,
'sim_card_id' => $sim->id,
'package_id' => $package->id,
]);
// Asignar SIM al cliente
$existingRelation = ClientSim::where('client_id', $client->id)
->where('sim_card_id', $sim->id)
->where('is_active', true)
->exists();
if (!$existingRelation) {
ClientSim::create([
'client_id' => $client->id,
'sim_card_id' => $sim->id,
'assigned_at' => $saleDate,
'is_active' => true,
]);
}
// Asignar paquete a la SIM
$sim->packages()->attach($package->id, [
'activated_at' => $saleDate,
'is_active' => true,
]);
// Actualizar status de la SIM
$sim->update(['status' => SimCardStatus::ASSIGNED]);
$this->stats['sales_created']++;
$this->stats['assigned']++;
}
private function parseSaleDate($dateValue)
{
if (empty($dateValue)) {
return now();
}
if (is_numeric($dateValue)) {
try {
$excelBaseDate = new \DateTime('1899-12-30');
$excelBaseDate->modify("+{$dateValue} days");
return $excelBaseDate->format('Y-m-d H:i:s');
} catch (\Exception $e) {
return now();
}
}
// Intentar parsear como fecha
try {
return \Carbon\Carbon::parse($dateValue)->format('Y-m-d H:i:s');
} catch (\Exception $e) {
return now();
}
}
private function normalizePaymentMethod($method)
{
if (empty($method)) {
return 'import';
}
$method = strtolower(trim($method));
// Mapear variaciones comunes
$methodMap = [
'cash' => 'cash',
'efectivo' => 'cash',
'cash' => 'cash',
'card' => 'card',
'tarjeta' => 'card',
'credito' => 'card',
'debito' => 'card',
'transfer' => 'transfer',
'transferencia' => 'transfer',
'import' => 'import',
'importacion' => 'import',
];
return $methodMap[$method] ?? 'import';
}
private function processPackageFromText(SimCard $sim, array $row)
{
$estadoSim = trim($row['estado_de_la_sim'] ?? '');
$estadoSim = trim($row['paquetes'] ?? '');
if (empty($estadoSim)) {
return;
@ -343,4 +511,38 @@ private function splitFullName(string $fullName): array
'maternal' => $parts[2] ?? '',
];
}
/**
* Construye un mapa de nombres de columnas a índices
*/
private function buildColumnMap(array $headers)
{
$this->columnMap = [];
foreach ($headers as $index => $header) {
// Normalizar el nombre de la columna (mayúsculas, sin espacios extra)
$normalizedHeader = strtoupper(trim($header ?? ''));
if (!empty($normalizedHeader)) {
$this->columnMap[$normalizedHeader] = $index;
}
}
}
/**
* Obtiene el valor de una columna por su nombre
*/
private function getColumnValue(array $row, string $columnName)
{
$normalizedName = strtoupper(trim($columnName));
// Buscar en el mapa de columnas
if (isset($this->columnMap[$normalizedName])) {
$index = $this->columnMap[$normalizedName];
return $row[$index] ?? null;
}
// Si no se encuentra la columna, retornar null
return null;
}
}

View File

@ -27,7 +27,9 @@ public function authorize(): bool
public function rules(): array
{
return [
'msisdn' => ['required', 'string', 'max:15', 'unique:sim_cards,msisdn'],
'iccid' => ['sometimes', 'string', 'max:20'],
'msisdn' => ['required', 'string', 'max:11'],
'status' => ['sometimes', 'string', 'in:available,assigned'],
];
}

View File

@ -1,9 +1,12 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CashClose extends Model
{
use SoftDeletes;
protected $fillable = [
'user_id',
'opened_at',
@ -15,6 +18,8 @@ class CashClose extends Model
'income_card',
'income_transfer',
'status',
'type',
'notes',
];
protected $casts = [
@ -26,6 +31,7 @@ class CashClose extends Model
'income_transfer' => 'decimal:2',
'opened_at' => 'datetime',
'closed_at' => 'datetime',
'deleted_at' => 'datetime',
];
/**

View File

@ -1,33 +0,0 @@
<?php
namespace App\Services;
use App\Models\CashClose;
use Illuminate\Support\Facades\Auth;
class CashCloseService
{
/**
* Obtiene el corte de caja abierto del día o crea uno nuevo
*/
public static function getOrCreateOpenCashClose()
{
$cashClose = CashClose::open()->first();
if (!$cashClose) {
$cashClose = CashClose::create([
'user_id' => Auth::id(),
'opened_at' => now(),
'initial_balance' => 0,
'income' => 0,
'exit' => 0,
'income_cash' => 0,
'income_card' => 0,
'income_transfer' => 0,
'status' => 'open',
]);
}
return $cashClose;
}
}

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::table('cash_closes', function (Blueprint $table) {
$table->softDeletes()->after('updated_at');
$table->enum('type', ['daily', 'manual', 'import', 'adjustment'])->default('daily')->after('status');
$table->text('notes')->nullable()->after('type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cash_closes', function (Blueprint $table) {
$table->dropSoftDeletes();
$table->dropColumn(['type', 'notes']);
});
}
};

View File

@ -36,9 +36,12 @@
Route::resource('sales', SaleController::class);
Route::get('cash-closes', [CashCloseController::class, 'index']);
Route::put('cash-closes/close', [CashCloseController::class, 'closeCashClose']);
Route::get('cash-closes/report', [CashCloseController::class, 'report']);
Route::get('cash-closes/export', [CashCloseController::class, 'exportReport']);
Route::get('cash-closes/preview-range', [CashCloseController::class, 'previewSalesRange']);
Route::post('cash-closes/create-from-range', [CashCloseController::class, 'createFromRange']);
Route::delete('cash-closes/{cashClose}/undo', [CashCloseController::class, 'undoCashClose']);
});
/** Rutas públicas */