pdv.backend/app/Services/ReturnService.php

277 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Models\Returns;
use App\Models\ReturnDetail;
use App\Models\Sale;
use App\Models\SaleDetail;
use App\Models\InventorySerial;
use App\Models\CashRegister;
use Illuminate\Support\Facades\DB;
class ReturnService
{
/**
* 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']);
// 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'],
]);
$inventory = $saleDetail->inventory;
if ($inventory->track_serials) {
// Validación de cantidad de seriales
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
if (count($item['serial_numbers']) != $item['quantity_returned']) {
throw new \Exception(
"La cantidad de seriales proporcionados no coincide con la cantidad a devolver " .
"para {$saleDetail->product_name}."
);
}
}
// 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();
} else {
$inventory->increment('stock', $item['quantity_returned']);
}
}
// 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) {
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 numérico (la devolución lo había incrementado)
$detail->inventory->decrement('stock', $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;
}
}