426 lines
18 KiB
PHP
426 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Bundle;
|
|
use App\Models\CashRegister;
|
|
use App\Models\Client;
|
|
use App\Models\Inventory;
|
|
use App\Models\InventorySerial;
|
|
use App\Models\Sale;
|
|
use App\Models\SaleDetail;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
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. Expandir bundles en componentes individuales
|
|
$expandedItems = $this->expandBundlesIntoComponents($data['items']);
|
|
|
|
// 2.1. Validar stock de TODOS los items (componentes + productos normales)
|
|
$this->validateStockForAllItems($expandedItems);
|
|
|
|
// 3. Crear los detalles de la venta y asignar seriales
|
|
foreach ($expandedItems as $item) {
|
|
// Calcular descuento por detalle si aplica
|
|
$itemDiscountAmount = 0;
|
|
if ($discountPercentage > 0) {
|
|
$itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2);
|
|
}
|
|
|
|
// Obtener el inventario para conversión de unidades
|
|
$inventory = Inventory::find($item['inventory_id']);
|
|
|
|
// Conversión de equivalencia de unidades
|
|
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
|
|
$inputQuantity = (float) $item['quantity'];
|
|
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
|
|
|
|
if ($usesEquivalence && $inventory->track_serials) {
|
|
throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name}).");
|
|
}
|
|
|
|
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
|
|
|
|
// Crear detalle de venta
|
|
$saleDetail = SaleDetail::create([
|
|
'sale_id' => $sale->id,
|
|
'inventory_id' => $item['inventory_id'],
|
|
'bundle_id' => $item['bundle_id'] ?? null,
|
|
'bundle_sale_group' => $item['bundle_sale_group'] ?? null,
|
|
'warehouse_id' => $item['warehouse_id'] ?? null,
|
|
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
|
|
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
|
|
'product_name' => $item['product_name'],
|
|
'quantity' => $baseQuantity,
|
|
'unit_price' => $item['unit_price'],
|
|
'subtotal' => $item['subtotal'],
|
|
'discount_percentage' => $discountPercentage,
|
|
'discount_amount' => $itemDiscountAmount,
|
|
]);
|
|
|
|
if ($inventory->track_serials) {
|
|
$serialWarehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
|
|
|
|
// 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('warehouse_id', $serialWarehouseId)
|
|
->where('status', 'disponible')
|
|
->first();
|
|
|
|
if ($serial) {
|
|
$serial->markAsSold($saleDetail->id, $serialWarehouseId);
|
|
} else {
|
|
throw new \Exception("Serial {$serialNumber} no disponible en el almacén");
|
|
}
|
|
}
|
|
} else {
|
|
// Asignar automáticamente los primeros N seriales disponibles en el almacén
|
|
$serials = InventorySerial::where('inventory_id', $inventory->id)
|
|
->where('warehouse_id', $serialWarehouseId)
|
|
->where('status', 'disponible')
|
|
->limit($baseQuantity)
|
|
->get();
|
|
|
|
if ($serials->count() < $baseQuantity) {
|
|
throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}");
|
|
}
|
|
|
|
foreach ($serials as $serial) {
|
|
$serial->markAsSold($saleDetail->id, $serialWarehouseId);
|
|
}
|
|
}
|
|
|
|
// 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, $baseQuantity);
|
|
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$baseQuantity);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Expandir bundles en componentes individuales
|
|
*/
|
|
private function expandBundlesIntoComponents(array $items): array
|
|
{
|
|
$expanded = [];
|
|
|
|
foreach ($items as $item) {
|
|
// Detectar si es un bundle
|
|
if (isset($item['type']) && $item['type'] === 'bundle') {
|
|
// Es un kit, expandir en componentes
|
|
$bundle = Bundle::with(['items.inventory.price', 'price'])->findOrFail($item['bundle_id']);
|
|
$bundleQuantity = $item['quantity'];
|
|
$bundleSaleGroup = Str::uuid()->toString();
|
|
|
|
// Calcular precio por unidad de kit (para distribuir)
|
|
$bundleTotalPrice = $bundle->price->retail_price;
|
|
$bundleComponentsValue = $this->calculateBundleComponentsValue($bundle);
|
|
|
|
foreach ($bundle->items as $bundleItem) {
|
|
$componentInventory = $bundleItem->inventory;
|
|
$componentQuantity = $bundleItem->quantity * $bundleQuantity;
|
|
|
|
// Calcular precio proporcional del componente
|
|
$componentValue = ($componentInventory->price->retail_price ?? 0) * $bundleItem->quantity;
|
|
$priceRatio = $bundleComponentsValue > 0 ? $componentValue / $bundleComponentsValue : 0;
|
|
$componentUnitPrice = round($bundleTotalPrice * $priceRatio / $bundleItem->quantity, 2);
|
|
|
|
$expanded[] = [
|
|
'inventory_id' => $componentInventory->id,
|
|
'bundle_id' => $bundle->id,
|
|
'bundle_sale_group' => $bundleSaleGroup,
|
|
'warehouse_id' => $item['warehouse_id'] ?? null,
|
|
'product_name' => $componentInventory->name,
|
|
'quantity' => $componentQuantity,
|
|
'unit_price' => $componentUnitPrice,
|
|
'subtotal' => $componentUnitPrice * $componentQuantity,
|
|
'serial_numbers' => $item['serial_numbers'][$componentInventory->id] ?? null,
|
|
];
|
|
}
|
|
} else {
|
|
// Producto normal, agregar tal cual
|
|
$expanded[] = $item;
|
|
}
|
|
}
|
|
|
|
return $expanded;
|
|
}
|
|
|
|
/**
|
|
* Validar stock de todos los items (antes de crear sale_details)
|
|
*/
|
|
private function validateStockForAllItems(array $items): void
|
|
{
|
|
// Agrupar cantidad total por producto+almacén para evitar que un bundle y un producto
|
|
// suelto pasen la validación individualmente pero no haya stock suficiente en conjunto
|
|
$aggregated = [];
|
|
$serialsByProduct = [];
|
|
|
|
foreach ($items as $item) {
|
|
$inventoryId = $item['inventory_id'];
|
|
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
|
|
$key = "{$inventoryId}_{$warehouseId}";
|
|
|
|
if (! isset($aggregated[$key])) {
|
|
$aggregated[$key] = [
|
|
'inventory_id' => $inventoryId,
|
|
'warehouse_id' => $warehouseId,
|
|
'quantity' => 0,
|
|
];
|
|
}
|
|
|
|
// Convertir a unidades base si usa equivalencia
|
|
$inventory = Inventory::find($inventoryId);
|
|
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
|
|
$inputQuantity = (float) $item['quantity'];
|
|
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
|
|
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
|
|
|
|
$aggregated[$key]['quantity'] += $baseQuantity;
|
|
|
|
// Acumular seriales específicos por producto
|
|
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
|
|
if (! isset($serialsByProduct[$inventoryId])) {
|
|
$serialsByProduct[$inventoryId] = [];
|
|
}
|
|
$serialsByProduct[$inventoryId] = array_merge(
|
|
$serialsByProduct[$inventoryId],
|
|
$item['serial_numbers']
|
|
);
|
|
}
|
|
}
|
|
|
|
// Validar stock total agrupado
|
|
foreach ($aggregated as $entry) {
|
|
$inventory = Inventory::find($entry['inventory_id']);
|
|
|
|
if ($inventory->track_serials) {
|
|
if (! empty($serialsByProduct[$entry['inventory_id']])) {
|
|
// Validar que los seriales específicos existan, estén disponibles y en el almacén correcto
|
|
foreach ($serialsByProduct[$entry['inventory_id']] as $serialNumber) {
|
|
$serial = InventorySerial::where('inventory_id', $inventory->id)
|
|
->where('serial_number', $serialNumber)
|
|
->where('warehouse_id', $entry['warehouse_id'])
|
|
->where('status', 'disponible')
|
|
->first();
|
|
|
|
if (! $serial) {
|
|
throw new \Exception(
|
|
"Serial {$serialNumber} no disponible en el almacén para {$inventory->name}"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Validar que haya suficientes seriales disponibles en el almacén
|
|
$availableSerials = InventorySerial::where('inventory_id', $inventory->id)
|
|
->where('warehouse_id', $entry['warehouse_id'])
|
|
->where('status', 'disponible')
|
|
->count();
|
|
|
|
if ($availableSerials < $entry['quantity']) {
|
|
throw new \Exception(
|
|
"Stock insuficiente de seriales para {$inventory->name}. ".
|
|
"Disponibles: {$availableSerials}, Requeridos: {$entry['quantity']}"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Validar stock total en almacén
|
|
$this->movementService->validateStock($inventory->id, $entry['warehouse_id'], $entry['quantity']);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtener precio total de componentes de un bundle
|
|
*/
|
|
private function calculateBundleComponentsValue(Bundle $bundle): float
|
|
{
|
|
$total = 0;
|
|
|
|
foreach ($bundle->items as $item) {
|
|
$componentPrice = $item->inventory->price->retail_price ?? 0;
|
|
$total += $componentPrice * $item->quantity;
|
|
}
|
|
|
|
return round($total, 2);
|
|
}
|
|
}
|