277 lines
11 KiB
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;
|
|
}
|
|
}
|