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:
Juan Felipe Zapata Moreno 2026-02-16 17:17:05 -06:00
parent 7ebca6456f
commit 7a68c458b8
16 changed files with 947 additions and 27 deletions

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

View 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.'
);
}
});
}
}

View 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.'
);
}
}
});
}
}

View File

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

View 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);
}
}

View File

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

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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