diff --git a/.gitignore b/.gitignore index 8a0ab32..a5aeea2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ yarn-error.log /.nova /.vscode /.zed +CLAUDE.md diff --git a/app/Http/Controllers/App/ReturnController.php b/app/Http/Controllers/App/ReturnController.php new file mode 100644 index 0000000..5dfca5c --- /dev/null +++ b/app/Http/Controllers/App/ReturnController.php @@ -0,0 +1,136 @@ +orderBy('created_at', 'desc'); + + // 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', + ]); + + 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) + { + 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(), + ]); + } + } +} diff --git a/app/Http/Requests/App/ReturnStoreRequest.php b/app/Http/Requests/App/ReturnStoreRequest.php new file mode 100644 index 0000000..947af00 --- /dev/null +++ b/app/Http/Requests/App/ReturnStoreRequest.php @@ -0,0 +1,152 @@ + ['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'], + ]; + } + + 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 si fueron especificados + if (isset($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.' + ); + } + } + }); + } +} diff --git a/app/Models/CashRegister.php b/app/Models/CashRegister.php index 34fc908..fec2d5b 100644 --- a/app/Models/CashRegister.php +++ b/app/Models/CashRegister.php @@ -16,6 +16,10 @@ class CashRegister extends Model 'difference', 'total_sales', 'sales_count', + 'total_returns', + 'cash_returns', + 'card_returns', + 'returns_count', 'notes', 'status', ]; @@ -28,6 +32,9 @@ class CashRegister extends Model 'expected_cash' => 'decimal:2', 'difference' => 'decimal:2', 'total_sales' => 'decimal:2', + 'total_returns' => 'decimal:2', + 'cash_returns' => 'decimal:2', + 'card_returns' => 'decimal:2', ]; public function user() @@ -40,6 +47,11 @@ 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 */ diff --git a/app/Models/InventorySerial.php b/app/Models/InventorySerial.php index 1410154..a7d4ec3 100644 --- a/app/Models/InventorySerial.php +++ b/app/Models/InventorySerial.php @@ -15,6 +15,7 @@ class InventorySerial extends Model 'serial_number', 'status', 'sale_detail_id', + 'return_detail_id', 'notes', ]; @@ -32,6 +33,11 @@ public function saleDetail() return $this->belongsTo(SaleDetail::class); } + public function returnDetail() + { + return $this->belongsTo(ReturnDetail::class); + } + /** * Verificar si el serial está disponible */ @@ -61,4 +67,35 @@ public function markAsAvailable(): void '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, + 'return_detail_id' => null, + ]); + } + + /** + * Verificar si el serial está devuelto + */ + public function isReturned(): bool + { + return $this->status === 'devuelto'; + } } diff --git a/app/Models/ReturnDetail.php b/app/Models/ReturnDetail.php new file mode 100644 index 0000000..61f01bc --- /dev/null +++ b/app/Models/ReturnDetail.php @@ -0,0 +1,62 @@ + 'decimal:2', + 'subtotal' => 'decimal:2', + 'quantity_returned' => 'integer', + ]; + + /** + * 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(); + } +} diff --git a/app/Models/Returns.php b/app/Models/Returns.php new file mode 100644 index 0000000..d7dc7c7 --- /dev/null +++ b/app/Models/Returns.php @@ -0,0 +1,76 @@ + 'decimal:2', + 'tax' => 'decimal:2', + 'total' => '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', + }; + } +} diff --git a/app/Models/Sale.php b/app/Models/Sale.php index 8febc95..ecc3a8a 100644 --- a/app/Models/Sale.php +++ b/app/Models/Sale.php @@ -56,4 +56,36 @@ 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()); + } } diff --git a/app/Models/SaleDetail.php b/app/Models/SaleDetail.php index 0972e5f..7d72f36 100644 --- a/app/Models/SaleDetail.php +++ b/app/Models/SaleDetail.php @@ -51,4 +51,44 @@ 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(); + } } diff --git a/app/Services/CashRegisterService.php b/app/Services/CashRegisterService.php index 3abd564..bce9b3b 100644 --- a/app/Services/CashRegisterService.php +++ b/app/Services/CashRegisterService.php @@ -4,6 +4,7 @@ use App\Models\CashRegister; use App\Models\Sale; +use App\Models\Returns; class CashRegisterService { @@ -38,19 +39,29 @@ public function closeRegister(CashRegister $register, array $data) ->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 - $expectedCash = $register->initial_cash + $cashSales; + // Efectivo esperado (ajustado por devoluciones) + $expectedCash = $register->initial_cash + $cashSales - $cashReturns; // Diferencia (sobrante o faltante) $difference = $data['final_cash'] - $expectedCash; @@ -66,6 +77,13 @@ public function closeRegister(CashRegister $register, array $data) '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', ]); @@ -82,12 +100,19 @@ public function getCurrentSummary(CashRegister $register) ->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'); @@ -110,12 +135,21 @@ public function getCurrentSummary(CashRegister $register) '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 - 'expected_cash' => (float) ($register->initial_cash + $cashSales) + // 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), ]; } } diff --git a/app/Services/ReturnService.php b/app/Services/ReturnService.php new file mode 100644 index 0000000..db1909f --- /dev/null +++ b/app/Services/ReturnService.php @@ -0,0 +1,253 @@ +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']); + + // 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 ($item['quantity_returned'] > $maxReturnable) { + throw new \Exception( + "Solo puedes devolver {$maxReturnable} unidades de {$saleDetail->product_name}. " . + "Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas." + ); + } + + $itemSubtotal = $saleDetail->unit_price * $item['quantity_returned']; + $subtotal += $itemSubtotal; + } + + // Calcular impuesto proporcional + if ($sale->subtotal > 0) { + $taxRate = $sale->tax / $sale->subtotal; + $tax = $subtotal * $taxRate; + } + + $total = $subtotal + $tax; + + // 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, + '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']); + + $returnDetail = ReturnDetail::create([ + 'return_id' => $return->id, + 'sale_detail_id' => $saleDetail->id, + 'inventory_id' => $saleDetail->inventory_id, + 'product_name' => $saleDetail->product_name, + 'quantity_returned' => $item['quantity_returned'], + 'unit_price' => $saleDetail->unit_price, + 'subtotal' => $saleDetail->unit_price * $item['quantity_returned'], + ]); + + // Gestionar seriales + if (isset($item['serial_numbers']) && is_array($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($item['quantity_returned']) + ->get(); + + if ($serials->count() < $item['quantity_returned']) { + 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(); + } + + // 5. Retornar con relaciones cargadas + return $return->load([ + 'details.inventory', + 'details.serials', + 'sale', + '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) { + $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(); + } + + // 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) { + // 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; + } +} diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 09a730a..a14d7c0 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -108,6 +108,11 @@ public function cancelSale(Sale $sale) 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) { $serials = InventorySerial::where('sale_detail_id', $detail->id)->get(); diff --git a/database/migrations/2026_01_25_163037_create_returns_table.php b/database/migrations/2026_01_25_163037_create_returns_table.php new file mode 100644 index 0000000..b0ea0ce --- /dev/null +++ b/database/migrations/2026_01_25_163037_create_returns_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('sale_id')->constrained()->onDelete('restrict'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('cash_register_id')->nullable()->constrained()->onDelete('restrict'); + $table->string('return_number')->unique(); + $table->decimal('subtotal', 10, 2); + $table->decimal('tax', 10, 2); + $table->decimal('total', 10, 2); + $table->enum('refund_method', ['cash', 'credit_card', 'debit_card']); + $table->enum('reason', ['defective', 'wrong_product', 'change_of_mind', 'damaged', 'other']); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + // Índices para optimizar consultas + $table->index('sale_id'); + $table->index('return_number'); + $table->index('cash_register_id'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('returns'); + } +}; diff --git a/database/migrations/2026_01_25_163051_create_return_details_table.php b/database/migrations/2026_01_25_163051_create_return_details_table.php new file mode 100644 index 0000000..c72f15f --- /dev/null +++ b/database/migrations/2026_01_25_163051_create_return_details_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('return_id')->constrained()->onDelete('cascade'); + $table->foreignId('sale_detail_id')->constrained()->onDelete('restrict'); + $table->foreignId('inventory_id')->constrained()->onDelete('restrict'); + $table->string('product_name'); + $table->integer('quantity_returned'); + $table->decimal('unit_price', 10, 2); + $table->decimal('subtotal', 10, 2); + $table->timestamps(); + + // Índices + $table->index('return_id'); + $table->index('sale_detail_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('return_details'); + } +}; diff --git a/database/migrations/2026_01_25_163115_add_return_detail_id_to_inventory_serials_table.php b/database/migrations/2026_01_25_163115_add_return_detail_id_to_inventory_serials_table.php new file mode 100644 index 0000000..45f30b2 --- /dev/null +++ b/database/migrations/2026_01_25_163115_add_return_detail_id_to_inventory_serials_table.php @@ -0,0 +1,41 @@ +foreignId('return_detail_id') + ->nullable() + ->after('sale_detail_id') + ->constrained()->onDelete('set null'); + + $table->index('return_detail_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_serials', function (Blueprint $table) { + $table->dropForeign(['return_detail_id']); + $table->dropIndex(['return_detail_id']); + $table->dropColumn('return_detail_id'); + }); + + DB::statement("ALTER TABLE inventory_serials MODIFY COLUMN status ENUM('disponible', 'vendido') NOT NULL DEFAULT 'disponible'"); + } +}; diff --git a/database/migrations/2026_01_25_163148_add_returns_tracking_to_cash_registers_table.php b/database/migrations/2026_01_25_163148_add_returns_tracking_to_cash_registers_table.php new file mode 100644 index 0000000..a2a57a6 --- /dev/null +++ b/database/migrations/2026_01_25_163148_add_returns_tracking_to_cash_registers_table.php @@ -0,0 +1,44 @@ +decimal('cash_sales', 10, 2)->default(0)->after('total_sales'); + $table->decimal('card_sales', 10, 2)->default(0)->after('cash_sales'); + $table->decimal('total_cash_received', 10, 2)->default(0)->after('card_sales'); + $table->decimal('total_change_given', 10, 2)->default(0)->after('total_cash_received'); + $table->decimal('total_returns', 10, 2)->default(0)->after('total_change_given'); + $table->decimal('cash_returns', 10, 2)->default(0)->after('total_returns'); + $table->decimal('card_returns', 10, 2)->default(0)->after('cash_returns'); + $table->integer('returns_count')->default(0)->after('card_returns'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cash_registers', function (Blueprint $table) { + $table->dropColumn([ + 'cash_sales', + 'card_sales', + 'total_cash_received', + 'total_change_given', + 'total_returns', + 'cash_returns', + 'card_returns', + 'returns_count', + ]); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 38b367d..5d1d17b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\App\PriceController; use App\Http\Controllers\App\ReportController; use App\Http\Controllers\App\SaleController; +use App\Http\Controllers\App\ReturnController; use App\Http\Controllers\App\FacturaDataController; use App\Http\Controllers\App\InventorySerialController; use Illuminate\Support\Facades\Route; @@ -47,6 +48,17 @@ Route::resource('/sales', SaleController::class); Route::put('/sales/{sale}/cancel', [SaleController::class, 'cancel']); + // DEVOLUCIONES + Route::prefix('returns')->group(function () { + Route::get('/', [ReturnController::class, 'index']); + Route::get('/{return}', [ReturnController::class, 'show']); + Route::post('/', [ReturnController::class, 'store']); + Route::put('/{return}/cancel', [ReturnController::class, 'cancel']); + }); + + // Items que pueden ser devueltos de una venta + Route::get('/sales/{sale}/returnable', [ReturnController::class, 'returnable']); + // Rutas de caja Route::prefix('cash-registers')->group(function () { Route::get('/', [CashRegisterController::class, 'index']);