add: devoluciones wip
This commit is contained in:
parent
2c8189ca59
commit
b9e694f6ca
1
.gitignore
vendored
1
.gitignore
vendored
@ -24,3 +24,4 @@ yarn-error.log
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
CLAUDE.md
|
||||
|
||||
136
app/Http/Controllers/App/ReturnController.php
Normal file
136
app/Http/Controllers/App/ReturnController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
152
app/Http/Requests/App/ReturnStoreRequest.php
Normal file
152
app/Http/Requests/App/ReturnStoreRequest.php
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
62
app/Models/ReturnDetail.php
Normal file
62
app/Models/ReturnDetail.php
Normal 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
76
app/Models/Returns.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
253
app/Services/ReturnService.php
Normal file
253
app/Services/ReturnService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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'");
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user