diff --git a/app/Http/Controllers/App/ExcelController.php b/app/Http/Controllers/App/ExcelController.php index 70e0478..45bdc01 100644 --- a/app/Http/Controllers/App/ExcelController.php +++ b/app/Http/Controllers/App/ExcelController.php @@ -14,6 +14,7 @@ use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Alignment; use Carbon\Carbon; +use PhpOffice\PhpSpreadsheet\Cell\DataType; class ExcelController extends Controller { @@ -133,11 +134,15 @@ public function clientDiscountsReport(Request $request) $row = 7; $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('G' . $row, $totalVentas); + $sheet->setCellValueExplicit('G' . $row, (int) $totalVentas, DataType::TYPE_NUMERIC); $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('C' . $row)->getFont()->setSize(12)->getColor()->setRGB('4472C4'); @@ -523,6 +528,8 @@ public function inventoryReport(Request $request) $retailPrice = $inventory->price?->retail_price ?? 0; $totalSold = $quantitySold * $retailPrice; $inventoryValue = $inventory->stock * $cost; + $unitProfit = $retailPrice - $cost; + $totalProfit = $unitProfit * $quantitySold; return [ 'sku' => $inventory->sku ?? 'N/A', @@ -538,6 +545,8 @@ public function inventoryReport(Request $request) 'price' => (float) $retailPrice, 'total_sold' => $totalSold, 'inventory_value' => $inventoryValue, + 'unit_profit' => (float) $unitProfit, + 'total_profit' => $totalProfit, ]; }); @@ -606,25 +615,67 @@ public function inventoryReport(Request $request) $totalQuantitySold = $data->sum('quantity_sold'); $totalSoldValue = $data->sum('total_sold'); $totalInventoryValue = $data->sum('inventory_value'); + $totalProfit = $data->sum('total_profit'); $row = 7; - $sheet->setCellValue('A' . $row, 'TOTAL PRODUCTOS:'); - $sheet->setCellValue('C' . $row, $totalProducts); - $sheet->setCellValue('D' . $row, 'STOCK TOTAL:'); - $sheet->setCellValue('F' . $row, $totalStock); - $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)); + // Columna A-B: TOTAL PRODUCTOS + $sheet->mergeCells("A{$row}:B{$row}"); + $sheet->setCellValue("A{$row}", 'TOTAL PRODUCTOS:'); + $sheet->setCellValueExplicit("C{$row}", (int) $totalProducts, DataType::TYPE_NUMERIC); - $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFont()->setBold(true); - $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('M' . $row)->getFont()->setSize(12)->getColor()->setRGB('008000'); + // Columna D-E: STOCK TOTAL + $sheet->mergeCells("D{$row}:E{$row}"); + $sheet->setCellValue("D{$row}", 'STOCK TOTAL:'); + $sheet->setCellValueExplicit("F{$row}", (int) $totalStock, DataType::TYPE_NUMERIC); + + // 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 --- $h = 9; @@ -643,37 +694,48 @@ public function inventoryReport(Request $request) 'L' => "PRECIO\nVENTA", 'M' => "TOTAL\nVENDIDO", 'N' => "VALOR\nINVENTARIO", + 'O' => "UTILIDAD\nPOR UNIDAD", + 'P' => "UTILIDAD\nTOTAL", ]; foreach ($headers as $col => $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); // --- LLENADO DE DATOS --- $row = 10; $i = 1; + $totalProfit = 0; foreach ($data as $item) { $sheet->setCellValue('A' . $row, $i); $sheet->setCellValue('B' . $row, $item['sku']); $sheet->setCellValue('C' . $row, $item['name']); $sheet->setCellValue('D' . $row, $item['category']); - $sheet->setCellValue('E' . $row, $item['stock']); - $sheet->setCellValue('F' . $row, $item['quantity_sold']); - $sheet->setCellValue('G' . $row, $item['serials_total']); - $sheet->setCellValue('H' . $row, $item['serials_available']); - $sheet->setCellValue('I' . $row, $item['serials_sold']); - $sheet->setCellValue('J' . $row, $item['serials_returned']); - $sheet->setCellValue('K' . $row, '$' . number_format($item['cost'], 2)); - $sheet->setCellValue('L' . $row, '$' . number_format($item['price'], 2)); - $sheet->setCellValue('M' . $row, '$' . number_format($item['total_sold'], 2)); - $sheet->setCellValue('N' . $row, '$' . number_format($item['inventory_value'], 2)); + + // NÚMEROS SIN FORMATO + $sheet->setCellValueExplicit('E' . $row, (int) $item['stock'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('F' . $row, (int) $item['quantity_sold'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('G' . $row, (int) $item['serials_total'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('H' . $row, (int) $item['serials_available'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('I' . $row, (int) $item['serials_sold'], DataType::TYPE_NUMERIC); + $sheet->setCellValueExplicit('J' . $row, (int) $item['serials_returned'], DataType::TYPE_NUMERIC); + + // 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 - $sheet->getStyle("A{$row}:{$lastCol}{$row}")->applyFromArray([ + $sheet->getStyle("A{$row}:P{$row}")->applyFromArray([ 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], 'font' => ['size' => 10] @@ -689,15 +751,19 @@ public function inventoryReport(Request $request) $sheet->getStyle("I{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); $sheet->getStyle("J{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); - // Color de stock bajo (si está configurado el threshold) - if ($request->low_stock_threshold && $item['stock'] <= $request->low_stock_threshold) { - $sheet->getStyle("E{$row}")->getFont()->getColor()->setRGB('FF0000'); - $sheet->getStyle("E{$row}")->getFont()->setBold(true); + // Formato moneda para columnas K-P + $sheet->getStyle("K{$row}:P{$row}")->getNumberFormat()->setFormatCode('$#,##0.00'); + + // 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 if ($i % 2 == 0) { - $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFill() + $sheet->getStyle("A{$row}:P{$row}")->getFill() ->setFillType(Fill::FILL_SOLID) ->getStartColor()->setRGB('F2F2F2'); } @@ -707,20 +773,22 @@ public function inventoryReport(Request $request) } // --- ANCHOS DE COLUMNA --- - $sheet->getColumnDimension('A')->setWidth(5); - $sheet->getColumnDimension('B')->setWidth(15); - $sheet->getColumnDimension('C')->setWidth(35); + $sheet->getColumnDimension('A')->setWidth(18); + $sheet->getColumnDimension('B')->setWidth(18); + $sheet->getColumnDimension('C')->setWidth(18); $sheet->getColumnDimension('D')->setWidth(18); - $sheet->getColumnDimension('E')->setWidth(12); - $sheet->getColumnDimension('F')->setWidth(12); - $sheet->getColumnDimension('G')->setWidth(12); - $sheet->getColumnDimension('H')->setWidth(14); - $sheet->getColumnDimension('I')->setWidth(12); - $sheet->getColumnDimension('J')->setWidth(12); - $sheet->getColumnDimension('K')->setWidth(14); - $sheet->getColumnDimension('L')->setWidth(14); - $sheet->getColumnDimension('M')->setWidth(14); - $sheet->getColumnDimension('N')->setWidth(16); + $sheet->getColumnDimension('E')->setWidth(18); + $sheet->getColumnDimension('F')->setWidth(18); + $sheet->getColumnDimension('G')->setWidth(18); + $sheet->getColumnDimension('H')->setWidth(18); + $sheet->getColumnDimension('I')->setWidth(18); + $sheet->getColumnDimension('J')->setWidth(18); + $sheet->getColumnDimension('K')->setWidth(18); + $sheet->getColumnDimension('L')->setWidth(18); + $sheet->getColumnDimension('M')->setWidth(18); + $sheet->getColumnDimension('N')->setWidth(18); + $sheet->getColumnDimension('O')->setWidth(18); + $sheet->getColumnDimension('P')->setWidth(18); $writer = new Xlsx($spreadsheet); $writer->save($filePath); diff --git a/app/Http/Controllers/App/InventoryController.php b/app/Http/Controllers/App/InventoryController.php index 97eda9f..88cf616 100644 --- a/app/Http/Controllers/App/InventoryController.php +++ b/app/Http/Controllers/App/InventoryController.php @@ -187,11 +187,8 @@ public function downloadTemplate() 'sku', 'codigo_barras', 'categoria', - 'stock', - 'costo', 'precio_venta', - 'impuesto', - 'numeros_serie' + 'impuesto' ]; $exampleData = [ @@ -200,8 +197,6 @@ public function downloadTemplate() 'sku' => 'SAM-A55-BLK', 'codigo_barras' => '7502276853456', 'categoria' => 'Electrónica', - 'stock' => 15, - 'costo' => 5000.00, 'precio_venta' => 7500.00, 'impuesto' => 16 ], @@ -210,22 +205,16 @@ public function downloadTemplate() 'sku' => 'COCA-600', 'codigo_barras' => '750227686666', 'categoria' => 'Bebidas', - 'stock' => 5, - 'costo' => 12.50, 'precio_venta' => 18.00, - 'impuesto' => 8, - 'numeros_serie' => '' // Dejar vacío si el producto no maneja seriales individuales + 'impuesto' => 8 ], [ 'nombre' => 'Laptop HP Pavilion 15', 'sku' => 'HP-LAP-15', 'codigo_barras' => '7502276854443', 'categoria' => 'Computadoras', - 'stock' => 5, - 'costo' => 8500.00, 'precio_venta' => 12000.00, - 'impuesto' => 16, - 'numeros_serie' => 'HP-LAP-15-01,HP-LAP-15-02,HP-LAP-15-03,HP-LAP-15-04,HP-LAP-15-05' + 'impuesto' => 16 ], ]; diff --git a/app/Http/Controllers/App/InventorySerialController.php b/app/Http/Controllers/App/InventorySerialController.php index e97743c..c485395 100644 --- a/app/Http/Controllers/App/InventorySerialController.php +++ b/app/Http/Controllers/App/InventorySerialController.php @@ -29,11 +29,15 @@ public function index(Inventory $inventario, Request $request) $query->where('status', $request->status); } + if ($request->has('warehouse_id')) { + $query->where('warehouse_id', $request->warehouse_id); + } + if ($request->has('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')); 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 diff --git a/app/Http/Requests/App/InventoryEntryRequest.php b/app/Http/Requests/App/InventoryEntryRequest.php index f6dc9a7..aae8ab0 100644 --- a/app/Http/Requests/App/InventoryEntryRequest.php +++ b/app/Http/Requests/App/InventoryEntryRequest.php @@ -24,6 +24,8 @@ public function rules(): array 'products.*.inventory_id' => 'required|exists:inventories,id', 'products.*.quantity' => 'required|integer|min:1', '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', 'invoice_reference' => 'required|string|max:255', '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 'inventory_id.required' => 'El producto es requerido', '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 '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.numeric' => 'El costo unitario debe ser un número', '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 'warehouse_id.required' => 'El almacén es requerido', diff --git a/app/Http/Requests/App/InventoryExitRequest.php b/app/Http/Requests/App/InventoryExitRequest.php index b544e3c..ad93034 100644 --- a/app/Http/Requests/App/InventoryExitRequest.php +++ b/app/Http/Requests/App/InventoryExitRequest.php @@ -23,6 +23,8 @@ public function rules(): array 'products' => 'required|array|min:1', 'products.*.inventory_id' => 'required|exists:inventories,id', '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', 'quantity' => 'required|integer|min:1', '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 'inventory_id.required' => 'El producto es requerido', '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 '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.*.quantity.required' => 'La cantidad es requerida', '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 'warehouse_id.required' => 'El almacén es requerido', diff --git a/app/Http/Requests/App/InventoryImportRequest.php b/app/Http/Requests/App/InventoryImportRequest.php index 2395951..1a21de3 100644 --- a/app/Http/Requests/App/InventoryImportRequest.php +++ b/app/Http/Requests/App/InventoryImportRequest.php @@ -44,8 +44,8 @@ public function messages(): array /** * Reglas de validación para cada fila del Excel - * Nota: SKU y código de barras no tienen 'unique' porque se permite reimportar - * para agregar stock/seriales a productos existentes + * Solo valida información del catálogo de productos. + * Para agregar stock, usar movimientos de entrada (POST /movimientos/entrada). */ public static function rowRules(): array { @@ -54,9 +54,7 @@ public static function rowRules(): array 'sku' => ['nullable', 'string', 'max:50'], 'codigo_barras' => ['nullable', 'string', 'max:100'], 'categoria' => ['nullable', 'string', 'max:100'], - 'stock' => ['required', 'integer', 'min:0'], - 'costo' => ['nullable', 'numeric', 'min:0'], - 'precio_venta' => ['required', 'numeric', 'min:0'], + 'precio_venta' => ['required', 'numeric', 'min:0.01'], '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.max' => 'El nombre no debe exceder los 100 caracteres.', 'sku.max' => 'El SKU no debe exceder los 50 caracteres.', - 'stock.required' => 'El stock es requerido.', - 'stock.integer' => 'El stock debe ser un número entero.', - '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.', + 'codigo_barras.max' => 'El código de barras no debe exceder los 100 caracteres.', + 'categoria.max' => 'El nombre de la categoría no debe exceder los 100 caracteres.', 'precio_venta.required' => 'El precio de venta es requerido.', '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.gt' => 'El precio de venta debe ser mayor que el costo.', + 'precio_venta.min' => 'El precio de venta debe ser mayor a 0.', 'impuesto.numeric' => 'El impuesto debe ser un número.', 'impuesto.min' => 'El impuesto no puede ser negativo.', 'impuesto.max' => 'El impuesto no puede exceder el 100%.', diff --git a/app/Http/Requests/App/InventoryTransferRequest.php b/app/Http/Requests/App/InventoryTransferRequest.php index a7c1f0b..2a17a52 100644 --- a/app/Http/Requests/App/InventoryTransferRequest.php +++ b/app/Http/Requests/App/InventoryTransferRequest.php @@ -24,6 +24,8 @@ public function rules(): array 'products' => 'required|array|min:1', 'products.*.inventory_id' => 'required|exists:inventories,id', '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', 'quantity' => 'required|integer|min:1', '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 'inventory_id.required' => 'El producto es requerido', '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 '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.*.quantity.required' => 'La cantidad es requerida', '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 'warehouse_from_id.required' => 'El almacén origen es requerido', diff --git a/app/Imports/ProductsImport.php b/app/Imports/ProductsImport.php index 07b4992..010abde 100644 --- a/app/Imports/ProductsImport.php +++ b/app/Imports/ProductsImport.php @@ -6,8 +6,6 @@ use App\Models\Price; use App\Models\Category; use App\Http\Requests\App\InventoryImportRequest; -use App\Models\InventorySerial; -use App\Services\InventoryMovementService; use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithValidation; @@ -19,13 +17,15 @@ /** * 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: * - nombre: Nombre del producto (requerido) * - sku: Código SKU (opcional, único) + * - codigo_barras: Código de barras (opcional, único) * - categoria: Nombre de la categoría (opcional) - * - stock: Cantidad inicial (requerido, mínimo 0) - * - costo: Precio de costo (requerido, mínimo 0) - * - precio_venta: Precio de venta (requerido, mayor que costo) + * - precio_venta: Precio de venta (requerido, mayor a 0) * - impuesto: Porcentaje de impuesto (opcional, 0-100) */ class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, SkipsEmptyRows, WithMapping @@ -36,12 +36,6 @@ class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChu private $imported = 0; private $updated = 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 @@ -53,11 +47,8 @@ public function map($row): array 'sku' => isset($row['sku']) ? (string) $row['sku'] : null, 'codigo_barras' => isset($row['codigo_barras']) ? (string) $row['codigo_barras'] : null, 'categoria' => $row['categoria'] ?? null, - 'stock' => $row['stock'] ?? null, - 'costo' => $row['costo'] ?? null, 'precio_venta' => $row['precio_venta'] ?? 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) { // 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; } @@ -81,19 +72,17 @@ public function model(array $row) $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) { return $this->updateExistingProduct($existingInventory, $row); } - // Producto nuevo: obtener valores - $costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : 0; + // Producto nuevo $precioVenta = (float) $row['precio_venta']; - // Validar precio > costo solo si costo > 0 - if ($costo > 0 && $precioVenta <= $costo) { + if ($precioVenta <= 0) { $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; } @@ -107,34 +96,24 @@ public function model(array $row) $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->name = trim($row['nombre']); $inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null; $inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null; $inventory->category_id = $categoryId; $inventory->is_active = true; + $inventory->track_serials = false; // Por defecto no rastrea seriales $inventory->save(); - // Crear el precio del producto + // Crear el precio del producto (sin costo inicial) Price::create([ 'inventory_id' => $inventory->id, - 'cost' => $costo, + 'cost' => 0, // El costo se actualiza con movimientos de entrada 'retail_price' => $precioVenta, '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++; 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) { - $mainWarehouseId = $this->movementService->getMainWarehouseId(); - $stockToAdd = (int) ($row['stock'] ?? 0); - $costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : null; + try { + // Actualizar información básica del producto + if (!empty($row['nombre'])) { + $inventory->name = trim($row['nombre']); + } - // Si hay stock para agregar - if ($stockToAdd > 0) { - // Si tiene números de serie - if (!empty($row['numeros_serie'])) { - $serials = explode(',', $row['numeros_serie']); - $serialsAdded = 0; - $serialsSkipped = 0; + if (!empty($row['codigo_barras'])) { + $inventory->barcode = trim($row['codigo_barras']); + } - foreach ($serials as $serial) { - $serial = trim($serial); - if (empty($serial)) continue; + // Actualizar categoría si se proporciona + if (!empty($row['categoria'])) { + $category = Category::firstOrCreate( + ['name' => trim($row['categoria'])], + ['is_active' => true] + ); + $inventory->category_id = $category->id; + } - // Verificar si el serial ya existe - $exists = InventorySerial::where('serial_number', $serial)->exists(); + $inventory->save(); - if (!$exists) { - InventorySerial::create([ - 'inventory_id' => $inventory->id, - 'warehouse_id' => $mainWarehouseId, - 'serial_number' => $serial, - 'status' => 'disponible', - ]); - $serialsAdded++; - } else { - $serialsSkipped++; - } + // Actualizar precio de venta e impuesto (NO el costo) + if ($inventory->price) { + $updateData = []; + + if (!empty($row['precio_venta'])) { + $updateData['retail_price'] = (float) $row['precio_venta']; } - // Sincronizar stock basado en seriales - $inventory->syncStock(); + if (isset($row['impuesto'])) { + $updateData['tax'] = (float) $row['impuesto']; + } - if ($serialsSkipped > 0) { - $this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados"; + if (!empty($updateData)) { + $inventory->price->update($updateData); } } - // Registrar movimiento de entrada con costo si existe - 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++; - $this->updated++; - - return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo - } - - /** - * 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); + return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo + } catch (\Exception $e) { + $this->skipped++; + $this->errors[] = "Error actualizando producto '{$inventory->name}': " . $e->getMessage(); + return null; } } diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 10c36fd..ac01297 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -4,6 +4,7 @@ use App\Models\Inventory; use App\Models\InventoryMovement; +use App\Models\InventorySerial; use App\Models\InventoryWarehouse; use App\Models\Warehouse; use Illuminate\Support\Facades\DB; @@ -23,8 +24,29 @@ public function entry(array $data): InventoryMovement return DB::transaction(function () use ($data) { $inventory = Inventory::findOrFail($data['inventory_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']); - $quantity = $data['quantity']; - $unitCost = $data['unit_cost']; + $quantity = (int) $data['quantity']; + $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 $curentStock = $inventory->stock; @@ -41,8 +63,28 @@ public function entry(array $data): InventoryMovement // Actualizar costo en prices $this->updateProductCost($inventory, $newCost); - // Actualizar stock en inventory_warehouse - $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); + // Crear seriales si se proporcionan + 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 return InventoryMovement::create([ @@ -70,8 +112,32 @@ public function bulkEntry(array $data): array foreach ($data['products'] as $productData) { $inventory = Inventory::findOrFail($productData['inventory_id']); - $quantity = $productData['quantity']; - $unitCost = $productData['unit_cost']; + $quantity = (int) $productData['quantity']; + $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 $currentStock = $inventory->stock; @@ -88,8 +154,28 @@ public function bulkEntry(array $data): array // Actualizar costo en prices $this->updateProductCost($inventory, $newCost); - // Actualizar stock en inventory_warehouse - $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); + // Crear seriales si se proporcionan + 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 $movement = InventoryMovement::create([ @@ -122,13 +208,57 @@ public function bulkExit(array $data): array foreach ($data['products'] as $productData) { $inventory = Inventory::findOrFail($productData['inventory_id']); - $quantity = $productData['quantity']; + $quantity = (int) $productData['quantity']; + $serialNumbers = (array) $productData['serial_numbers'] ?? null; - // Validar stock disponible - $this->validateStock($inventory->id, $warehouse->id, $quantity); + // Validar y procesar 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."); + } - // Decrementar stock en inventory_warehouse - $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); + // Filtrar seriales vacíos y hacer trim + $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 $movement = InventoryMovement::create([ @@ -165,16 +295,58 @@ public function bulkTransfer(array $data): array foreach ($data['products'] as $productData) { $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 - $this->validateStock($inventory->id, $warehouseFrom->id, $quantity); + // Validar y procesar 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."); + } - // Decrementar en origen - $this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); + // Filtrar seriales vacíos y hacer trim + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { + return !empty($serial); + }); + $serialNumbers = array_values($serialNumbers); - // Incrementar en destino - $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); + $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 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 $movement = InventoryMovement::create([ @@ -227,13 +399,57 @@ public function exit(array $data): InventoryMovement return DB::transaction(function () use ($data) { $inventory = Inventory::findOrFail($data['inventory_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']); - $quantity = $data['quantity']; + $quantity = (int) $data['quantity']; + $serialNumbers = (array) ($data['serial_numbers'] ?? null); - // Validar stock disponible - $this->validateStock($inventory->id, $warehouse->id, $quantity); + // Validar y procesar 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.'); + } - // Decrementar stock en inventory_warehouse - $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); + // Filtrar seriales vacíos y hacer trim + $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 return InventoryMovement::create([ @@ -257,21 +473,63 @@ public function transfer(array $data): InventoryMovement $inventory = Inventory::findOrFail($data['inventory_id']); $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_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 if ($warehouseFrom->id === $warehouseTo->id) { throw new \Exception('No se puede traspasar al mismo almacén.'); } - // Validar stock disponible en almacén origen - $this->validateStock($inventory->id, $warehouseFrom->id, $quantity); + // Validar y procesar 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.'); + } - // Decrementar en origen - $this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); + // Filtrar seriales vacíos y hacer trim + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { + return !empty($serial); + }); + $serialNumbers = array_values($serialNumbers); - // Incrementar en destino - $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); + $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 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 return InventoryMovement::create([ diff --git a/routes/api.php b/routes/api.php index e5e0321..6191b56 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,7 +43,7 @@ Route::resource('inventario', InventoryController::class); // 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']); // ALMACENES