pdv.backend/app/Imports/ProductsImport.php
Juan Felipe Zapata Moreno 9a78d92dbf feat(inventory): movimientos masivos y costeo unitario
- Habilita entradas, salidas y traspasos masivos con validación.
- Implementa cálculo de costo promedio ponderado y campo de costo unitario.
- Agrega filtro por almacén y ajusta manejo de costos nulos.
2026-02-06 16:03:09 -06:00

294 lines
10 KiB
PHP

<?php
namespace App\Imports;
use App\Models\Inventory;
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;
use Maatwebsite\Excel\Concerns\Importable;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\WithMapping;
/**
* Import de productos desde Excel
*
* Formato esperado del Excel:
* - nombre: Nombre del producto (requerido)
* - sku: Código SKU (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)
* - impuesto: Porcentaje de impuesto (opcional, 0-100)
*/
class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, SkipsEmptyRows, WithMapping
{
use Importable;
private $errors = [];
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
*/
public function map($row): array
{
return [
'nombre' => $row['nombre'] ?? null,
'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,
];
}
/**
* Procesa cada fila del Excel
*/
public function model(array $row)
{
// Ignorar filas completamente vacías
if (empty($row['nombre']) && empty($row['sku']) && empty($row['stock'])) {
return null;
}
try {
// Buscar producto existente por SKU o código de barras
$existingInventory = null;
if (!empty($row['sku'])) {
$existingInventory = Inventory::where('sku', trim($row['sku']))->first();
}
if (!$existingInventory && !empty($row['codigo_barras'])) {
$existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first();
}
// Si el producto ya existe, solo agregar stock y costo
if ($existingInventory) {
return $this->updateExistingProduct($existingInventory, $row);
}
// Producto nuevo: obtener valores
$costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : 0;
$precioVenta = (float) $row['precio_venta'];
// Validar precio > costo solo si costo > 0
if ($costo > 0 && $precioVenta <= $costo) {
$this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta ($precioVenta) debe ser mayor que el costo ($costo)";
return null;
}
// Buscar o crear categoría si se proporciona
$categoryId = null;
if (!empty($row['categoria'])) {
$category = Category::firstOrCreate(
['name' => trim($row['categoria'])],
['is_active' => true]
);
$categoryId = $category->id;
}
// Crear el producto en inventario (sin stock, vive en inventory_warehouse)
$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->save();
// Crear el precio del producto
Price::create([
'inventory_id' => $inventory->id,
'cost' => $costo,
'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;
} catch (\Exception $e) {
$this->skipped++;
$this->errors[] = "Error en fila: " . $e->getMessage();
return null;
}
}
/**
* Actualiza un producto existente: suma stock y actualiza costo
*/
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;
// 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;
foreach ($serials as $serial) {
$serial = trim($serial);
if (empty($serial)) continue;
// Verificar si el serial ya existe
$exists = InventorySerial::where('serial_number', $serial)->exists();
if (!$exists) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial,
'status' => 'disponible',
]);
$serialsAdded++;
} else {
$serialsSkipped++;
}
}
// Sincronizar stock basado en seriales
$inventory->syncStock();
if ($serialsSkipped > 0) {
$this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados";
}
}
// 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++;
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);
}
}
/**
* Reglas de validación para cada fila
*/
public function rules(): array
{
return InventoryImportRequest::rowRules();
}
/**
* Mensajes personalizados de validación
*/
public function customValidationMessages()
{
return InventoryImportRequest::rowMessages();
}
/**
* Chunk size for reading
*/
public function chunkSize(): int
{
return 100;
}
/**
* Obtener estadísticas de la importación
*/
public function getStats(): array
{
return [
'imported' => $this->imported,
'updated' => $this->updated,
'skipped' => $this->skipped,
'errors' => $this->errors,
];
}
}