add: devoluciones wip

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-25 22:17:16 -06:00
parent 2c8189ca59
commit b9e694f6ca
17 changed files with 1023 additions and 4 deletions

1
.gitignore vendored
View File

@ -24,3 +24,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
CLAUDE.md

View File

@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\ReturnStoreRequest;
use App\Services\ReturnService;
use App\Models\Returns;
use App\Models\Sale;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ReturnController extends Controller
{
public function __construct(
protected ReturnService $returnService
) {}
/**
* Listar devoluciones
*/
public function index(Request $request)
{
$query = Returns::with([
'sale',
'user',
'cashRegister',
'details.inventory',
])->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(),
]);
}
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use App\Models\Sale;
use App\Models\SaleDetail;
use App\Models\ReturnDetail;
use App\Models\InventorySerial;
class ReturnStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'sale_id' => ['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.'
);
}
}
});
}
}

View File

@ -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
*/

View File

@ -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';
}
}

View File

@ -0,0 +1,62 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ReturnDetail extends Model
{
protected $fillable = [
'return_id',
'sale_detail_id',
'inventory_id',
'product_name',
'quantity_returned',
'unit_price',
'subtotal',
];
protected $casts = [
'unit_price' => '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();
}
}

76
app/Models/Returns.php Normal file
View File

@ -0,0 +1,76 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Returns extends Model
{
use SoftDeletes;
protected $fillable = [
'sale_id',
'user_id',
'cash_register_id',
'return_number',
'subtotal',
'tax',
'total',
'refund_method',
'reason',
'notes'
];
protected $casts = [
'subtotal' => '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',
};
}
}

View File

@ -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());
}
}

View File

@ -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();
}
}

View File

@ -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),
];
}
}

View File

@ -0,0 +1,253 @@
<?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'],
]);
// 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;
}
}

View File

@ -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();

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('returns', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('return_details', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Agregar nuevo estado 'devuelto' al enum status
DB::statement("ALTER TABLE inventory_serials MODIFY COLUMN status ENUM('disponible', 'vendido', 'devuelto') NOT NULL DEFAULT 'disponible'");
Schema::table('inventory_serials', function (Blueprint $table) {
$table->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'");
}
};

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cash_registers', function (Blueprint $table) {
$table->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',
]);
});
}
};

View File

@ -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']);