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:
parent
6b76b94e62
commit
7f6db1b83c
@ -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);
|
||||||
|
|||||||
@ -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'
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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%.',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user