pdv.backend/app/Services/SaleService.php
Juan Felipe Zapata Moreno 5a646d84d5 Refactorizar gestión de inventario a sistema multi-almacén
- Migrar manejo de stock de  a .
- Implementar  para centralizar lógica de entradas, salidas y transferencias.
- Añadir  (CRUD) y Requests de validación.
- Actualizar reportes, cálculos de valor y migraciones para la nueva estructura.
- Agregar campo  para rastreo de movimientos.
2026-02-05 23:59:35 -06:00

248 lines
10 KiB
PHP

<?php namespace App\Services;
use App\Models\CashRegister;
use App\Models\Client;
use App\Models\Sale;
use App\Models\SaleDetail;
use App\Models\Inventory;
use App\Models\InventorySerial;
use Illuminate\Support\Facades\DB;
class SaleService
{
public function __construct(
protected ClientTierService $clientTierService,
protected InventoryMovementService $movementService
) {}
/**
* Crear una nueva venta con sus detalles
*
*/
public function createSale(array $data)
{
return DB::transaction(function () use ($data) {
// Obtener cliente si existe
$client = null;
if (isset($data['client_id'])) {
$client = Client::find($data['client_id']);
} elseif (isset($data['client_number'])) {
$client = Client::where('client_number', $data['client_number'])->first();
}
// Calcular descuento si el cliente tiene tier
$discountPercentage = 0;
$discountAmount = 0;
$clientTierName = null;
if ($client && $client->tier) {
$discountPercentage = $this->clientTierService->getApplicableDiscount($client);
$clientTierName = $client->tier->tier_name;
// Calcular descuento solo sobre el subtotal (sin IVA)
$discountAmount = round($data['subtotal'] * ($discountPercentage / 100), 2);
// Recalcular total: subtotal - descuento + IVA
$data['total'] = ($data['subtotal'] - $discountAmount) + $data['tax'];
}
// Calcular el cambio si es pago en efectivo
$cashReceived = null;
$change = null;
if ($data['payment_method'] === 'cash' && isset($data['cash_received'])) {
$cashReceived = $data['cash_received'];
$change = $cashReceived - $data['total'];
}
// 1. Crear la venta principal
$sale = Sale::create([
'user_id' => $data['user_id'],
'client_id' => $client?->id ?? $data['client_id'] ?? null,
'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']),
'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(),
'subtotal' => $data['subtotal'],
'tax' => $data['tax'],
'total' => $data['total'],
'discount_percentage' => $discountPercentage,
'discount_amount' => $discountAmount,
'client_tier_name' => $clientTierName,
'cash_received' => $cashReceived,
'change' => $change,
'payment_method' => $data['payment_method'],
'status' => $data['status'] ?? 'completed',
]);
// 2. Crear los detalles de la venta y asignar seriales
foreach ($data['items'] as $item) {
// Calcular descuento por detalle si aplica
$itemDiscountAmount = 0;
if ($discountPercentage > 0) {
$itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2);
}
// Crear detalle de venta
$saleDetail = SaleDetail::create([
'sale_id' => $sale->id,
'inventory_id' => $item['inventory_id'],
'product_name' => $item['product_name'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'subtotal' => $item['subtotal'],
'discount_percentage' => $discountPercentage,
'discount_amount' => $itemDiscountAmount,
]);
// Obtener el inventario
$inventory = Inventory::find($item['inventory_id']);
if ($inventory->track_serials) {
// Si se proporcionaron números de serie específicos
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
foreach ($item['serial_numbers'] as $serialNumber) {
$serial = InventorySerial::where('inventory_id', $inventory->id)
->where('serial_number', $serialNumber)
->where('status', 'disponible')
->first();
if ($serial) {
$serial->markAsSold($saleDetail->id);
} else {
throw new \Exception("Serial {$serialNumber} no disponible");
}
}
} else {
// Asignar automáticamente los primeros N seriales disponibles
$serials = InventorySerial::where('inventory_id', $inventory->id)
->where('status', 'disponible')
->limit($item['quantity'])
->get();
if ($serials->count() < $item['quantity']) {
throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}");
}
foreach ($serials as $serial) {
$serial->markAsSold($saleDetail->id);
}
}
// Sincronizar el stock
$inventory->syncStock();
} else {
// Obtener almacén (del item o el principal)
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
$this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']);
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$item['quantity']);
}
}
// 3. Actualizar estadísticas del cliente si existe
if ($client && $sale->status === 'completed') {
$this->clientTierService->updateClientPurchaseStats($client, $sale->total);
}
// 4. Actualizar cash register con descuentos
if ($sale->cash_register_id && $discountAmount > 0) {
$cashRegister = CashRegister::find($sale->cash_register_id);
if ($cashRegister) {
$cashRegister->increment('total_discounts', $discountAmount);
}
}
// 5. Retornar la venta con sus relaciones cargadas
return $sale->load(['details.inventory', 'details.serials', 'user', 'client.tier']);
});
}
/**
* Cancelar una venta y restaurar el stock
*
*/
public function cancelSale(Sale $sale)
{
return DB::transaction(function () use ($sale) {
// Verificar que la venta esté completada
if ($sale->status !== 'completed') {
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) {
if ($detail->inventory->track_serials) {
// Restaurar seriales
$serials = InventorySerial::where('sale_detail_id', $detail->id)->get();
foreach ($serials as $serial) {
$serial->markAsAvailable();
}
$detail->inventory->syncStock();
} else {
// Restaurar stock en el almacén
$warehouseId = $detail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, $detail->quantity);
}
}
// Revertir estadísticas del cliente si existe
if ($sale->client_id) {
$client = Client::find($sale->client_id);
if ($client) {
// Restar de total_purchases y decrementar transacciones
$client->decrement('total_purchases', $sale->total);
$client->decrement('total_transactions', 1);
// Recalcular tier después de cancelación
$this->clientTierService->recalculateClientTier($client);
}
}
// Revertir descuentos del cash register
if ($sale->cash_register_id && $sale->discount_amount > 0) {
$cashRegister = CashRegister::find($sale->cash_register_id);
if ($cashRegister) {
$cashRegister->decrement('total_discounts', $sale->discount_amount);
}
}
// Marcar venta como cancelada
$sale->update(['status' => 'cancelled']);
return $sale->fresh(['details.inventory', 'details.serials', 'user', 'client.tier']);
});
}
/**
* Generar número de factura único
* Formato: INV-YYYYMMDD-0001
*/
private function generateInvoiceNumber(): string
{
$prefix = 'INV-';
$date = now()->format('Ymd');
// Obtener la última venta del día
$lastSale = Sale::whereDate('created_at', today())
->orderBy('id', 'desc')
->first();
// Incrementar secuencial
$sequential = $lastSale ? (intval(substr($lastSale->invoice_number, -4)) + 1) : 1;
return $prefix . $date . '-' . str_pad($sequential, 4, '0', STR_PAD_LEFT);
}
private function getCurrentCashRegister($userId)
{
$register = CashRegister::where('user_id', $userId)
->where('status', 'open')
->first();
return $register ? $register->id : null;
}
}