pdv.backend/app/Services/ReturnService.php

345 lines
14 KiB
PHP

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