feat: separar stock de importación y validar series

- Elimina gestión de stock inicial en importación (solo catálogo).
- Unifica validación de números de serie en todos los movimientos.
- Restringe controlador de series a lectura y filtra rutas.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-08 20:24:25 -06:00
parent 6b76b94e62
commit 7f6db1b83c
10 changed files with 509 additions and 297 deletions

View File

@ -14,6 +14,7 @@
use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Alignment;
use Carbon\Carbon; use Carbon\Carbon;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
class ExcelController extends Controller class ExcelController extends Controller
{ {
@ -133,11 +134,15 @@ public function clientDiscountsReport(Request $request)
$row = 7; $row = 7;
$sheet->setCellValue('A' . $row, 'TOTAL CLIENTES CON DESCUENTOS:'); $sheet->setCellValue('A' . $row, 'TOTAL CLIENTES CON DESCUENTOS:');
$sheet->setCellValue('C' . $row, $totalClientes); $sheet->setCellValueExplicit('C' . $row, (int) $totalClientes, DataType::TYPE_NUMERIC);
$sheet->setCellValue('E' . $row, 'TOTAL VENTAS:'); $sheet->setCellValue('E' . $row, 'TOTAL VENTAS:');
$sheet->setCellValue('G' . $row, $totalVentas); $sheet->setCellValueExplicit('G' . $row, (int) $totalVentas, DataType::TYPE_NUMERIC);
$sheet->setCellValue('I' . $row, 'TOTAL DESCUENTOS:'); $sheet->setCellValue('I' . $row, 'TOTAL DESCUENTOS:');
$sheet->setCellValue('J' . $row, '$' . number_format($totalDescuentos, 2)); $sheet->setCellValueExplicit('J' . $row, (float) $totalDescuentos, DataType::TYPE_NUMERIC);
// Aplicar formato moneda
$sheet->getStyle('G' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle('J' . $row)->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle('A' . $row . ':J' . $row)->getFont()->setBold(true); $sheet->getStyle('A' . $row . ':J' . $row)->getFont()->setBold(true);
$sheet->getStyle('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4'); $sheet->getStyle('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4');
@ -523,6 +528,8 @@ public function inventoryReport(Request $request)
$retailPrice = $inventory->price?->retail_price ?? 0; $retailPrice = $inventory->price?->retail_price ?? 0;
$totalSold = $quantitySold * $retailPrice; $totalSold = $quantitySold * $retailPrice;
$inventoryValue = $inventory->stock * $cost; $inventoryValue = $inventory->stock * $cost;
$unitProfit = $retailPrice - $cost;
$totalProfit = $unitProfit * $quantitySold;
return [ return [
'sku' => $inventory->sku ?? 'N/A', 'sku' => $inventory->sku ?? 'N/A',
@ -538,6 +545,8 @@ public function inventoryReport(Request $request)
'price' => (float) $retailPrice, 'price' => (float) $retailPrice,
'total_sold' => $totalSold, 'total_sold' => $totalSold,
'inventory_value' => $inventoryValue, 'inventory_value' => $inventoryValue,
'unit_profit' => (float) $unitProfit,
'total_profit' => $totalProfit,
]; ];
}); });
@ -606,25 +615,67 @@ public function inventoryReport(Request $request)
$totalQuantitySold = $data->sum('quantity_sold'); $totalQuantitySold = $data->sum('quantity_sold');
$totalSoldValue = $data->sum('total_sold'); $totalSoldValue = $data->sum('total_sold');
$totalInventoryValue = $data->sum('inventory_value'); $totalInventoryValue = $data->sum('inventory_value');
$totalProfit = $data->sum('total_profit');
$row = 7; $row = 7;
$sheet->setCellValue('A' . $row, 'TOTAL PRODUCTOS:'); // Columna A-B: TOTAL PRODUCTOS
$sheet->setCellValue('C' . $row, $totalProducts); $sheet->mergeCells("A{$row}:B{$row}");
$sheet->setCellValue('D' . $row, 'STOCK TOTAL:'); $sheet->setCellValue("A{$row}", 'TOTAL PRODUCTOS:');
$sheet->setCellValue('F' . $row, $totalStock); $sheet->setCellValueExplicit("C{$row}", (int) $totalProducts, DataType::TYPE_NUMERIC);
$sheet->setCellValue('G' . $row, 'VENDIDOS:');
$sheet->setCellValue('H' . $row, $totalQuantitySold);
$sheet->setCellValue('I' . $row, 'TOTAL VENDIDO:');
$sheet->setCellValue('K' . $row, '$' . number_format($totalSoldValue, 2));
$sheet->setCellValue('L' . $row, 'VALOR INVENTARIO:');
$sheet->setCellValue('M' . $row, '$' . number_format($totalInventoryValue, 2));
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setBold(true); // Columna D-E: STOCK TOTAL
$sheet->getStyle('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('70AD47'); $sheet->mergeCells("D{$row}:E{$row}");
$sheet->getStyle('F' . $row)->getFont()->setSize(12)->getColor()->setRGB('70AD47'); $sheet->setCellValue("D{$row}", 'STOCK TOTAL:');
$sheet->getStyle('H' . $row)->getFont()->setSize(12)->getColor()->setRGB('2E75B6'); $sheet->setCellValueExplicit("F{$row}", (int) $totalStock, DataType::TYPE_NUMERIC);
$sheet->getStyle('K' . $row)->getFont()->setSize(12)->getColor()->setRGB('FF6600');
$sheet->getStyle('M' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000'); // Columna G: VENDIDOS
$sheet->setCellValue("G{$row}", 'VENDIDOS:');
$sheet->setCellValueExplicit("H{$row}", (int) $totalQuantitySold, DataType::TYPE_NUMERIC);
// Columna I-J: TOTAL VENDIDO
$sheet->mergeCells("I{$row}:J{$row}");
$sheet->setCellValue("I{$row}", 'TOTAL VENDIDO:');
$sheet->setCellValueExplicit("K{$row}", (float) $totalSoldValue, DataType::TYPE_NUMERIC);
// Columna L-M: VALOR INVENTARIO
$sheet->mergeCells("L{$row}:M{$row}");
$sheet->setCellValue("L{$row}", 'VALOR INVENTARIO:');
$sheet->setCellValueExplicit("N{$row}", (float) $totalInventoryValue, DataType::TYPE_NUMERIC);
// Columna O: UTILIDAD TOTAL
$sheet->setCellValue("O{$row}", 'UTILIDAD TOTAL:');
$sheet->setCellValueExplicit("P{$row}", (float) $totalProfit, DataType::TYPE_NUMERIC);
// Aplicar formato moneda
$sheet->getStyle("K{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle("N{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle("P{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
// Aplicar estilos a TODA la fila
$sheet->getStyle("A{$row}:P{$row}")->getFont()->setBold(true);
// Centrar vertical y horizontalmente TODOS los datos
$sheet->getStyle("A{$row}:P{$row}")->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// Colores para cada valor numérico
$sheet->getStyle("C{$row}")->getFont()->setSize(12)->getColor()->setRGB('70AD47');
$sheet->getStyle("F{$row}")->getFont()->setSize(12)->getColor()->setRGB('70AD47');
$sheet->getStyle("H{$row}")->getFont()->setSize(12)->getColor()->setRGB('2E75B6');
$sheet->getStyle("K{$row}")->getFont()->setSize(12)->getColor()->setRGB('FF6600');
$sheet->getStyle("N{$row}")->getFont()->setSize(12)->getColor()->setRGB('008000');
$sheet->getStyle("P{$row}")->getFont()->setSize(12);
// Color de utilidad (verde si es positiva, rojo si es negativa)
if ($totalProfit > 0) {
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('008000');
} elseif ($totalProfit < 0) {
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('FF0000');
}
// Altura de fila para que se vea mejor
$sheet->getRowDimension($row)->setRowHeight(25);
// --- ENCABEZADOS DE TABLA --- // --- ENCABEZADOS DE TABLA ---
$h = 9; $h = 9;
@ -643,37 +694,48 @@ public function inventoryReport(Request $request)
'L' => "PRECIO\nVENTA", 'L' => "PRECIO\nVENTA",
'M' => "TOTAL\nVENDIDO", 'M' => "TOTAL\nVENDIDO",
'N' => "VALOR\nINVENTARIO", 'N' => "VALOR\nINVENTARIO",
'O' => "UTILIDAD\nPOR UNIDAD",
'P' => "UTILIDAD\nTOTAL",
]; ];
foreach ($headers as $col => $text) { foreach ($headers as $col => $text) {
$sheet->setCellValue("{$col}{$h}", $text); $sheet->setCellValue("{$col}{$h}", $text);
} }
$sheet->getStyle("A{$h}:{$lastCol}{$h}")->applyFromArray($styleTableHeader); $sheet->getStyle("A{$h}:P{$h}")->applyFromArray($styleTableHeader);
$sheet->getRowDimension($h)->setRowHeight(35); $sheet->getRowDimension($h)->setRowHeight(35);
// --- LLENADO DE DATOS --- // --- LLENADO DE DATOS ---
$row = 10; $row = 10;
$i = 1; $i = 1;
$totalProfit = 0;
foreach ($data as $item) { foreach ($data as $item) {
$sheet->setCellValue('A' . $row, $i); $sheet->setCellValue('A' . $row, $i);
$sheet->setCellValue('B' . $row, $item['sku']); $sheet->setCellValue('B' . $row, $item['sku']);
$sheet->setCellValue('C' . $row, $item['name']); $sheet->setCellValue('C' . $row, $item['name']);
$sheet->setCellValue('D' . $row, $item['category']); $sheet->setCellValue('D' . $row, $item['category']);
$sheet->setCellValue('E' . $row, $item['stock']);
$sheet->setCellValue('F' . $row, $item['quantity_sold']); // NÚMEROS SIN FORMATO
$sheet->setCellValue('G' . $row, $item['serials_total']); $sheet->setCellValueExplicit('E' . $row, (int) $item['stock'], DataType::TYPE_NUMERIC);
$sheet->setCellValue('H' . $row, $item['serials_available']); $sheet->setCellValueExplicit('F' . $row, (int) $item['quantity_sold'], DataType::TYPE_NUMERIC);
$sheet->setCellValue('I' . $row, $item['serials_sold']); $sheet->setCellValueExplicit('G' . $row, (int) $item['serials_total'], DataType::TYPE_NUMERIC);
$sheet->setCellValue('J' . $row, $item['serials_returned']); $sheet->setCellValueExplicit('H' . $row, (int) $item['serials_available'], DataType::TYPE_NUMERIC);
$sheet->setCellValue('K' . $row, '$' . number_format($item['cost'], 2)); $sheet->setCellValueExplicit('I' . $row, (int) $item['serials_sold'], DataType::TYPE_NUMERIC);
$sheet->setCellValue('L' . $row, '$' . number_format($item['price'], 2)); $sheet->setCellValueExplicit('J' . $row, (int) $item['serials_returned'], DataType::TYPE_NUMERIC);
$sheet->setCellValue('M' . $row, '$' . number_format($item['total_sold'], 2));
$sheet->setCellValue('N' . $row, '$' . number_format($item['inventory_value'], 2)); // NÚMEROS CON FORMATO MONEDA
$sheet->setCellValueExplicit('K' . $row, (float) $item['cost'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit('L' . $row, (float) $item['price'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit('M' . $row, (float) $item['total_sold'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit('N' . $row, (float) $item['inventory_value'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit('O' . $row, (float) $item['unit_profit'], DataType::TYPE_NUMERIC);
$sheet->setCellValueExplicit('P' . $row, (float) $item['total_profit'], DataType::TYPE_NUMERIC);
$totalProfit += $item['total_profit'];
// Estilos de fila // Estilos de fila
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([ $sheet->getStyle("A{$row}:P{$row}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10] 'font' => ['size' => 10]
@ -689,15 +751,19 @@ public function inventoryReport(Request $request)
$sheet->getStyle("I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->getStyle("I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->getStyle("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// Color de stock bajo (si está configurado el threshold) // Formato moneda para columnas K-P
if ($request->low_stock_threshold && $item['stock'] <= $request->low_stock_threshold) { $sheet->getStyle("K{$row}:P{$row}")->getNumberFormat()->setFormatCode('$#,##0.00');
$sheet->getStyle("E{$row}")->getFont()->getColor()->setRGB('FF0000');
$sheet->getStyle("E{$row}")->getFont()->setBold(true); // Color de utilidad (verde si es positiva)
if ($item['total_profit'] > 0) {
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('008000');
} elseif ($item['total_profit'] < 0) {
$sheet->getStyle("P{$row}")->getFont()->getColor()->setRGB('FF0000');
} }
// Color alterno de filas // Color alterno de filas
if ($i % 2 == 0) { if ($i % 2 == 0) {
$sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFill() $sheet->getStyle("A{$row}:P{$row}")->getFill()
->setFillType(Fill::FILL_SOLID) ->setFillType(Fill::FILL_SOLID)
->getStartColor()->setRGB('F2F2F2'); ->getStartColor()->setRGB('F2F2F2');
} }
@ -707,20 +773,22 @@ public function inventoryReport(Request $request)
} }
// --- ANCHOS DE COLUMNA --- // --- ANCHOS DE COLUMNA ---
$sheet->getColumnDimension('A')->setWidth(5); $sheet->getColumnDimension('A')->setWidth(18);
$sheet->getColumnDimension('B')->setWidth(15); $sheet->getColumnDimension('B')->setWidth(18);
$sheet->getColumnDimension('C')->setWidth(35); $sheet->getColumnDimension('C')->setWidth(18);
$sheet->getColumnDimension('D')->setWidth(18); $sheet->getColumnDimension('D')->setWidth(18);
$sheet->getColumnDimension('E')->setWidth(12); $sheet->getColumnDimension('E')->setWidth(18);
$sheet->getColumnDimension('F')->setWidth(12); $sheet->getColumnDimension('F')->setWidth(18);
$sheet->getColumnDimension('G')->setWidth(12); $sheet->getColumnDimension('G')->setWidth(18);
$sheet->getColumnDimension('H')->setWidth(14); $sheet->getColumnDimension('H')->setWidth(18);
$sheet->getColumnDimension('I')->setWidth(12); $sheet->getColumnDimension('I')->setWidth(18);
$sheet->getColumnDimension('J')->setWidth(12); $sheet->getColumnDimension('J')->setWidth(18);
$sheet->getColumnDimension('K')->setWidth(14); $sheet->getColumnDimension('K')->setWidth(18);
$sheet->getColumnDimension('L')->setWidth(14); $sheet->getColumnDimension('L')->setWidth(18);
$sheet->getColumnDimension('M')->setWidth(14); $sheet->getColumnDimension('M')->setWidth(18);
$sheet->getColumnDimension('N')->setWidth(16); $sheet->getColumnDimension('N')->setWidth(18);
$sheet->getColumnDimension('O')->setWidth(18);
$sheet->getColumnDimension('P')->setWidth(18);
$writer = new Xlsx($spreadsheet); $writer = new Xlsx($spreadsheet);
$writer->save($filePath); $writer->save($filePath);

View File

@ -187,11 +187,8 @@ public function downloadTemplate()
'sku', 'sku',
'codigo_barras', 'codigo_barras',
'categoria', 'categoria',
'stock',
'costo',
'precio_venta', 'precio_venta',
'impuesto', 'impuesto'
'numeros_serie'
]; ];
$exampleData = [ $exampleData = [
@ -200,8 +197,6 @@ public function downloadTemplate()
'sku' => 'SAM-A55-BLK', 'sku' => 'SAM-A55-BLK',
'codigo_barras' => '7502276853456', 'codigo_barras' => '7502276853456',
'categoria' => 'Electrónica', 'categoria' => 'Electrónica',
'stock' => 15,
'costo' => 5000.00,
'precio_venta' => 7500.00, 'precio_venta' => 7500.00,
'impuesto' => 16 'impuesto' => 16
], ],
@ -210,22 +205,16 @@ public function downloadTemplate()
'sku' => 'COCA-600', 'sku' => 'COCA-600',
'codigo_barras' => '750227686666', 'codigo_barras' => '750227686666',
'categoria' => 'Bebidas', 'categoria' => 'Bebidas',
'stock' => 5,
'costo' => 12.50,
'precio_venta' => 18.00, 'precio_venta' => 18.00,
'impuesto' => 8, 'impuesto' => 8
'numeros_serie' => '' // Dejar vacío si el producto no maneja seriales individuales
], ],
[ [
'nombre' => 'Laptop HP Pavilion 15', 'nombre' => 'Laptop HP Pavilion 15',
'sku' => 'HP-LAP-15', 'sku' => 'HP-LAP-15',
'codigo_barras' => '7502276854443', 'codigo_barras' => '7502276854443',
'categoria' => 'Computadoras', 'categoria' => 'Computadoras',
'stock' => 5,
'costo' => 8500.00,
'precio_venta' => 12000.00, 'precio_venta' => 12000.00,
'impuesto' => 16, 'impuesto' => 16
'numeros_serie' => 'HP-LAP-15-01,HP-LAP-15-02,HP-LAP-15-03,HP-LAP-15-04,HP-LAP-15-05'
], ],
]; ];

View File

@ -29,11 +29,15 @@ public function index(Inventory $inventario, Request $request)
$query->where('status', $request->status); $query->where('status', $request->status);
} }
if ($request->has('warehouse_id')) {
$query->where('warehouse_id', $request->warehouse_id);
}
if ($request->has('q')) { if ($request->has('q')) {
$query->where('serial_number', 'like', "%{$request->q}%"); $query->where('serial_number', 'like', "%{$request->q}%");
} }
$serials = $query->orderBy('serial_number', 'ASC')->with('saleDetail.sale') $serials = $query->orderBy('serial_number', 'ASC')->with(['saleDetail.sale', 'warehouse'])
->paginate(config('app.pagination')); ->paginate(config('app.pagination'));
return ApiResponse::OK->response([ return ApiResponse::OK->response([
@ -75,68 +79,6 @@ public function show(Inventory $inventario, InventorySerial $serial)
]); ]);
} }
/**
* Crear un nuevo serial
*/
public function store(Inventory $inventario, Request $request)
{
$request->validate([
'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'],
'warehouse_id' => ['nullable', 'exists:warehouses,id'],
'notes' => ['nullable', 'string'],
]);
$warehouseId = $request->warehouse_id ?? app(InventoryMovementService::class)->getMainWarehouseId();
$serial = InventorySerial::create([
'inventory_id' => $inventario->id,
'warehouse_id' => $warehouseId,
'serial_number' => $request->serial_number,
'status' => 'disponible',
'notes' => $request->notes,
]);
if(!$inventario->track_serials) {
$inventario->update(['track_serials' => true]);
}
// Sincronizar stock
$inventario->syncStock();
return ApiResponse::CREATED->response([
'serial' => $serial,
'inventory' => $inventario->fresh(),
]);
}
/**
* Actualizar un serial
*/
public function update(Inventory $inventario , InventorySerial $serial, Request $request)
{
// Verificar que el serial pertenece al inventario
if ($serial->inventory_id !== $inventario->id) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario'
]);
}
$request->validate([
'serial_number' => ['sometimes', 'string', 'unique:inventory_serials,serial_number,' . $serial->id],
'status' => ['sometimes', 'in:disponible,vendido'],
'notes' => ['nullable', 'string'],
]);
$serial->update($request->only(['serial_number', 'status', 'notes']));
// Sincronizar stock del inventario
$inventario->syncStock();
return ApiResponse::OK->response([
'serial' => $serial->fresh(),
'inventory' => $inventario->fresh(),
]);
}
/** /**
* Eliminar un serial * Eliminar un serial

View File

@ -24,6 +24,8 @@ public function rules(): array
'products.*.inventory_id' => 'required|exists:inventories,id', 'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|integer|min:1', 'products.*.quantity' => 'required|integer|min:1',
'products.*.unit_cost' => 'required|numeric|min:0', 'products.*.unit_cost' => 'required|numeric|min:0',
'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
]; ];
} }
@ -34,6 +36,8 @@ public function rules(): array
'unit_cost' => 'required|numeric|min:0', 'unit_cost' => 'required|numeric|min:0',
'invoice_reference' => 'required|string|max:255', 'invoice_reference' => 'required|string|max:255',
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
]; ];
} }
@ -43,6 +47,11 @@ public function messages(): array
// Mensajes para entrada única // Mensajes para entrada única
'inventory_id.required' => 'El producto es requerido', 'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe', 'inventory_id.exists' => 'El producto no existe',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'serial_numbers.*.string' => 'El número de serie debe ser texto',
'serial_numbers.*.distinct' => 'Los números de serie no pueden repetirse',
'serial_numbers.*.unique' => 'El número de serie ya existe en el sistema',
// Mensajes para entrada múltiple // Mensajes para entrada múltiple
'products.required' => 'Debe incluir al menos un producto', 'products.required' => 'Debe incluir al menos un producto',
@ -53,6 +62,11 @@ public function messages(): array
'products.*.unit_cost.required' => 'El costo unitario es requerido', 'products.*.unit_cost.required' => 'El costo unitario es requerido',
'products.*.unit_cost.numeric' => 'El costo unitario debe ser un número', 'products.*.unit_cost.numeric' => 'El costo unitario debe ser un número',
'products.*.unit_cost.min' => 'El costo unitario no puede ser negativo', 'products.*.unit_cost.min' => 'El costo unitario no puede ser negativo',
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
'products.*.serial_numbers.*.distinct' => 'Los números de serie no pueden repetirse',
'products.*.serial_numbers.*.unique' => 'El número de serie ya existe en el sistema',
// Mensajes comunes // Mensajes comunes
'warehouse_id.required' => 'El almacén es requerido', 'warehouse_id.required' => 'El almacén es requerido',

View File

@ -23,6 +23,8 @@ public function rules(): array
'products' => 'required|array|min:1', 'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id', 'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|integer|min:1', 'products.*.quantity' => 'required|integer|min:1',
'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
]; ];
} }
@ -32,6 +34,8 @@ public function rules(): array
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'quantity' => 'required|integer|min:1', 'quantity' => 'required|integer|min:1',
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
]; ];
} }
@ -41,6 +45,10 @@ public function messages(): array
// Mensajes para salida única // Mensajes para salida única
'inventory_id.required' => 'El producto es requerido', 'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe', 'inventory_id.exists' => 'El producto no existe',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'serial_numbers.*.string' => 'El número de serie debe ser texto',
'serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes para salida múltiple // Mensajes para salida múltiple
'products.required' => 'Debe incluir al menos un producto', 'products.required' => 'Debe incluir al menos un producto',
@ -48,6 +56,10 @@ public function messages(): array
'products.*.inventory_id.exists' => 'El producto no existe', 'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida', 'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1', 'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
'products.*.serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes comunes // Mensajes comunes
'warehouse_id.required' => 'El almacén es requerido', 'warehouse_id.required' => 'El almacén es requerido',

View File

@ -44,8 +44,8 @@ public function messages(): array
/** /**
* Reglas de validación para cada fila del Excel * Reglas de validación para cada fila del Excel
* Nota: SKU y código de barras no tienen 'unique' porque se permite reimportar * Solo valida información del catálogo de productos.
* para agregar stock/seriales a productos existentes * Para agregar stock, usar movimientos de entrada (POST /movimientos/entrada).
*/ */
public static function rowRules(): array public static function rowRules(): array
{ {
@ -54,9 +54,7 @@ public static function rowRules(): array
'sku' => ['nullable', 'string', 'max:50'], 'sku' => ['nullable', 'string', 'max:50'],
'codigo_barras' => ['nullable', 'string', 'max:100'], 'codigo_barras' => ['nullable', 'string', 'max:100'],
'categoria' => ['nullable', 'string', 'max:100'], 'categoria' => ['nullable', 'string', 'max:100'],
'stock' => ['required', 'integer', 'min:0'], 'precio_venta' => ['required', 'numeric', 'min:0.01'],
'costo' => ['nullable', 'numeric', 'min:0'],
'precio_venta' => ['required', 'numeric', 'min:0'],
'impuesto' => ['nullable', 'numeric', 'min:0', 'max:100'], 'impuesto' => ['nullable', 'numeric', 'min:0', 'max:100'],
]; ];
} }
@ -70,15 +68,11 @@ public static function rowMessages(): array
'nombre.required' => 'El nombre del producto es requerido.', 'nombre.required' => 'El nombre del producto es requerido.',
'nombre.max' => 'El nombre no debe exceder los 100 caracteres.', 'nombre.max' => 'El nombre no debe exceder los 100 caracteres.',
'sku.max' => 'El SKU no debe exceder los 50 caracteres.', 'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
'stock.required' => 'El stock es requerido.', 'codigo_barras.max' => 'El código de barras no debe exceder los 100 caracteres.',
'stock.integer' => 'El stock debe ser un número entero.', 'categoria.max' => 'El nombre de la categoría no debe exceder los 100 caracteres.',
'stock.min' => 'El stock no puede ser negativo.',
'costo.numeric' => 'El costo debe ser un número.',
'costo.min' => 'El costo no puede ser negativo.',
'precio_venta.required' => 'El precio de venta es requerido.', 'precio_venta.required' => 'El precio de venta es requerido.',
'precio_venta.numeric' => 'El precio de venta debe ser un número.', 'precio_venta.numeric' => 'El precio de venta debe ser un número.',
'precio_venta.min' => 'El precio de venta no puede ser negativo.', 'precio_venta.min' => 'El precio de venta debe ser mayor a 0.',
'precio_venta.gt' => 'El precio de venta debe ser mayor que el costo.',
'impuesto.numeric' => 'El impuesto debe ser un número.', 'impuesto.numeric' => 'El impuesto debe ser un número.',
'impuesto.min' => 'El impuesto no puede ser negativo.', 'impuesto.min' => 'El impuesto no puede ser negativo.',
'impuesto.max' => 'El impuesto no puede exceder el 100%.', 'impuesto.max' => 'El impuesto no puede exceder el 100%.',

View File

@ -24,6 +24,8 @@ public function rules(): array
'products' => 'required|array|min:1', 'products' => 'required|array|min:1',
'products.*.inventory_id' => 'required|exists:inventories,id', 'products.*.inventory_id' => 'required|exists:inventories,id',
'products.*.quantity' => 'required|integer|min:1', 'products.*.quantity' => 'required|integer|min:1',
'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
]; ];
} }
@ -34,6 +36,8 @@ public function rules(): array
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id', 'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id',
'quantity' => 'required|integer|min:1', 'quantity' => 'required|integer|min:1',
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
]; ];
} }
@ -43,6 +47,10 @@ public function messages(): array
// Mensajes para traspaso único // Mensajes para traspaso único
'inventory_id.required' => 'El producto es requerido', 'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe', 'inventory_id.exists' => 'El producto no existe',
'serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'serial_numbers.*.string' => 'El número de serie debe ser texto',
'serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes para traspaso múltiple // Mensajes para traspaso múltiple
'products.required' => 'Debe incluir al menos un producto', 'products.required' => 'Debe incluir al menos un producto',
@ -50,6 +58,10 @@ public function messages(): array
'products.*.inventory_id.exists' => 'El producto no existe', 'products.*.inventory_id.exists' => 'El producto no existe',
'products.*.quantity.required' => 'La cantidad es requerida', 'products.*.quantity.required' => 'La cantidad es requerida',
'products.*.quantity.min' => 'La cantidad debe ser al menos 1', 'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
'products.*.serial_numbers.array' => 'Los números de serie deben ser un arreglo',
'products.*.serial_numbers.*.required' => 'El número de serie no puede estar vacío',
'products.*.serial_numbers.*.string' => 'El número de serie debe ser texto',
'products.*.serial_numbers.*.exists' => 'El número de serie no existe en el sistema',
// Mensajes comunes // Mensajes comunes
'warehouse_from_id.required' => 'El almacén origen es requerido', 'warehouse_from_id.required' => 'El almacén origen es requerido',

View File

@ -6,8 +6,6 @@
use App\Models\Price; use App\Models\Price;
use App\Models\Category; use App\Models\Category;
use App\Http\Requests\App\InventoryImportRequest; use App\Http\Requests\App\InventoryImportRequest;
use App\Models\InventorySerial;
use App\Services\InventoryMovementService;
use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation; use Maatwebsite\Excel\Concerns\WithValidation;
@ -19,13 +17,15 @@
/** /**
* Import de productos desde Excel * Import de productos desde Excel
* *
* Solo crea/actualiza el CATÁLOGO de productos.
* Para agregar stock, usar movimientos de entrada (POST /movimientos/entrada).
*
* Formato esperado del Excel: * Formato esperado del Excel:
* - nombre: Nombre del producto (requerido) * - nombre: Nombre del producto (requerido)
* - sku: Código SKU (opcional, único) * - sku: Código SKU (opcional, único)
* - codigo_barras: Código de barras (opcional, único)
* - categoria: Nombre de la categoría (opcional) * - categoria: Nombre de la categoría (opcional)
* - stock: Cantidad inicial (requerido, mínimo 0) * - precio_venta: Precio de venta (requerido, mayor a 0)
* - costo: Precio de costo (requerido, mínimo 0)
* - precio_venta: Precio de venta (requerido, mayor que costo)
* - impuesto: Porcentaje de impuesto (opcional, 0-100) * - impuesto: Porcentaje de impuesto (opcional, 0-100)
*/ */
class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, SkipsEmptyRows, WithMapping class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, SkipsEmptyRows, WithMapping
@ -36,12 +36,6 @@ class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChu
private $imported = 0; private $imported = 0;
private $updated = 0; private $updated = 0;
private $skipped = 0; private $skipped = 0;
private InventoryMovementService $movementService;
public function __construct()
{
$this->movementService = app(InventoryMovementService::class);
}
/** /**
* Mapea y transforma los datos de cada fila antes de la validación * Mapea y transforma los datos de cada fila antes de la validación
@ -53,11 +47,8 @@ public function map($row): array
'sku' => isset($row['sku']) ? (string) $row['sku'] : null, 'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
'codigo_barras' => isset($row['codigo_barras']) ? (string) $row['codigo_barras'] : null, 'codigo_barras' => isset($row['codigo_barras']) ? (string) $row['codigo_barras'] : null,
'categoria' => $row['categoria'] ?? null, 'categoria' => $row['categoria'] ?? null,
'stock' => $row['stock'] ?? null,
'costo' => $row['costo'] ?? null,
'precio_venta' => $row['precio_venta'] ?? null, 'precio_venta' => $row['precio_venta'] ?? null,
'impuesto' => $row['impuesto'] ?? null, 'impuesto' => $row['impuesto'] ?? null,
'numeros_serie' => $row['numeros_serie'] ?? null,
]; ];
} }
@ -67,7 +58,7 @@ public function map($row): array
public function model(array $row) public function model(array $row)
{ {
// Ignorar filas completamente vacías // Ignorar filas completamente vacías
if (empty($row['nombre']) && empty($row['sku']) && empty($row['stock'])) { if (empty($row['nombre']) && empty($row['sku']) && empty($row['precio_venta'])) {
return null; return null;
} }
@ -81,19 +72,17 @@ public function model(array $row)
$existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first(); $existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first();
} }
// Si el producto ya existe, solo agregar stock y costo // Si el producto ya existe, actualizar información
if ($existingInventory) { if ($existingInventory) {
return $this->updateExistingProduct($existingInventory, $row); return $this->updateExistingProduct($existingInventory, $row);
} }
// Producto nuevo: obtener valores // Producto nuevo
$costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : 0;
$precioVenta = (float) $row['precio_venta']; $precioVenta = (float) $row['precio_venta'];
// Validar precio > costo solo si costo > 0 if ($precioVenta <= 0) {
if ($costo > 0 && $precioVenta <= $costo) {
$this->skipped++; $this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta ($precioVenta) debe ser mayor que el costo ($costo)"; $this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta debe ser mayor a 0";
return null; return null;
} }
@ -107,34 +96,24 @@ public function model(array $row)
$categoryId = $category->id; $categoryId = $category->id;
} }
// Crear el producto en inventario (sin stock, vive en inventory_warehouse) // Crear el producto en inventario (sin stock inicial)
$inventory = new Inventory(); $inventory = new Inventory();
$inventory->name = trim($row['nombre']); $inventory->name = trim($row['nombre']);
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null; $inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null; $inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$inventory->category_id = $categoryId; $inventory->category_id = $categoryId;
$inventory->is_active = true; $inventory->is_active = true;
$inventory->track_serials = false; // Por defecto no rastrea seriales
$inventory->save(); $inventory->save();
// Crear el precio del producto // Crear el precio del producto (sin costo inicial)
Price::create([ Price::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'cost' => $costo, 'cost' => 0, // El costo se actualiza con movimientos de entrada
'retail_price' => $precioVenta, 'retail_price' => $precioVenta,
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0, 'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
]); ]);
// Agregar stock inicial si existe
$stockFromExcel = (int) ($row['stock'] ?? 0);
if ($stockFromExcel > 0) {
$this->addStockWithCost(
$inventory,
$stockFromExcel,
$costo,
$row['numeros_serie'] ?? null
);
}
$this->imported++; $this->imported++;
return $inventory; return $inventory;
@ -146,111 +125,55 @@ public function model(array $row)
} }
/** /**
* Actualiza un producto existente: suma stock y actualiza costo * Actualiza un producto existente con nueva información
*/ */
private function updateExistingProduct(Inventory $inventory, array $row) private function updateExistingProduct(Inventory $inventory, array $row)
{ {
$mainWarehouseId = $this->movementService->getMainWarehouseId(); try {
$stockToAdd = (int) ($row['stock'] ?? 0); // Actualizar información básica del producto
$costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : null; if (!empty($row['nombre'])) {
$inventory->name = trim($row['nombre']);
}
// Si hay stock para agregar if (!empty($row['codigo_barras'])) {
if ($stockToAdd > 0) { $inventory->barcode = trim($row['codigo_barras']);
// Si tiene números de serie }
if (!empty($row['numeros_serie'])) {
$serials = explode(',', $row['numeros_serie']);
$serialsAdded = 0;
$serialsSkipped = 0;
foreach ($serials as $serial) { // Actualizar categoría si se proporciona
$serial = trim($serial); if (!empty($row['categoria'])) {
if (empty($serial)) continue; $category = Category::firstOrCreate(
['name' => trim($row['categoria'])],
['is_active' => true]
);
$inventory->category_id = $category->id;
}
// Verificar si el serial ya existe $inventory->save();
$exists = InventorySerial::where('serial_number', $serial)->exists();
if (!$exists) { // Actualizar precio de venta e impuesto (NO el costo)
InventorySerial::create([ if ($inventory->price) {
'inventory_id' => $inventory->id, $updateData = [];
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial, if (!empty($row['precio_venta'])) {
'status' => 'disponible', $updateData['retail_price'] = (float) $row['precio_venta'];
]);
$serialsAdded++;
} else {
$serialsSkipped++;
}
} }
// Sincronizar stock basado en seriales if (isset($row['impuesto'])) {
$inventory->syncStock(); $updateData['tax'] = (float) $row['impuesto'];
}
if ($serialsSkipped > 0) { if (!empty($updateData)) {
$this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados"; $inventory->price->update($updateData);
} }
} }
// Registrar movimiento de entrada con costo si existe $this->updated++;
if ($costo !== null && $costo > 0) {
$this->movementService->entry([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'quantity' => $stockToAdd,
'unit_cost' => $costo,
'invoice_reference' => 'IMP-' . date('YmdHis'),
'notes' => 'Importación desde Excel - actualización',
]);
} else {
// Sin costo, solo agregar stock sin movimiento de entrada
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockToAdd);
}
}
$this->updated++; return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo
} catch (\Exception $e) {
return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo $this->skipped++;
} $this->errors[] = "Error actualizando producto '{$inventory->name}': " . $e->getMessage();
return null;
/**
* Agrega stock inicial a un producto nuevo con registro de movimiento
*/
private function addStockWithCost(Inventory $inventory, int $quantity, float $cost, ?string $serialsString): void
{
$mainWarehouseId = $this->movementService->getMainWarehouseId();
// Si tiene números de serie
if (!empty($serialsString)) {
$serials = explode(',', $serialsString);
foreach ($serials as $serial) {
$serial = trim($serial);
if (!empty($serial)) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial,
'status' => 'disponible',
]);
}
}
// Sincronizar stock basado en seriales
$inventory->syncStock();
}
// Registrar movimiento de entrada con costo si existe
if ($cost > 0) {
$this->movementService->entry([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'quantity' => $quantity,
'unit_cost' => $cost,
'invoice_reference' => 'IMP-' . date('YmdHis'),
'notes' => 'Importación desde Excel - stock inicial',
]);
} else {
// Sin costo, solo agregar stock
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $quantity);
} }
} }

View File

@ -4,6 +4,7 @@
use App\Models\Inventory; use App\Models\Inventory;
use App\Models\InventoryMovement; use App\Models\InventoryMovement;
use App\Models\InventorySerial;
use App\Models\InventoryWarehouse; use App\Models\InventoryWarehouse;
use App\Models\Warehouse; use App\Models\Warehouse;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -23,8 +24,29 @@ public function entry(array $data): InventoryMovement
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$inventory = Inventory::findOrFail($data['inventory_id']); $inventory = Inventory::findOrFail($data['inventory_id']);
$warehouse = Warehouse::findOrFail($data['warehouse_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']);
$quantity = $data['quantity']; $quantity = (int) $data['quantity'];
$unitCost = $data['unit_cost']; $unitCost = (float) $data['unit_cost'];
$serialNumbers = $data['serial_numbers'] ?? null;
// Validar seriales si el producto los requiere
if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
// Re-indexar el array
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Seriales recibidos: " . implode(', ', $serialNumbers));
}
}
//Obtener stock actual para calcular costo promedio ponderado //Obtener stock actual para calcular costo promedio ponderado
$curentStock = $inventory->stock; $curentStock = $inventory->stock;
@ -41,8 +63,28 @@ public function entry(array $data): InventoryMovement
// Actualizar costo en prices // Actualizar costo en prices
$this->updateProductCost($inventory, $newCost); $this->updateProductCost($inventory, $newCost);
// Actualizar stock en inventory_warehouse // Crear seriales si se proporcionan
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); if (!empty($serialNumbers)) {
foreach ($serialNumbers as $serialNumber) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $warehouse->id,
'serial_number' => trim($serialNumber),
'status' => 'disponible',
]);
}
// Activar track_serials si no estaba activo
if (!$inventory->track_serials) {
$inventory->update(['track_serials' => true]);
}
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, actualizar stock manualmente
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
}
// Registrar movimiento // Registrar movimiento
return InventoryMovement::create([ return InventoryMovement::create([
@ -70,8 +112,32 @@ public function bulkEntry(array $data): array
foreach ($data['products'] as $productData) { foreach ($data['products'] as $productData) {
$inventory = Inventory::findOrFail($productData['inventory_id']); $inventory = Inventory::findOrFail($productData['inventory_id']);
$quantity = $productData['quantity']; $quantity = (int) $productData['quantity'];
$unitCost = $productData['unit_cost']; $unitCost = (float)$productData['unit_cost'];
$serialNumbers = $productData['serial_numbers'] ?? null;
// Validar seriales si el producto los requiere
if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales.");
}
// Filtrar seriales vacíos y hacer trim
$serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
// Re-indexar el array
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount != $quantity) {
throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Seriales recibidos: " . implode(', ', $serialNumbers));
}
// Actualizar el array limpio
$productData['serial_numbers'] = $serialNumbers;
}
// Obtener stock actual para calcular costo promedio ponderado // Obtener stock actual para calcular costo promedio ponderado
$currentStock = $inventory->stock; $currentStock = $inventory->stock;
@ -88,8 +154,28 @@ public function bulkEntry(array $data): array
// Actualizar costo en prices // Actualizar costo en prices
$this->updateProductCost($inventory, $newCost); $this->updateProductCost($inventory, $newCost);
// Actualizar stock en inventory_warehouse // Crear seriales si se proporcionan
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); if (!empty($serialNumbers)) {
foreach ($serialNumbers as $serialNumber) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $warehouse->id,
'serial_number' => trim($serialNumber),
'status' => 'disponible',
]);
}
// Activar track_serials si no estaba activo
if (!$inventory->track_serials) {
$inventory->update(['track_serials' => true]);
}
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, actualizar stock manualmente
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
}
// Registrar movimiento // Registrar movimiento
$movement = InventoryMovement::create([ $movement = InventoryMovement::create([
@ -122,13 +208,57 @@ public function bulkExit(array $data): array
foreach ($data['products'] as $productData) { foreach ($data['products'] as $productData) {
$inventory = Inventory::findOrFail($productData['inventory_id']); $inventory = Inventory::findOrFail($productData['inventory_id']);
$quantity = $productData['quantity']; $quantity = (int) $productData['quantity'];
$serialNumbers = (array) $productData['serial_numbers'] ?? null;
// Validar stock disponible // Validar y procesar seriales si el producto los requiere
$this->validateStock($inventory->id, $warehouse->id, $quantity); if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales.");
}
// Decrementar stock en inventory_warehouse // Filtrar seriales vacíos y hacer trim
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount !== $quantity) {
throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén correcto
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status !== 'disponible') {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id !== $warehouse->id) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está en el almacén seleccionado.");
}
}
// Eliminar los seriales (salida definitiva)
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->delete();
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, validar y decrementar stock manualmente
$this->validateStock($inventory->id, $warehouse->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
}
// Registrar movimiento // Registrar movimiento
$movement = InventoryMovement::create([ $movement = InventoryMovement::create([
@ -165,16 +295,58 @@ public function bulkTransfer(array $data): array
foreach ($data['products'] as $productData) { foreach ($data['products'] as $productData) {
$inventory = Inventory::findOrFail($productData['inventory_id']); $inventory = Inventory::findOrFail($productData['inventory_id']);
$quantity = $productData['quantity']; $quantity = (int) $productData['quantity'];
$serialNumbers = (array) ($productData['serial_numbers'] ?? null);
// Validar stock disponible en almacén origen // Validar y procesar seriales si el producto los requiere
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity); if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception("El producto '{$inventory->name}' requiere números de serie. Debe proporcionar {$quantity} seriales.");
}
// Decrementar en origen // Filtrar seriales vacíos y hacer trim
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
// Incrementar en destino $serialCount = count($serialNumbers);
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); if ($serialCount !== $quantity) {
throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén origen
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status !== 'disponible') {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id !== $warehouseFrom->id) {
throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no está en el almacén origen.");
}
}
// Actualizar warehouse_id de los seriales seleccionados
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->update(['warehouse_id' => $warehouseTo->id]);
// Sincronizar stock desde seriales (actualiza ambos almacenes)
$inventory->syncStock();
} else {
// Sin seriales, validar y actualizar stock manualmente
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
}
// Registrar movimiento // Registrar movimiento
$movement = InventoryMovement::create([ $movement = InventoryMovement::create([
@ -227,13 +399,57 @@ public function exit(array $data): InventoryMovement
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$inventory = Inventory::findOrFail($data['inventory_id']); $inventory = Inventory::findOrFail($data['inventory_id']);
$warehouse = Warehouse::findOrFail($data['warehouse_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']);
$quantity = $data['quantity']; $quantity = (int) $data['quantity'];
$serialNumbers = (array) ($data['serial_numbers'] ?? null);
// Validar stock disponible // Validar y procesar seriales si el producto los requiere
$this->validateStock($inventory->id, $warehouse->id, $quantity); if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
}
// Decrementar stock en inventory_warehouse // Filtrar seriales vacíos y hacer trim
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
$serialCount = count($serialNumbers);
if ($serialCount !== $quantity) {
throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén correcto
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status !== 'disponible') {
throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id !== $warehouse->id) {
throw new \Exception("El serial '{$serialNumber}' no está en el almacén seleccionado.");
}
}
// Eliminar los seriales (salida definitiva)
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->delete();
// Sincronizar stock desde seriales
$inventory->syncStock();
} else {
// Sin seriales, validar y decrementar stock manualmente
$this->validateStock($inventory->id, $warehouse->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
}
// Registrar movimiento // Registrar movimiento
return InventoryMovement::create([ return InventoryMovement::create([
@ -257,21 +473,63 @@ public function transfer(array $data): InventoryMovement
$inventory = Inventory::findOrFail($data['inventory_id']); $inventory = Inventory::findOrFail($data['inventory_id']);
$warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']); $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']);
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']); $warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
$quantity = $data['quantity']; $quantity = (int) $data['quantity'];
$serialNumbers = (array) ($data['serial_numbers'] ?? null);
// Validar que no sea el mismo almacén // Validar que no sea el mismo almacén
if ($warehouseFrom->id === $warehouseTo->id) { if ($warehouseFrom->id === $warehouseTo->id) {
throw new \Exception('No se puede traspasar al mismo almacén.'); throw new \Exception('No se puede traspasar al mismo almacén.');
} }
// Validar stock disponible en almacén origen // Validar y procesar seriales si el producto los requiere
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity); if ($inventory->track_serials) {
if (empty($serialNumbers)) {
throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.');
}
// Decrementar en origen // Filtrar seriales vacíos y hacer trim
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) {
return !empty($serial);
});
$serialNumbers = array_values($serialNumbers);
// Incrementar en destino $serialCount = count($serialNumbers);
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); if ($serialCount !== $quantity) {
throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Ajuste la cantidad o agregue/elimine seriales.");
}
// Validar que los seriales existan, pertenezcan al producto, estén disponibles y en el almacén origen
foreach ($serialNumbers as $serialNumber) {
$serial = InventorySerial::where('serial_number', $serialNumber)
->where('inventory_id', $inventory->id)
->first();
if (!$serial) {
throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto.");
}
if ($serial->status !== 'disponible') {
throw new \Exception("El serial '{$serialNumber}' no está disponible (estado: {$serial->status}).");
}
if ($serial->warehouse_id !== $warehouseFrom->id) {
throw new \Exception("El serial '{$serialNumber}' no está en el almacén origen.");
}
}
// Actualizar warehouse_id de los seriales seleccionados
InventorySerial::whereIn('serial_number', $serialNumbers)
->where('inventory_id', $inventory->id)
->update(['warehouse_id' => $warehouseTo->id]);
// Sincronizar stock desde seriales (actualiza ambos almacenes)
$inventory->syncStock();
} else {
// Sin seriales, validar y actualizar stock manualmente
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
}
// Registrar movimiento // Registrar movimiento
return InventoryMovement::create([ return InventoryMovement::create([

View File

@ -43,7 +43,7 @@
Route::resource('inventario', InventoryController::class); Route::resource('inventario', InventoryController::class);
// NÚMEROS DE SERIE DE INVENTARIO // NÚMEROS DE SERIE DE INVENTARIO
Route::resource('inventario.serials', InventorySerialController::class); Route::resource('inventario.serials', InventorySerialController::class)->only(['index', 'show', 'destroy']);
Route::get('serials/search', [InventorySerialController::class, 'search']); Route::get('serials/search', [InventorySerialController::class, 'search']);
// ALMACENES // ALMACENES