feat: agregar documentación y componentes para gestión de bundles
This commit is contained in:
parent
156c915403
commit
1466cd2166
815
BUNDLES_API_DOCS.md
Normal file
815
BUNDLES_API_DOCS.md
Normal file
@ -0,0 +1,815 @@
|
||||
# API de Bundles/Kits - Documentación Frontend
|
||||
|
||||
## 📋 Índice
|
||||
|
||||
1. [Conceptos Generales](#conceptos-generales)
|
||||
2. [Estructura de Datos](#estructura-de-datos)
|
||||
3. [Endpoints - CRUD de Bundles](#endpoints---crud-de-bundles)
|
||||
4. [Endpoints - Ventas con Bundles](#endpoints---ventas-con-bundles)
|
||||
5. [Validaciones](#validaciones)
|
||||
6. [Ejemplos de Uso](#ejemplos-de-uso)
|
||||
7. [Respuestas de Error](#respuestas-de-error)
|
||||
|
||||
---
|
||||
|
||||
## Conceptos Generales
|
||||
|
||||
### ¿Qué es un Bundle?
|
||||
|
||||
Un **bundle** (o kit) es una agrupación virtual de productos que se vende como una unidad con un precio especial. Al vender un bundle:
|
||||
|
||||
- Se venden todos los componentes del bundle
|
||||
- Cada componente genera su propio `sale_detail`
|
||||
- El stock se decrementa de cada componente individual
|
||||
- Los seriales se asignan a cada componente que los requiera
|
||||
|
||||
### Características Clave
|
||||
|
||||
✅ **Stock Virtual**: El bundle NO tiene stock propio, se calcula desde sus componentes
|
||||
✅ **Precio Especial**: El precio del bundle puede ser menor que la suma de componentes (promoción)
|
||||
✅ **Componentes Independientes**: Los productos del bundle también pueden venderse por separado
|
||||
✅ **Devoluciones Flexibles**: Se puede devolver el bundle completo o solo algunos componentes
|
||||
✅ **Rastreo de Seriales**: Soporta componentes con números de serie
|
||||
|
||||
---
|
||||
|
||||
## Estructura de Datos
|
||||
|
||||
### Bundle (Kit)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Kit Gamer Pro",
|
||||
"sku": "KIT-GAMER-001",
|
||||
"barcode": "123456789",
|
||||
"available_stock": 5,
|
||||
"total_cost": 250.00,
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"bundle_id": 1,
|
||||
"inventory_id": 10,
|
||||
"quantity": 1,
|
||||
"inventory": {
|
||||
"id": 10,
|
||||
"name": "Mouse Gaming",
|
||||
"sku": "MOUSE-001",
|
||||
"stock": 50,
|
||||
"price": {
|
||||
"cost": 80.00,
|
||||
"retail_price": 150.00,
|
||||
"tax": 16.00
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"bundle_id": 1,
|
||||
"inventory_id": 15,
|
||||
"quantity": 1,
|
||||
"inventory": {
|
||||
"id": 15,
|
||||
"name": "Teclado Gaming",
|
||||
"sku": "TECLADO-001",
|
||||
"stock": 30,
|
||||
"price": {
|
||||
"cost": 120.00,
|
||||
"retail_price": 300.00,
|
||||
"tax": 16.00
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"bundle_id": 1,
|
||||
"inventory_id": 20,
|
||||
"quantity": 2,
|
||||
"inventory": {
|
||||
"id": 20,
|
||||
"name": "Mouse Pad",
|
||||
"sku": "PAD-001",
|
||||
"stock": 100,
|
||||
"price": {
|
||||
"cost": 25.00,
|
||||
"retail_price": 50.00,
|
||||
"tax": 16.00
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"price": {
|
||||
"id": 1,
|
||||
"bundle_id": 1,
|
||||
"cost": 250.00,
|
||||
"retail_price": 450.00,
|
||||
"tax": 72.00
|
||||
},
|
||||
"created_at": "2026-02-16T10:00:00.000000Z",
|
||||
"updated_at": "2026-02-16T10:00:00.000000Z",
|
||||
"deleted_at": null
|
||||
}
|
||||
```
|
||||
|
||||
### Campos Calculados
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| `available_stock` | int | Stock disponible del bundle = min(stock_componente / cantidad_requerida) |
|
||||
| `total_cost` | float | Suma de costos de componentes = Σ(costo_componente × cantidad) |
|
||||
|
||||
**Ejemplo de cálculo de `available_stock`:**
|
||||
|
||||
- Mouse Gaming: stock 50 / cantidad 1 = 50 kits posibles
|
||||
- Teclado Gaming: stock 30 / cantidad 1 = 30 kits posibles
|
||||
- Mouse Pad: stock 100 / cantidad 2 = 50 kits posibles
|
||||
|
||||
**Stock disponible = min(50, 30, 50) = 30 kits**
|
||||
|
||||
---
|
||||
|
||||
## Endpoints - CRUD de Bundles
|
||||
|
||||
### 1. Listar Bundles
|
||||
|
||||
```http
|
||||
GET /bundles?q={busqueda}&page={pagina}
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (opcional): Búsqueda por nombre, SKU o código de barras
|
||||
- `page` (opcional): Número de página para paginación
|
||||
|
||||
**Respuesta Exitosa:**
|
||||
|
||||
```json
|
||||
{
|
||||
"bundles": {
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Kit Gamer Pro",
|
||||
"sku": "KIT-GAMER-001",
|
||||
"available_stock": 30,
|
||||
"items": [...],
|
||||
"price": {...}
|
||||
}
|
||||
],
|
||||
"current_page": 1,
|
||||
"last_page": 3,
|
||||
"per_page": 15,
|
||||
"total": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Ver Detalle de Bundle
|
||||
|
||||
```http
|
||||
GET /bundles/{id}
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Respuesta Exitosa:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"id": 1,
|
||||
"name": "Kit Gamer Pro",
|
||||
"sku": "KIT-GAMER-001",
|
||||
"barcode": "123456789",
|
||||
"available_stock": 30,
|
||||
"total_cost": 250.00,
|
||||
"items": [...],
|
||||
"price": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Crear Bundle
|
||||
|
||||
```http
|
||||
POST /bundles
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Kit Gamer Pro",
|
||||
"sku": "KIT-GAMER-001",
|
||||
"barcode": "123456789",
|
||||
"items": [
|
||||
{
|
||||
"inventory_id": 10,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"inventory_id": 15,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"inventory_id": 20,
|
||||
"quantity": 2
|
||||
}
|
||||
],
|
||||
"retail_price": 450.00,
|
||||
"tax": 72.00
|
||||
}
|
||||
```
|
||||
|
||||
**Campos:**
|
||||
|
||||
| Campo | Tipo | Requerido | Descripción |
|
||||
|-------|------|-----------|-------------|
|
||||
| `name` | string | ✅ | Nombre del bundle (max 255 caracteres) |
|
||||
| `sku` | string | ✅ | SKU único del bundle (max 50 caracteres) |
|
||||
| `barcode` | string | ❌ | Código de barras (max 50 caracteres) |
|
||||
| `items` | array | ✅ | Componentes del bundle (mínimo 2) |
|
||||
| `items[].inventory_id` | int | ✅ | ID del producto componente |
|
||||
| `items[].quantity` | int | ✅ | Cantidad de este componente (min: 1) |
|
||||
| `retail_price` | float | ❌ | Precio de venta (si no se envía, se calcula automáticamente) |
|
||||
| `tax` | float | ❌ | Impuesto (si no se envía, se calcula como 16% del retail_price) |
|
||||
|
||||
**Respuesta Exitosa (201 Created):**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"id": 1,
|
||||
"name": "Kit Gamer Pro",
|
||||
"sku": "KIT-GAMER-001",
|
||||
...
|
||||
},
|
||||
"message": "Bundle creado exitosamente"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Actualizar Bundle
|
||||
|
||||
```http
|
||||
PUT /bundles/{id}
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Kit Gamer Pro V2",
|
||||
"sku": "KIT-GAMER-002",
|
||||
"items": [
|
||||
{
|
||||
"inventory_id": 10,
|
||||
"quantity": 2
|
||||
},
|
||||
{
|
||||
"inventory_id": 15,
|
||||
"quantity": 1
|
||||
}
|
||||
],
|
||||
"retail_price": 500.00,
|
||||
"recalculate_price": false
|
||||
}
|
||||
```
|
||||
|
||||
**Campos:**
|
||||
|
||||
| Campo | Tipo | Requerido | Descripción |
|
||||
|-------|------|-----------|-------------|
|
||||
| `name` | string | ❌ | Nuevo nombre del bundle |
|
||||
| `sku` | string | ❌ | Nuevo SKU (debe ser único) |
|
||||
| `items` | array | ❌ | Nuevos componentes (si se envía, reemplaza todos) |
|
||||
| `retail_price` | float | ❌ | Nuevo precio de venta |
|
||||
| `recalculate_price` | bool | ❌ | Si es `true`, recalcula precio desde componentes |
|
||||
|
||||
**Respuesta Exitosa:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {...},
|
||||
"message": "Bundle actualizado exitosamente"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Eliminar Bundle
|
||||
|
||||
```http
|
||||
DELETE /bundles/{id}
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Respuesta Exitosa:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Bundle eliminado exitosamente"
|
||||
}
|
||||
```
|
||||
|
||||
**Nota:** El bundle se elimina con soft delete (puede restaurarse).
|
||||
|
||||
---
|
||||
|
||||
### 6. Verificar Stock de Bundle
|
||||
|
||||
```http
|
||||
GET /bundles/{id}/check-stock?quantity={cantidad}&warehouse_id={almacen}
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `quantity` (opcional): Cantidad de kits a verificar (default: 1)
|
||||
- `warehouse_id` (opcional): ID del almacén específico
|
||||
|
||||
**Respuesta Exitosa:**
|
||||
|
||||
```json
|
||||
{
|
||||
"bundle_id": 1,
|
||||
"bundle_name": "Kit Gamer Pro",
|
||||
"quantity_requested": 2,
|
||||
"available_stock": 30,
|
||||
"has_stock": true,
|
||||
"components_stock": [
|
||||
{
|
||||
"inventory_id": 10,
|
||||
"product_name": "Mouse Gaming",
|
||||
"required_quantity": 1,
|
||||
"available_stock": 50
|
||||
},
|
||||
{
|
||||
"inventory_id": 15,
|
||||
"product_name": "Teclado Gaming",
|
||||
"required_quantity": 1,
|
||||
"available_stock": 30
|
||||
},
|
||||
{
|
||||
"inventory_id": 20,
|
||||
"product_name": "Mouse Pad",
|
||||
"required_quantity": 2,
|
||||
"available_stock": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints - Ventas con Bundles
|
||||
|
||||
### Vender Bundle
|
||||
|
||||
```http
|
||||
POST /sales
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": 1,
|
||||
"client_id": 5,
|
||||
"subtotal": 450.00,
|
||||
"tax": 72.00,
|
||||
"total": 522.00,
|
||||
"payment_method": "cash",
|
||||
"cash_received": 600.00,
|
||||
"items": [
|
||||
{
|
||||
"type": "bundle",
|
||||
"bundle_id": 1,
|
||||
"quantity": 1,
|
||||
"warehouse_id": 1,
|
||||
"serial_numbers": {
|
||||
"15": ["SN-TECLADO-001"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Campos del Item (Bundle):**
|
||||
|
||||
| Campo | Tipo | Requerido | Descripción |
|
||||
|-------|------|-----------|-------------|
|
||||
| `type` | string | ✅ | Debe ser `"bundle"` |
|
||||
| `bundle_id` | int | ✅ | ID del bundle a vender |
|
||||
| `quantity` | int | ✅ | Cantidad de kits a vender |
|
||||
| `warehouse_id` | int | ❌ | Almacén de donde salen los componentes |
|
||||
| `serial_numbers` | object | ❌ | Seriales por componente (key = inventory_id, value = array de seriales) |
|
||||
|
||||
**Ejemplo de `serial_numbers` para múltiples componentes:**
|
||||
|
||||
```json
|
||||
{
|
||||
"serial_numbers": {
|
||||
"10": ["SN-MOUSE-001"],
|
||||
"15": ["SN-TECLADO-001"],
|
||||
"20": ["SN-PAD-001", "SN-PAD-002"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Respuesta Exitosa:**
|
||||
|
||||
La venta se crea con múltiples `sale_details` (uno por componente):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 100,
|
||||
"invoice_number": "INV-20260216-0001",
|
||||
"total": 522.00,
|
||||
"details": [
|
||||
{
|
||||
"id": 1,
|
||||
"sale_id": 100,
|
||||
"inventory_id": 10,
|
||||
"bundle_id": 1,
|
||||
"bundle_sale_group": "uuid-aaa-bbb-ccc",
|
||||
"product_name": "Mouse Gaming",
|
||||
"quantity": 1,
|
||||
"unit_price": 135.00,
|
||||
"subtotal": 135.00
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"sale_id": 100,
|
||||
"inventory_id": 15,
|
||||
"bundle_id": 1,
|
||||
"bundle_sale_group": "uuid-aaa-bbb-ccc",
|
||||
"product_name": "Teclado Gaming",
|
||||
"quantity": 1,
|
||||
"unit_price": 270.00,
|
||||
"subtotal": 270.00,
|
||||
"serials": [
|
||||
{
|
||||
"serial_number": "SN-TECLADO-001",
|
||||
"status": "vendido"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"sale_id": 100,
|
||||
"inventory_id": 20,
|
||||
"bundle_id": 1,
|
||||
"bundle_sale_group": "uuid-aaa-bbb-ccc",
|
||||
"product_name": "Mouse Pad",
|
||||
"quantity": 2,
|
||||
"unit_price": 45.00,
|
||||
"subtotal": 90.00
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Notas importantes:**
|
||||
|
||||
1. **`bundle_sale_group`**: Todos los componentes del mismo kit vendido comparten el mismo UUID
|
||||
2. **Precios Proporcionales**: Los precios se distribuyen proporcionalmente del precio total del bundle
|
||||
3. **Stock**: Se decrementa automáticamente de cada componente
|
||||
4. **Seriales**: Se asignan automáticamente a los componentes que usan `track_serials=true`
|
||||
|
||||
---
|
||||
|
||||
### Venta Mixta (Productos + Bundles)
|
||||
|
||||
Puedes vender productos normales y bundles en la misma venta:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"type": "bundle",
|
||||
"bundle_id": 1,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"type": "product",
|
||||
"inventory_id": 50,
|
||||
"product_name": "Cable HDMI",
|
||||
"quantity": 2,
|
||||
"unit_price": 25.00,
|
||||
"subtotal": 50.00
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Si no se especifica `type`, se asume que es `"product"`.**
|
||||
|
||||
---
|
||||
|
||||
## Validaciones
|
||||
|
||||
### Validaciones de Bundle (Creación)
|
||||
|
||||
| Campo | Validaciones |
|
||||
|-------|--------------|
|
||||
| `name` | Requerido, string, máx 255 caracteres |
|
||||
| `sku` | Requerido, string, máx 50 caracteres, único en tabla `bundles` |
|
||||
| `barcode` | Opcional, string, máx 50 caracteres |
|
||||
| `items` | Requerido, array, mínimo 2 productos |
|
||||
| `items[].inventory_id` | Requerido, debe existir en tabla `inventories` |
|
||||
| `items[].quantity` | Requerido, entero, mínimo 1 |
|
||||
| `retail_price` | Opcional, numérico, mínimo 0 |
|
||||
| `tax` | Opcional, numérico, mínimo 0 |
|
||||
|
||||
**Validaciones adicionales:**
|
||||
|
||||
- ❌ No se pueden agregar productos duplicados al mismo bundle
|
||||
- ❌ Cada producto solo puede aparecer UNA vez por bundle
|
||||
|
||||
**Ejemplo de error:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Error de validación",
|
||||
"errors": {
|
||||
"items": ["No se pueden agregar productos duplicados al bundle."],
|
||||
"sku": ["Este SKU ya está en uso."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Validaciones de Venta con Bundle
|
||||
|
||||
| Campo | Validaciones |
|
||||
|-------|--------------|
|
||||
| `type` | Opcional, debe ser `"product"` o `"bundle"` |
|
||||
| `bundle_id` | Requerido si `type="bundle"`, debe existir en tabla `bundles` |
|
||||
| `quantity` | Requerido, entero, mínimo 1 |
|
||||
| `warehouse_id` | Opcional, debe existir en tabla `warehouses` |
|
||||
| `serial_numbers` | Opcional, objeto con arrays de seriales |
|
||||
|
||||
**Validaciones de stock:**
|
||||
|
||||
- ✅ Se valida que TODOS los componentes tengan stock suficiente
|
||||
- ✅ Se valida que los seriales existan y estén disponibles
|
||||
- ✅ Si falta stock de UN componente, la venta completa falla
|
||||
|
||||
**Ejemplo de error de stock:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Stock insuficiente del kit 'Kit Gamer Pro'. Disponibles: 5, Requeridos: 10"
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo de error de serial:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Serial SN-TECLADO-999 no disponible para Teclado Gaming"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ejemplos de Uso
|
||||
|
||||
### Ejemplo 1: Flujo Completo - Crear y Vender Bundle
|
||||
|
||||
**Paso 1: Crear Bundle**
|
||||
|
||||
```javascript
|
||||
const response = await fetch('/bundles', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Kit Gamer Pro",
|
||||
sku: "KIT-GAMER-001",
|
||||
items: [
|
||||
{ inventory_id: 10, quantity: 1 }, // Mouse
|
||||
{ inventory_id: 15, quantity: 1 }, // Teclado
|
||||
{ inventory_id: 20, quantity: 2 } // Mouse Pads
|
||||
],
|
||||
retail_price: 450.00,
|
||||
tax: 72.00
|
||||
})
|
||||
});
|
||||
|
||||
const bundle = await response.json();
|
||||
console.log("Bundle creado:", bundle.model.id);
|
||||
```
|
||||
|
||||
**Paso 2: Verificar Stock**
|
||||
|
||||
```javascript
|
||||
const stockResponse = await fetch(`/bundles/${bundle.model.id}/check-stock?quantity=5`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const stockData = await stockResponse.json();
|
||||
|
||||
if (stockData.has_stock) {
|
||||
console.log(`Stock disponible: ${stockData.available_stock} kits`);
|
||||
} else {
|
||||
console.log("Stock insuficiente");
|
||||
console.table(stockData.components_stock);
|
||||
}
|
||||
```
|
||||
|
||||
**Paso 3: Vender Bundle**
|
||||
|
||||
```javascript
|
||||
const saleResponse = await fetch('/sales', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: 1,
|
||||
subtotal: 450.00,
|
||||
tax: 72.00,
|
||||
total: 522.00,
|
||||
payment_method: "cash",
|
||||
cash_received: 600.00,
|
||||
items: [
|
||||
{
|
||||
type: "bundle",
|
||||
bundle_id: bundle.model.id,
|
||||
quantity: 1,
|
||||
warehouse_id: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const sale = await saleResponse.json();
|
||||
console.log(`Venta creada: ${sale.invoice_number}`);
|
||||
console.log(`Total de componentes vendidos: ${sale.details.length}`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Ejemplo 2: Buscar Bundle por SKU
|
||||
|
||||
```javascript
|
||||
const searchResponse = await fetch('/bundles?q=GAMER', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const { bundles } = await searchResponse.json();
|
||||
|
||||
bundles.data.forEach(bundle => {
|
||||
console.log(`${bundle.name} - Stock: ${bundle.available_stock}`);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Ejemplo 3: Actualizar Precio de Bundle
|
||||
|
||||
```javascript
|
||||
await fetch(`/bundles/${bundleId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
retail_price: 399.99, // Nuevo precio promocional
|
||||
tax: 64.00
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Respuestas de Error
|
||||
|
||||
### Errores Comunes
|
||||
|
||||
#### 404 Not Found
|
||||
```json
|
||||
{
|
||||
"message": "No query results for model [App\\Models\\Bundle] 1"
|
||||
}
|
||||
```
|
||||
|
||||
#### 422 Validation Error
|
||||
```json
|
||||
{
|
||||
"message": "Error de validación",
|
||||
"errors": {
|
||||
"items": ["Un bundle debe tener al menos 2 productos."],
|
||||
"sku": ["Este SKU ya está en uso."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 500 Internal Server Error
|
||||
```json
|
||||
{
|
||||
"message": "Error al crear el bundle: Stock insuficiente del kit 'Kit Gamer Pro'. Disponibles: 5, Requeridos: 10"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notas Importantes para Frontend
|
||||
|
||||
### 1. **Cálculo de Totales**
|
||||
|
||||
Cuando vendes un bundle, el frontend debe:
|
||||
- Usar el `retail_price` del bundle (NO sumar componentes)
|
||||
- Calcular el tax basado en el `retail_price` del bundle
|
||||
- El backend distribuirá los precios proporcionalmente a los componentes
|
||||
|
||||
### 2. **Visualización de Stock**
|
||||
|
||||
El `available_stock` ya está calculado por el backend. No necesitas:
|
||||
- ❌ Sumar el stock de componentes
|
||||
- ❌ Hacer cálculos manuales
|
||||
|
||||
Solo muestra el valor de `available_stock` directamente.
|
||||
|
||||
### 3. **Seriales en Bundles**
|
||||
|
||||
Si un componente del bundle usa seriales (`track_serials=true`):
|
||||
- ✅ Puedes especificarlos en `serial_numbers` por `inventory_id`
|
||||
- ✅ Si no los especificas, se asignan automáticamente
|
||||
- ✅ Solo necesitas seriales para componentes que los requieran
|
||||
|
||||
### 4. **Devoluciones**
|
||||
|
||||
Las devoluciones de bundles funcionan igual que productos normales:
|
||||
- Puedes devolver componentes individuales
|
||||
- Cada `sale_detail` se puede devolver independientemente
|
||||
- El `bundle_sale_group` te ayuda a identificar qué componentes pertenecen al mismo kit vendido
|
||||
|
||||
### 5. **Cancelación de Ventas**
|
||||
|
||||
La cancelación funciona automáticamente:
|
||||
- El stock de TODOS los componentes se restaura
|
||||
- Los seriales vuelven a estado `disponible`
|
||||
- No necesitas lógica especial en el frontend
|
||||
|
||||
---
|
||||
|
||||
## Cambios Respecto a Ventas Normales
|
||||
|
||||
| Aspecto | Venta Normal | Venta de Bundle |
|
||||
|---------|--------------|-----------------|
|
||||
| **Items** | `type: "product"` o sin type | `type: "bundle"` |
|
||||
| **ID del producto** | `inventory_id` | `bundle_id` |
|
||||
| **Sale Details** | 1 por item | N por bundle (N = componentes) |
|
||||
| **Stock** | Decrementa 1 producto | Decrementa N componentes |
|
||||
| **Seriales** | Por producto | Por componente (si requiere) |
|
||||
| **Devolución** | Por producto | Por componente |
|
||||
|
||||
---
|
||||
|
||||
## Diagrama de Relaciones
|
||||
|
||||
```
|
||||
Bundle (Kit)
|
||||
├── BundlePrice (1:1)
|
||||
│ └── cost, retail_price, tax
|
||||
│
|
||||
└── BundleItems (1:N)
|
||||
└── Inventory (N:1)
|
||||
├── Stock (en inventory_warehouse)
|
||||
├── Price
|
||||
└── Serials (si track_serials=true)
|
||||
|
||||
Sale
|
||||
└── SaleDetails (1:N)
|
||||
├── bundle_id (FK, nullable)
|
||||
├── bundle_sale_group (UUID, nullable)
|
||||
└── Inventory (N:1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Soporte
|
||||
|
||||
Si encuentras algún problema o tienes dudas sobre la implementación, contacta al equipo de backend o consulta el código fuente en:
|
||||
|
||||
- **Controlador**: `app/Http/Controllers/App/BundleController.php`
|
||||
- **Servicio**: `app/Services/BundleService.php`
|
||||
- **Modelos**: `app/Models/Bundle.php`, `app/Models/BundleItem.php`, `app/Models/BundlePrice.php`
|
||||
- **Plan completo**: `~/.claude/plans/fancy-roaming-shamir.md`
|
||||
0
src/pages/POS/Bundles/Create.vue
Normal file
0
src/pages/POS/Bundles/Create.vue
Normal file
88
src/pages/POS/Bundles/Delete.vue
Normal file
88
src/pages/POS/Bundles/Delete.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
bundle: Object
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'confirm']);
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm', props.bundle.id);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" max-width="md" @close="handleClose">
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Eliminar Paquete
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<GoogleIcon name="close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenido -->
|
||||
<div class="space-y-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
¿Estás seguro de que deseas eliminar este paquete?
|
||||
</p>
|
||||
|
||||
<!-- Información del bundle -->
|
||||
<div v-if="bundle" class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p class="font-bold text-gray-900 dark:text-gray-100 mb-1">
|
||||
{{ bundle.name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
SKU: {{ bundle.sku }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ bundle.items?.length || 0 }} componentes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Advertencia -->
|
||||
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0" />
|
||||
<p class="text-sm text-red-800 dark:text-red-300">
|
||||
Esta acción no afectará los productos individuales del inventario.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
@click="handleConfirm"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
<GoogleIcon name="delete" class="text-xl" />
|
||||
Eliminar Paquete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
0
src/pages/POS/Bundles/Edit.vue
Normal file
0
src/pages/POS/Bundles/Edit.vue
Normal file
0
src/pages/POS/Bundles/Index.vue
Normal file
0
src/pages/POS/Bundles/Index.vue
Normal file
16
src/pages/POS/Bundles/Module.js
Normal file
16
src/pages/POS/Bundles/Module.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||
|
||||
// Ruta API
|
||||
const apiTo = (name, params = {}) => route(`inventario.${name}`, params)
|
||||
|
||||
// Ruta visual
|
||||
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.inventory.${name}`, params, query })
|
||||
|
||||
// Determina si un usuario puede hacer algo en base a los permisos
|
||||
const can = (permission) => hasPermission(`inventario.${permission}`)
|
||||
|
||||
export {
|
||||
can,
|
||||
viewTo,
|
||||
apiTo
|
||||
}
|
||||
189
src/pages/POS/Bundles/ProductSelector.vue
Normal file
189
src/pages/POS/Bundles/ProductSelector.vue
Normal file
@ -0,0 +1,189 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useApi, apiURL } from '@Services/Api';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
excludeIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Buscar producto por nombre o SKU...'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'select']);
|
||||
|
||||
const api = useApi();
|
||||
const productSuggestions = ref([]);
|
||||
const showSuggestions = ref(false);
|
||||
const searchingProduct = ref(false);
|
||||
const productNotFound = ref(false);
|
||||
let debounceTimer = null;
|
||||
|
||||
const localValue = ref(props.modelValue);
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
localValue.value = newVal;
|
||||
});
|
||||
|
||||
watch(localValue, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
onProductInput();
|
||||
});
|
||||
|
||||
const onProductInput = () => {
|
||||
productNotFound.value = false;
|
||||
|
||||
const searchValue = localValue.value?.trim();
|
||||
if (!searchValue || searchValue.length < 2) {
|
||||
productSuggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
showSuggestions.value = true;
|
||||
searchProduct();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const searchProduct = () => {
|
||||
const searchValue = localValue.value?.trim();
|
||||
if (!searchValue) {
|
||||
productSuggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
searchingProduct.value = true;
|
||||
productNotFound.value = false;
|
||||
|
||||
api.get(apiURL(`inventario?q=${encodeURIComponent(searchValue)}`), {
|
||||
onSuccess: (data) => {
|
||||
const foundProducts = data.products?.data || data.data || [];
|
||||
|
||||
// Filtrar productos ya agregados
|
||||
const filteredProducts = foundProducts.filter(
|
||||
p => !props.excludeIds.includes(p.id)
|
||||
);
|
||||
|
||||
if (filteredProducts.length > 0) {
|
||||
productSuggestions.value = filteredProducts;
|
||||
showSuggestions.value = true;
|
||||
} else {
|
||||
productSuggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
productNotFound.value = true;
|
||||
}
|
||||
},
|
||||
onFail: () => {
|
||||
productSuggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
productNotFound.value = true;
|
||||
},
|
||||
onError: () => {
|
||||
productSuggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
productNotFound.value = true;
|
||||
},
|
||||
onFinish: () => {
|
||||
searchingProduct.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const selectProduct = (product) => {
|
||||
emit('select', product);
|
||||
localValue.value = '';
|
||||
productSuggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
productNotFound.value = false;
|
||||
};
|
||||
|
||||
const closeSuggestions = () => {
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false;
|
||||
}, 200);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="localValue"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@blur="closeSuggestions"
|
||||
class="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<GoogleIcon
|
||||
v-if="searchingProduct"
|
||||
name="hourglass_empty"
|
||||
class="text-gray-400 text-lg animate-spin"
|
||||
/>
|
||||
<GoogleIcon
|
||||
v-else
|
||||
name="search"
|
||||
class="text-gray-400 text-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sugerencias -->
|
||||
<div
|
||||
v-if="showSuggestions && productSuggestions.length > 0"
|
||||
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="product in productSuggestions"
|
||||
:key="product.id"
|
||||
@click="selectProduct(product)"
|
||||
class="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border-b border-gray-200 dark:border-gray-700 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ product.name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
SKU: {{ product.sku }} | Stock: {{ product.stock }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Precio</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatCurrency(product.price?.retail_price) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje de no encontrado -->
|
||||
<div
|
||||
v-if="productNotFound && !searchingProduct"
|
||||
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||
<GoogleIcon name="search_off" class="text-xl" />
|
||||
<p class="text-sm">No se encontraron productos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
x
Reference in New Issue
Block a user