diff --git a/app/Http/Controllers/Netbien/CashCloseController.php b/app/Http/Controllers/Netbien/CashCloseController.php index 774e0e1..443c801 100644 --- a/app/Http/Controllers/Netbien/CashCloseController.php +++ b/app/Http/Controllers/Netbien/CashCloseController.php @@ -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(), + ]); + } + } } diff --git a/app/Http/Controllers/Netbien/SaleController.php b/app/Http/Controllers/Netbien/SaleController.php index 5f3bb4a..8785c3b 100644 --- a/app/Http/Controllers/Netbien/SaleController.php +++ b/app/Http/Controllers/Netbien/SaleController.php @@ -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(), diff --git a/app/Http/Controllers/Netbien/SimCardController.php b/app/Http/Controllers/Netbien/SimCardController.php index 6cc528b..aaa3c5c 100644 --- a/app/Http/Controllers/Netbien/SimCardController.php +++ b/app/Http/Controllers/Netbien/SimCardController.php @@ -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']++; } - $this->processPackageFromText($sim, $row); - // Asignar cliente - $this->assignToClient($sim, $row); + // 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); + } + 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; + } } diff --git a/app/Http/Requests/Netbien/SimCardUpdateRequest.php b/app/Http/Requests/Netbien/SimCardUpdateRequest.php index c865b9e..a889cfc 100644 --- a/app/Http/Requests/Netbien/SimCardUpdateRequest.php +++ b/app/Http/Requests/Netbien/SimCardUpdateRequest.php @@ -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'], ]; } diff --git a/app/Models/CashClose.php b/app/Models/CashClose.php index 3ca6cef..ec4ef20 100644 --- a/app/Models/CashClose.php +++ b/app/Models/CashClose.php @@ -1,9 +1,12 @@ 'decimal:2', 'opened_at' => 'datetime', 'closed_at' => 'datetime', + 'deleted_at' => 'datetime', ]; /** diff --git a/app/Services/CashCloseService.php b/app/Services/CashCloseService.php deleted file mode 100644 index 7a9f5f4..0000000 --- a/app/Services/CashCloseService.php +++ /dev/null @@ -1,33 +0,0 @@ -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; - } -} diff --git a/database/migrations/2025_11_23_173455_add_soft_deletes_and_type_to_cash_closes_table.php b/database/migrations/2025_11_23_173455_add_soft_deletes_and_type_to_cash_closes_table.php new file mode 100644 index 0000000..84150c9 --- /dev/null +++ b/database/migrations/2025_11_23_173455_add_soft_deletes_and_type_to_cash_closes_table.php @@ -0,0 +1,31 @@ +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']); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index e55450d..67312f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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 */