feat: implementar gestión de bundles, incluyendo creación, actualización y eliminación, así como validación de stock
This commit is contained in:
parent
7ebca6456f
commit
7a68c458b8
140
app/Http/Controllers/App/BundleController.php
Normal file
140
app/Http/Controllers/App/BundleController.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Bundle;
|
||||
use App\Services\BundleService;
|
||||
use App\Http\Requests\App\BundleStoreRequest;
|
||||
use App\Http\Requests\App\BundleUpdateRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||
|
||||
class BundleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BundleService $bundleService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Listar todos los bundles activos
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$bundles = Bundle::with(['items.inventory.price', 'price'])
|
||||
->when($request->has('q'), function ($query) use ($request) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(config('app.pagination', 15));
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'bundles' => $bundles,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ver detalle de un bundle
|
||||
*/
|
||||
public function show(Bundle $bundle)
|
||||
{
|
||||
$bundle->load(['items.inventory.price', 'price']);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $bundle,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear un nuevo bundle
|
||||
*/
|
||||
public function store(BundleStoreRequest $request)
|
||||
{
|
||||
try {
|
||||
$bundle = $this->bundleService->createBundle($request->validated());
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'model' => $bundle,
|
||||
'message' => 'Bundle creado exitosamente',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al crear el bundle: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un bundle existente
|
||||
*/
|
||||
public function update(BundleUpdateRequest $request, Bundle $bundle)
|
||||
{
|
||||
try {
|
||||
$updatedBundle = $this->bundleService->updateBundle($bundle, $request->validated());
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $updatedBundle,
|
||||
'message' => 'Bundle actualizado exitosamente',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al actualizar el bundle: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar (soft delete) un bundle
|
||||
*/
|
||||
public function destroy(Bundle $bundle)
|
||||
{
|
||||
try {
|
||||
$this->bundleService->deleteBundle($bundle);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'message' => 'Bundle eliminado exitosamente',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::INTERNAL_ERROR->response([
|
||||
'message' => 'Error al eliminar el bundle: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar stock disponible de un bundle
|
||||
*/
|
||||
public function checkStock(Request $request, Bundle $bundle)
|
||||
{
|
||||
$quantity = $request->input('quantity', 1);
|
||||
$warehouseId = $request->input('warehouse_id');
|
||||
|
||||
$bundle->load(['items.inventory']);
|
||||
|
||||
$availableStock = $warehouseId
|
||||
? $bundle->stockInWarehouse($warehouseId)
|
||||
: $bundle->available_stock;
|
||||
|
||||
$hasStock = $bundle->hasStock($quantity, $warehouseId);
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'bundle_id' => $bundle->id,
|
||||
'bundle_name' => $bundle->name,
|
||||
'quantity_requested' => $quantity,
|
||||
'available_stock' => $availableStock,
|
||||
'has_stock' => $hasStock,
|
||||
'components_stock' => $bundle->items->map(function ($item) use ($warehouseId) {
|
||||
return [
|
||||
'inventory_id' => $item->inventory_id,
|
||||
'product_name' => $item->inventory->name,
|
||||
'required_quantity' => $item->quantity,
|
||||
'available_stock' => $warehouseId
|
||||
? $item->inventory->stockInWarehouse($warehouseId)
|
||||
: $item->inventory->stock,
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
app/Http/Requests/App/BundleStoreRequest.php
Normal file
73
app/Http/Requests/App/BundleStoreRequest.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BundleStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'sku' => ['required', 'string', 'max:50', 'unique:bundles,sku'],
|
||||
'barcode' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
// Componentes del kit (mínimo 2 productos)
|
||||
'items' => ['required', 'array', 'min:2'],
|
||||
'items.*.inventory_id' => ['required', 'exists:inventories,id'],
|
||||
'items.*.quantity' => ['required', 'integer', 'min:1'],
|
||||
|
||||
// Precio (opcional, se calcula automáticamente si no se provee)
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation messages
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'El nombre del bundle es obligatorio.',
|
||||
'sku.required' => 'El SKU es obligatorio.',
|
||||
'sku.unique' => 'Este SKU ya está en uso.',
|
||||
'items.required' => 'Debes agregar productos al bundle.',
|
||||
'items.min' => 'Un bundle debe tener al menos 2 productos.',
|
||||
'items.*.inventory_id.required' => 'Cada producto debe tener un ID válido.',
|
||||
'items.*.inventory_id.exists' => 'Uno de los productos no existe.',
|
||||
'items.*.quantity.required' => 'La cantidad es obligatoria.',
|
||||
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Validar que no haya productos duplicados
|
||||
$inventoryIds = collect($this->items)->pluck('inventory_id')->toArray();
|
||||
|
||||
if (count($inventoryIds) !== count(array_unique($inventoryIds))) {
|
||||
$validator->errors()->add(
|
||||
'items',
|
||||
'No se pueden agregar productos duplicados al bundle.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
73
app/Http/Requests/App/BundleUpdateRequest.php
Normal file
73
app/Http/Requests/App/BundleUpdateRequest.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\App;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BundleUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$bundleId = $this->route('bundle')?->id;
|
||||
|
||||
return [
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'sku' => ['nullable', 'string', 'max:50', 'unique:bundles,sku,' . $bundleId],
|
||||
'barcode' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
// Componentes del kit (opcional en update)
|
||||
'items' => ['nullable', 'array', 'min:2'],
|
||||
'items.*.inventory_id' => ['required_with:items', 'exists:inventories,id'],
|
||||
'items.*.quantity' => ['required_with:items', 'integer', 'min:1'],
|
||||
|
||||
// Precio
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0'],
|
||||
'recalculate_price' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation messages
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'sku.unique' => 'Este SKU ya está en uso.',
|
||||
'items.min' => 'Un bundle debe tener al menos 2 productos.',
|
||||
'items.*.inventory_id.exists' => 'Uno de los productos no existe.',
|
||||
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación adicional
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Validar que no haya productos duplicados (si se proporcionan items)
|
||||
if ($this->has('items')) {
|
||||
$inventoryIds = collect($this->items)->pluck('inventory_id')->toArray();
|
||||
|
||||
if (count($inventoryIds) !== count(array_unique($inventoryIds))) {
|
||||
$validator->errors()->add(
|
||||
'items',
|
||||
'No se pueden agregar productos duplicados al bundle.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -34,13 +34,24 @@ public function rules(): array
|
||||
|
||||
// Items del carrito
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.inventory_id' => ['required', 'exists:inventories,id'],
|
||||
'items.*.product_name' => ['required', 'string', 'max:255'],
|
||||
|
||||
// Items pueden ser productos O bundles
|
||||
'items.*.type' => ['nullable', 'in:product,bundle'],
|
||||
'items.*.bundle_id' => ['required_if:items.*.type,bundle', 'exists:bundles,id'],
|
||||
|
||||
// Para productos normales
|
||||
'items.*.inventory_id' => ['required_if:items.*.type,product', 'exists:inventories,id'],
|
||||
'items.*.product_name' => ['required_if:items.*.type,product', 'string', 'max:255'],
|
||||
'items.*.unit_price' => ['required_if:items.*.type,product', 'numeric', 'min:0'],
|
||||
'items.*.subtotal' => ['required_if:items.*.type,product', 'numeric', 'min:0'],
|
||||
|
||||
// Comunes a ambos
|
||||
'items.*.quantity' => ['required', 'integer', 'min:1'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
|
||||
'items.*.subtotal' => ['required', 'numeric', 'min:0'],
|
||||
'items.*.warehouse_id' => ['nullable', 'exists:warehouses,id'],
|
||||
|
||||
// Seriales (para productos normales o componentes del bundle)
|
||||
'items.*.serial_numbers' => ['nullable', 'array'],
|
||||
'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'],
|
||||
'items.*.serial_numbers.*' => ['nullable', 'array'], // Para bundles: {inventory_id: [serials]}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
115
app/Models/Bundle.php
Normal file
115
app/Models/Bundle.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Bundle extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'sku',
|
||||
'barcode',
|
||||
];
|
||||
|
||||
protected $appends = ['available_stock', 'total_cost'];
|
||||
|
||||
/**
|
||||
* Componentes del kit (items individuales)
|
||||
*/
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(BundleItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Productos que componen el kit (relación many-to-many)
|
||||
*/
|
||||
public function inventories()
|
||||
{
|
||||
return $this->belongsToMany(Inventory::class, 'bundle_items')
|
||||
->withPivot('quantity')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Precio del kit
|
||||
*/
|
||||
public function price()
|
||||
{
|
||||
return $this->hasOne(BundlePrice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock disponible del kit = mínimo(stock_componente / cantidad_requerida)
|
||||
*/
|
||||
public function getAvailableStockAttribute(): int
|
||||
{
|
||||
if ($this->items->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$minStock = PHP_INT_MAX;
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$inventory = $item->inventory;
|
||||
$availableStock = $inventory->stock;
|
||||
|
||||
// Cuántos kits puedo hacer con este componente
|
||||
$possibleKits = $availableStock > 0 ? floor($availableStock / $item->quantity) : 0;
|
||||
$minStock = min($minStock, $possibleKits);
|
||||
}
|
||||
|
||||
return $minStock === PHP_INT_MAX ? 0 : (int) $minStock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock disponible en un almacén específico
|
||||
*/
|
||||
public function stockInWarehouse(int $warehouseId): int
|
||||
{
|
||||
if ($this->items->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$minStock = PHP_INT_MAX;
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$inventory = $item->inventory;
|
||||
$warehouseStock = $inventory->stockInWarehouse($warehouseId);
|
||||
|
||||
$possibleKits = $warehouseStock > 0 ? floor($warehouseStock / $item->quantity) : 0;
|
||||
$minStock = min($minStock, $possibleKits);
|
||||
}
|
||||
|
||||
return $minStock === PHP_INT_MAX ? 0 : (int) $minStock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Costo total del kit (suma de costos de componentes)
|
||||
*/
|
||||
public function getTotalCostAttribute(): float
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
$componentCost = $item->inventory->price->cost ?? 0;
|
||||
$total += $componentCost * $item->quantity;
|
||||
}
|
||||
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar si el kit tiene stock disponible
|
||||
*/
|
||||
public function hasStock(int $quantity = 1, ?int $warehouseId = null): bool
|
||||
{
|
||||
if ($warehouseId) {
|
||||
return $this->stockInWarehouse($warehouseId) >= $quantity;
|
||||
}
|
||||
|
||||
return $this->available_stock >= $quantity;
|
||||
}
|
||||
}
|
||||
32
app/Models/BundleItem.php
Normal file
32
app/Models/BundleItem.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BundleItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'bundle_id',
|
||||
'inventory_id',
|
||||
'quantity',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bundle al que pertenece este item
|
||||
*/
|
||||
public function bundle()
|
||||
{
|
||||
return $this->belongsTo(Bundle::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Producto componente
|
||||
*/
|
||||
public function inventory()
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
}
|
||||
27
app/Models/BundlePrice.php
Normal file
27
app/Models/BundlePrice.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BundlePrice extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'bundle_id',
|
||||
'cost',
|
||||
'retail_price',
|
||||
'tax',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cost' => 'decimal:2',
|
||||
'retail_price' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bundle al que pertenece este precio
|
||||
*/
|
||||
public function bundle()
|
||||
{
|
||||
return $this->belongsTo(Bundle::class);
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,8 @@ class SaleDetail extends Model
|
||||
protected $fillable = [
|
||||
'sale_id',
|
||||
'inventory_id',
|
||||
'bundle_id',
|
||||
'bundle_sale_group',
|
||||
'warehouse_id',
|
||||
'product_name',
|
||||
'quantity',
|
||||
@ -101,4 +103,35 @@ public function getMaxReturnableQuantityAttribute(): int
|
||||
{
|
||||
return $this->getQuantityRemainingAttribute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle al que pertenece este sale_detail (si es componente de un kit)
|
||||
*/
|
||||
public function bundle()
|
||||
{
|
||||
return $this->belongsTo(Bundle::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si este sale_detail es parte de un kit
|
||||
*/
|
||||
public function isPartOfBundle(): bool
|
||||
{
|
||||
return !is_null($this->bundle_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener todos los sale_details del mismo kit vendido
|
||||
* (todos los componentes con el mismo bundle_sale_group)
|
||||
*/
|
||||
public function bundleComponents()
|
||||
{
|
||||
if (!$this->isPartOfBundle()) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
return SaleDetail::where('sale_id', $this->sale_id)
|
||||
->where('bundle_sale_group', $this->bundle_sale_group)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
175
app/Services/BundleService.php
Normal file
175
app/Services/BundleService.php
Normal file
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Bundle;
|
||||
use App\Models\BundleItem;
|
||||
use App\Models\BundlePrice;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BundleService
|
||||
{
|
||||
/**
|
||||
* Crear un nuevo kit/bundle con sus componentes y precio
|
||||
*/
|
||||
public function createBundle(array $data): Bundle
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
// 1. Crear el bundle principal
|
||||
$bundle = Bundle::create([
|
||||
'name' => $data['name'],
|
||||
'sku' => $data['sku'],
|
||||
'barcode' => $data['barcode'] ?? null,
|
||||
]);
|
||||
|
||||
// 2. Agregar componentes al kit
|
||||
foreach ($data['items'] as $item) {
|
||||
BundleItem::create([
|
||||
'bundle_id' => $bundle->id,
|
||||
'inventory_id' => $item['inventory_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Calcular costos y crear precio
|
||||
$bundle->load('items.inventory.price');
|
||||
|
||||
$totalCost = 0;
|
||||
$totalRetailPrice = 0;
|
||||
|
||||
foreach ($bundle->items as $item) {
|
||||
$totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity;
|
||||
$totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity;
|
||||
}
|
||||
|
||||
// Permitir override de precio (para promociones)
|
||||
$finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice;
|
||||
$tax = $data['tax'] ?? ($finalRetailPrice * 0.16); // 16% por defecto
|
||||
|
||||
BundlePrice::create([
|
||||
'bundle_id' => $bundle->id,
|
||||
'cost' => $totalCost,
|
||||
'retail_price' => $finalRetailPrice,
|
||||
'tax' => $tax,
|
||||
]);
|
||||
|
||||
return $bundle->fresh(['items.inventory.price', 'price']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar un kit existente
|
||||
*/
|
||||
public function updateBundle(Bundle $bundle, array $data): Bundle
|
||||
{
|
||||
return DB::transaction(function () use ($bundle, $data) {
|
||||
// 1. Actualizar datos básicos del bundle
|
||||
$bundle->update([
|
||||
'name' => $data['name'] ?? $bundle->name,
|
||||
'sku' => $data['sku'] ?? $bundle->sku,
|
||||
'barcode' => $data['barcode'] ?? $bundle->barcode,
|
||||
]);
|
||||
|
||||
// 2. Actualizar componentes si se proporcionan
|
||||
if (isset($data['items'])) {
|
||||
// Eliminar componentes actuales
|
||||
$bundle->items()->delete();
|
||||
|
||||
// Crear nuevos componentes
|
||||
foreach ($data['items'] as $item) {
|
||||
BundleItem::create([
|
||||
'bundle_id' => $bundle->id,
|
||||
'inventory_id' => $item['inventory_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Recalcular o actualizar precio
|
||||
if (isset($data['recalculate_price']) && $data['recalculate_price']) {
|
||||
// Recalcular precio basado en componentes actuales
|
||||
$bundle->load('items.inventory.price');
|
||||
|
||||
$totalCost = 0;
|
||||
$totalRetailPrice = 0;
|
||||
|
||||
foreach ($bundle->items as $item) {
|
||||
$totalCost += ($item->inventory->price->cost ?? 0) * $item->quantity;
|
||||
$totalRetailPrice += ($item->inventory->price->retail_price ?? 0) * $item->quantity;
|
||||
}
|
||||
|
||||
$finalRetailPrice = $data['retail_price'] ?? $totalRetailPrice;
|
||||
$tax = $data['tax'] ?? ($finalRetailPrice * 0.16);
|
||||
|
||||
$bundle->price->update([
|
||||
'cost' => $totalCost,
|
||||
'retail_price' => $finalRetailPrice,
|
||||
'tax' => $tax,
|
||||
]);
|
||||
} elseif (isset($data['retail_price'])) {
|
||||
// Solo actualizar precio sin recalcular componentes
|
||||
$bundle->price->update([
|
||||
'retail_price' => $data['retail_price'],
|
||||
'tax' => $data['tax'] ?? ($data['retail_price'] * 0.16),
|
||||
]);
|
||||
}
|
||||
|
||||
return $bundle->fresh(['items.inventory.price', 'price']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar disponibilidad de stock del kit
|
||||
*/
|
||||
public function validateBundleAvailability(Bundle $bundle, int $quantity, ?int $warehouseId = null): void
|
||||
{
|
||||
if (!$bundle->hasStock($quantity, $warehouseId)) {
|
||||
$availableStock = $warehouseId
|
||||
? $bundle->stockInWarehouse($warehouseId)
|
||||
: $bundle->available_stock;
|
||||
|
||||
throw new \Exception(
|
||||
"Stock insuficiente del kit '{$bundle->name}'. " .
|
||||
"Disponibles: {$availableStock}, Requeridos: {$quantity}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener precio total de componentes (sin promoción)
|
||||
*/
|
||||
public function getComponentsValue(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar (soft delete) un bundle
|
||||
*/
|
||||
public function deleteBundle(Bundle $bundle): bool
|
||||
{
|
||||
return $bundle->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurar un bundle eliminado
|
||||
*/
|
||||
public function restoreBundle(int $bundleId): ?Bundle
|
||||
{
|
||||
$bundle = Bundle::withTrashed()->find($bundleId);
|
||||
|
||||
if ($bundle && $bundle->trashed()) {
|
||||
$bundle->restore();
|
||||
return $bundle->fresh(['items.inventory.price', 'price']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
<?php namespace App\Services;
|
||||
|
||||
use App\Models\Bundle;
|
||||
use App\Models\CashRegister;
|
||||
use App\Models\Client;
|
||||
use App\Models\Sale;
|
||||
@ -7,6 +8,7 @@
|
||||
use App\Models\Inventory;
|
||||
use App\Models\InventorySerial;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SaleService
|
||||
{
|
||||
@ -72,8 +74,14 @@ public function createSale(array $data)
|
||||
'status' => $data['status'] ?? 'completed',
|
||||
]);
|
||||
|
||||
// 2. Crear los detalles de la venta y asignar seriales
|
||||
foreach ($data['items'] as $item) {
|
||||
// 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) {
|
||||
@ -84,6 +92,9 @@ public function createSale(array $data)
|
||||
$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,
|
||||
'product_name' => $item['product_name'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
@ -244,4 +255,113 @@ private function getCurrentCashRegister($userId)
|
||||
|
||||
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
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
$inventory = Inventory::find($item['inventory_id']);
|
||||
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
|
||||
|
||||
if ($inventory->track_serials) {
|
||||
// Validar seriales disponibles
|
||||
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
|
||||
// Validar que los seriales específicos existan y estén disponibles
|
||||
foreach ($item['serial_numbers'] as $serialNumber) {
|
||||
$serial = InventorySerial::where('inventory_id', $inventory->id)
|
||||
->where('serial_number', $serialNumber)
|
||||
->where('status', 'disponible')
|
||||
->first();
|
||||
|
||||
if (!$serial) {
|
||||
throw new \Exception(
|
||||
"Serial {$serialNumber} no disponible para {$inventory->name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Validar que haya suficientes seriales disponibles
|
||||
$availableSerials = InventorySerial::where('inventory_id', $inventory->id)
|
||||
->where('status', 'disponible')
|
||||
->count();
|
||||
|
||||
if ($availableSerials < $item['quantity']) {
|
||||
throw new \Exception(
|
||||
"Stock insuficiente de seriales para {$inventory->name}. " .
|
||||
"Disponibles: {$availableSerials}, Requeridos: {$item['quantity']}"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Validar stock en almacén
|
||||
$this->movementService->validateStock($inventory->id, $warehouseId, $item['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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,26 +158,6 @@ public function sendInvoice(
|
||||
return $pdfResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar ticket de venta por WhatsApp
|
||||
*/
|
||||
public function sendSaleTicket(
|
||||
string $phoneNumber,
|
||||
string $ticketUrl,
|
||||
string $saleNumber,
|
||||
string $customerName
|
||||
): array {
|
||||
return $this->sendDocument(
|
||||
phoneNumber: $phoneNumber,
|
||||
documentUrl: $ticketUrl,
|
||||
caption: "Ticket de venta {$saleNumber} - {$customerName}. Gracias por su compra.",
|
||||
filename: "Ticket_{$saleNumber}.pdf",
|
||||
userEmail: $this->email,
|
||||
ticket: $saleNumber,
|
||||
customerName: $customerName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpiar número de teléfono
|
||||
*/
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?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('bundles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('sku')->unique();
|
||||
$table->string('barcode')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bundles');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?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('bundle_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('bundle_id')->constrained('bundles')->onDelete('cascade');
|
||||
$table->foreignId('inventory_id')->constrained('inventories')->onDelete('restrict');
|
||||
$table->integer('quantity')->default(1);
|
||||
$table->timestamps();
|
||||
|
||||
// Un producto solo puede aparecer una vez por kit
|
||||
$table->unique(['bundle_id', 'inventory_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bundle_items');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?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('bundle_prices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('bundle_id')->unique()->constrained('bundles')->onDelete('cascade');
|
||||
$table->decimal('cost', 10, 2);
|
||||
$table->decimal('retail_price', 10, 2);
|
||||
$table->decimal('tax', 10, 2);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bundle_prices');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<?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('sale_details', function (Blueprint $table) {
|
||||
|
||||
$table->foreignId('bundle_id')->nullable()->after('inventory_id')->constrained('bundles')->onDelete('restrict');
|
||||
$table->string('bundle_sale_group', 36)->nullable()->after('bundle_id');
|
||||
$table->index('bundle_id');
|
||||
$table->index('bundle_sale_group');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('sale_details', function (Blueprint $table) {
|
||||
$table->dropIndex(['bundle_id']);
|
||||
$table->dropIndex(['bundle_sale_group']);
|
||||
$table->dropForeign(['bundle_id']);
|
||||
$table->dropColumn(['bundle_id', 'bundle_sale_group']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\App\BundleController;
|
||||
use App\Http\Controllers\App\CashRegisterController;
|
||||
use App\Http\Controllers\App\CategoryController;
|
||||
use App\Http\Controllers\App\ClientController;
|
||||
@ -75,6 +76,16 @@
|
||||
//CATEGORIAS
|
||||
Route::resource('categorias', CategoryController::class);
|
||||
|
||||
//BUNDLES/KITS
|
||||
Route::prefix('bundles')->group(function () {
|
||||
Route::get('/', [BundleController::class, 'index']);
|
||||
Route::get('/{bundle}', [BundleController::class, 'show']);
|
||||
Route::post('/', [BundleController::class, 'store']);
|
||||
Route::put('/{bundle}', [BundleController::class, 'update']);
|
||||
Route::delete('/{bundle}', [BundleController::class, 'destroy']);
|
||||
Route::get('/{bundle}/check-stock', [BundleController::class, 'checkStock']);
|
||||
});
|
||||
|
||||
//PRECIOS
|
||||
Route::resource('precios', PriceController::class);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user