From aff2448356a6ce702afa0368001cc7e6c2e6e7e0 Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Tue, 10 Feb 2026 16:39:36 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20agregar=20gesti=C3=B3n=20de=20proveedor?= =?UTF-8?q?es=20y=20unidad=20de=20medida=20en=20inventarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/App/InventoryController.php | 14 +- .../App/InventoryMovementController.php | 4 +- .../Controllers/App/SupplierController.php | 124 ++++++++++++++++++ .../Requests/App/InventoryEntryRequest.php | 2 + app/Imports/ProductsImport.php | 77 +++++++++-- app/Models/InventoryMovement.php | 5 + app/Models/Supplier.php | 31 +++++ app/Services/InventoryMovementService.php | 3 +- ...26_02_10_093124_create_suppliers_table.php | 50 +++++++ database/seeders/RoleSeeder.php | 16 ++- database/seeders/UnitsSeeder.php | 3 +- routes/api.php | 11 ++ 12 files changed, 320 insertions(+), 20 deletions(-) create mode 100644 app/Http/Controllers/App/SupplierController.php create mode 100644 app/Models/Supplier.php create mode 100644 database/migrations/2026_02_10_093124_create_suppliers_table.php diff --git a/app/Http/Controllers/App/InventoryController.php b/app/Http/Controllers/App/InventoryController.php index 8096fad..97eaba0 100644 --- a/app/Http/Controllers/App/InventoryController.php +++ b/app/Http/Controllers/App/InventoryController.php @@ -187,6 +187,7 @@ public function downloadTemplate() 'sku', 'codigo_barras', 'categoria', + 'unidad_medida', 'precio_venta', 'impuesto' ]; @@ -197,22 +198,25 @@ public function downloadTemplate() 'sku' => 'SAM-A55-BLK', 'codigo_barras' => '7502276853456', 'categoria' => 'Electrónica', + 'unidad_medida' => 'Pieza', 'precio_venta' => 7500.00, 'impuesto' => 16 ], [ - 'nombre' => 'Coca Cola 600ml', - 'sku' => 'COCA-600', + 'nombre' => 'Cable UTP CAT6 (Metro)', + 'sku' => 'UTP6-MTR', 'codigo_barras' => '750227686666', - 'categoria' => 'Bebidas', - 'precio_venta' => 18.00, - 'impuesto' => 8 + 'categoria' => 'Cables', + 'unidad_medida' => 'Metro', + 'precio_venta' => 50.00, + 'impuesto' => 16 ], [ 'nombre' => 'Laptop HP Pavilion 15', 'sku' => 'HP-LAP-15', 'codigo_barras' => '7502276854443', 'categoria' => 'Computadoras', + 'unidad_medida' => 'Pieza', 'precio_venta' => 12000.00, 'impuesto' => 16 ], diff --git a/app/Http/Controllers/App/InventoryMovementController.php b/app/Http/Controllers/App/InventoryMovementController.php index dcd36fd..720fdf4 100644 --- a/app/Http/Controllers/App/InventoryMovementController.php +++ b/app/Http/Controllers/App/InventoryMovementController.php @@ -25,7 +25,7 @@ public function __construct( */ public function index(Request $request) { - $query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user']) + $query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier']) ->orderBy('created_at', 'desc'); if ($request->has('q') && $request->q){ @@ -69,7 +69,7 @@ public function index(Request $request) */ public function show(int $id) { - $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user']) + $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier']) ->find($id); if (!$movement) { diff --git a/app/Http/Controllers/App/SupplierController.php b/app/Http/Controllers/App/SupplierController.php new file mode 100644 index 0000000..1147882 --- /dev/null +++ b/app/Http/Controllers/App/SupplierController.php @@ -0,0 +1,124 @@ +has('q')) { + $query->where(function($q) use ($request) { + $q->where('business_name', 'like', "%{$request->q}%") + ->orWhere('rfc', 'like', "%{$request->q}%"); + }); + } + + // Filtro por estado + if ($request->has('is_active')) { + $query->where('is_active', $request->is_active); + } + + $suppliers = $query->orderBy('business_name') + ->paginate(config('app.pagination')); + + return ApiResponse::OK->response([ + 'suppliers' => $suppliers + ]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'business_name' => 'required|string|max:255', + 'email' => 'nullable|email', + 'phone' => 'nullable|string|max:10', + 'rfc' => 'nullable|string|unique:suppliers,rfc', + 'address' => 'nullable|string', + 'postal_code' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $supplier = Supplier::create($validated); + + return ApiResponse::CREATED->response([ + 'supplier' => $supplier + ]); + } + + public function show(Supplier $supplier) + { + return ApiResponse::OK->response([ + 'supplier' => $supplier->load('inventoryMovements') + ]); + } + + public function update(Request $request, Supplier $supplier) + { + $validated = $request->validate([ + 'business_name' => 'nullable|string|max:255', + 'email' => 'nullable|email', + 'phone' => 'nullable|string|max:10', + 'rfc' => 'nullable|string|unique:suppliers,rfc,' . $supplier->id, + 'address' => 'nullable|string', + 'postal_code' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $supplier->update($validated); + + return ApiResponse::OK->response([ + 'supplier' => $supplier->fresh() + ]); + } + + public function destroy(Supplier $supplier) + { + $supplier->delete(); + return ApiResponse::OK->response(); + } + + /** + * Productos suministrados por el proveedor + */ + public function products(Supplier $supplier) + { + $products = $supplier->suppliedProducts() + ->with(['category', 'price']) + ->paginate(config('app.pagination')); + + return ApiResponse::OK->response([ + 'products' => $products + ]); + } + + /** + * Historial de compras al proveedor + */ + public function purchases(Supplier $supplier, Request $request) + { + $query = $supplier->inventoryMovements() + ->with(['inventory', 'warehouseTo', 'user']) + ->orderBy('created_at', 'desc'); + + if ($request->has('from_date')) { + $query->whereDate('created_at', '>=', $request->from_date); + } + + if ($request->has('to_date')) { + $query->whereDate('created_at', '<=', $request->to_date); + } + + $purchases = $query->paginate(config('app.pagination')); + + return ApiResponse::OK->response([ + 'purchases' => $purchases, + 'total_amount' => $supplier->total_purchases + ]); + } +} diff --git a/app/Http/Requests/App/InventoryEntryRequest.php b/app/Http/Requests/App/InventoryEntryRequest.php index cd2083b..707ef76 100644 --- a/app/Http/Requests/App/InventoryEntryRequest.php +++ b/app/Http/Requests/App/InventoryEntryRequest.php @@ -17,6 +17,7 @@ public function rules(): array if ($this->has('products')) { return [ 'warehouse_id' => 'required|exists:warehouses,id', + 'supplier_id' => 'nullable|exists:suppliers,id', 'invoice_reference' => 'required|string|max:255', 'notes' => 'nullable|string|max:1000', @@ -33,6 +34,7 @@ public function rules(): array return [ 'inventory_id' => 'required|exists:inventories,id', 'warehouse_id' => 'required|exists:warehouses,id', + 'supplier_id' => 'nullable|exists:suppliers,id', 'quantity' => 'required|numeric|min:0.001', 'unit_cost' => 'required|numeric|min:0', 'invoice_reference' => 'required|string|max:255', diff --git a/app/Imports/ProductsImport.php b/app/Imports/ProductsImport.php index 010abde..ded4459 100644 --- a/app/Imports/ProductsImport.php +++ b/app/Imports/ProductsImport.php @@ -6,6 +6,7 @@ use App\Models\Price; use App\Models\Category; use App\Http\Requests\App\InventoryImportRequest; +use App\Models\UnitOfMeasurement; use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithValidation; @@ -47,6 +48,7 @@ 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, + 'unidad_medida' => $row['unidad_medida'] ?? null, 'precio_venta' => $row['precio_venta'] ?? null, 'impuesto' => $row['impuesto'] ?? null, ]; @@ -63,12 +65,12 @@ public function model(array $row) } try { - // Buscar producto existente por SKU o código de barras + $existingInventory = null; - if (!empty($row['sku'])) { + if(!empty($row['sku'])) { $existingInventory = Inventory::where('sku', trim($row['sku']))->first(); } - if (!$existingInventory && !empty($row['codigo_barras'])) { + if(!$existingInventory && !empty($row['codigo_barras'])) { $existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first(); } @@ -78,8 +80,27 @@ public function model(array $row) } // Producto nuevo - $precioVenta = (float) $row['precio_venta']; + return $this->createNewProduct($row); + } catch (\Exception $e) { + $this->skipped++; + $this->errors[] = "Error en fila: " . $e->getMessage(); + return null; + } + } + + private function createNewProduct(array $row) + { + try { + // Validar nombre del producto + if (!isset($row['nombre']) || empty(trim($row['nombre']))) { + $this->skipped++; + $this->errors[] = "Fila sin nombre de producto"; + return null; + } + + // Validar precio de venta + $precioVenta = (float) $row['precio_venta']; if ($precioVenta <= 0) { $this->skipped++; $this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta debe ser mayor a 0"; @@ -96,20 +117,47 @@ public function model(array $row) $categoryId = $category->id; } - // Crear el producto en inventario (sin stock inicial) + // Buscar unidad de medida (requerida) + $unitId = null; + if (!empty($row['unidad_medida'])) { + $unit = \App\Models\UnitOfMeasurement::where('name', trim($row['unidad_medida'])) + ->orWhere('abbreviation', trim($row['unidad_medida'])) + ->first(); + + if ($unit) { + $unitId = $unit->id; + } else { + $this->skipped++; + $this->errors[] = "Fila con producto '{$row['nombre']}': Unidad de medida '{$row['unidad_medida']}' no encontrada"; + return null; + } + } else { + // Si no se proporciona, usar 'Pieza' por defecto + $unit = \App\Models\UnitOfMeasurement::where('name', 'Pieza')->first(); + if ($unit) { + $unitId = $unit->id; + } else { + $this->skipped++; + $this->errors[] = "Fila con producto '{$row['nombre']}': No se proporcionó unidad de medida y no existe 'Pieza' por defecto"; + return null; + } + } + + // Crear el producto en inventario $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->unit_of_measure_id = $unitId; $inventory->is_active = true; - $inventory->track_serials = false; // Por defecto no rastrea seriales + $inventory->track_serials = false; $inventory->save(); - // Crear el precio del producto (sin costo inicial) + // Crear el precio del producto Price::create([ 'inventory_id' => $inventory->id, - 'cost' => 0, // El costo se actualiza con movimientos de entrada + 'cost' => 0, 'retail_price' => $precioVenta, 'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0, ]); @@ -119,7 +167,7 @@ public function model(array $row) return $inventory; } catch (\Exception $e) { $this->skipped++; - $this->errors[] = "Error en fila: " . $e->getMessage(); + $this->errors[] = "Error creando producto '{$row['nombre']}': " . $e->getMessage(); return null; } } @@ -148,6 +196,17 @@ private function updateExistingProduct(Inventory $inventory, array $row) $inventory->category_id = $category->id; } + // Actualizar unidad de medida si se proporciona + if (!empty($row['unidad_medida'])) { + $unit = UnitOfMeasurement::where('name', trim($row['unidad_medida'])) + ->orWhere('abbreviation', trim($row['unidad_medida'])) + ->first(); + + if ($unit) { + $inventory->unit_of_measure_id = $unit->id; + } + } + $inventory->save(); // Actualizar precio de venta e impuesto (NO el costo) diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index 05be84d..44c74fc 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -13,6 +13,7 @@ class InventoryMovement extends Model 'movement_type', 'quantity', 'unit_cost', + 'supplier_id', 'reference_type', 'reference_id', 'user_id', @@ -31,6 +32,10 @@ public function inventory() { return $this->belongsTo(Inventory::class); } + public function supplier() { + return $this->belongsTo(Supplier::class); + } + public function warehouseFrom() { return $this->belongsTo(Warehouse::class, 'warehouse_from_id'); } diff --git a/app/Models/Supplier.php b/app/Models/Supplier.php new file mode 100644 index 0000000..8824bb6 --- /dev/null +++ b/app/Models/Supplier.php @@ -0,0 +1,31 @@ +hasMany(InventoryMovement::class)->where('movement_type', 'entry'); + } + + public function suppliedProducts() + { + return $this->hasManyThrough(Inventory::class, InventoryMovement::class, 'supplier_id', 'id', 'id', 'product_id') + ->where('inventory_movements.movement_type', 'entry') + ->distinct(); + } +} diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 84d3b19..dea329d 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -99,6 +99,7 @@ public function entry(array $data): InventoryMovement 'movement_type' => 'entry', 'quantity' => $quantity, 'unit_cost' => $unitCost, + 'supplier_id' => $data['supplier_id'] ?? null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, 'invoice_reference' => $data['invoice_reference'] ?? null, @@ -182,7 +183,6 @@ public function bulkEntry(array $data): array $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); } - // Registrar movimiento $movement = InventoryMovement::create([ 'inventory_id' => $inventory->id, 'warehouse_from_id' => null, @@ -190,6 +190,7 @@ public function bulkEntry(array $data): array 'movement_type' => 'entry', 'quantity' => $quantity, 'unit_cost' => $unitCost, + 'supplier_id' => $data['supplier_id'] ?? null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, 'invoice_reference' => $data['invoice_reference'], diff --git a/database/migrations/2026_02_10_093124_create_suppliers_table.php b/database/migrations/2026_02_10_093124_create_suppliers_table.php new file mode 100644 index 0000000..200a786 --- /dev/null +++ b/database/migrations/2026_02_10_093124_create_suppliers_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('business_name'); + $table->string('rfc')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('address')->nullable(); + $table->string('postal_code')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + if (!Schema::hasColumn('inventory_movements', 'supplier_id')) { + Schema::table('inventory_movements', function (Blueprint $table) { + $table->foreignId('supplier_id') + ->nullable() + ->after('warehouse_to_id') + ->constrained('suppliers') + ->onDelete('restrict'); + $table->index(['supplier_id', 'movement_type']); + }); + } + } + + public function down(): void + { + Schema::table('inventory_movements', function (Blueprint $table) { + if (Schema::hasColumn('inventory_movements', 'supplier_id')) { + $table->dropForeign(['supplier_id']); + $table->dropIndex(['supplier_id', 'movement_type']); + $table->dropColumn('supplier_id'); + } + }); + + Schema::dropIfExists('suppliers'); + } +}; diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index 1a83a6a..a12427a 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -9,7 +9,6 @@ use App\Models\PermissionType; use App\Models\Role; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\DB; use Notsoweb\LaravelCore\Traits\MySql\RolePermission; use Spatie\Permission\Models\Permission; @@ -179,6 +178,15 @@ public function run(): void $movementsEdit = $this->onEdit('movements', 'Actualizar registro', $movementsType, 'api'); $movementsDestroy = $this->onDestroy('movements', 'Eliminar registro', $movementsType, 'api'); + $suppliers = PermissionType::firstOrCreate([ + 'name' => 'Proveedores' + ]); + + $supplierIndex = $this->onIndex('suppliers', 'Mostrar datos', $suppliers, 'api'); + $supplierCreate = $this->onCreate('suppliers', 'Crear registros', $suppliers, 'api'); + $supplierEdit = $this->onEdit('suppliers', 'Actualizar registro', $suppliers, 'api'); + $supplierDestroy = $this->onDestroy('suppliers', 'Eliminar registro', $suppliers, 'api'); + // ==================== ROLES ==================== // Desarrollador @@ -248,7 +256,11 @@ public function run(): void $unitsIndex, $unitsCreate, $unitsEdit, - $unitsDestroy + $unitsDestroy, + $supplierIndex, + $supplierCreate, + $supplierEdit, + $supplierDestroy ); //Operador PDV (solo permisos de operación de caja y ventas) diff --git a/database/seeders/UnitsSeeder.php b/database/seeders/UnitsSeeder.php index 3670405..5d6966e 100644 --- a/database/seeders/UnitsSeeder.php +++ b/database/seeders/UnitsSeeder.php @@ -11,7 +11,8 @@ class UnitsSeeder extends Seeder public function run(): void { $units = [ - ['name' => 'Pieza', 'abbreviation' => 'u', 'allows_decimals' => false], + ['name' => 'Serials', 'abbreviation' => 'ser', 'allows_decimals' => false], + ['name' => 'Pieza', 'abbreviation' => 'u', 'allows_decimals' => true], ['name' => 'Kilogramo', 'abbreviation' => 'kg', 'allows_decimals' => true], ['name' => 'Litro', 'abbreviation' => 'L', 'allows_decimals' => true], ['name' => 'Metro', 'abbreviation' => 'm', 'allows_decimals' => true], diff --git a/routes/api.php b/routes/api.php index b28e2b3..a12e623 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\App\InvoiceRequestController; use App\Http\Controllers\App\InventoryMovementController; use App\Http\Controllers\App\KardexController; +use App\Http\Controllers\App\SupplierController; use App\Http\Controllers\App\UnitOfMeasurementController; use App\Http\Controllers\App\WarehouseController; use App\Http\Controllers\App\WhatsappController; @@ -142,6 +143,16 @@ Route::post('/{id}/upload', [InvoiceRequestController::class, 'uploadInvoiceFile']); }); + Route::prefix('proveedores')->group(function () { + Route::get('/', [SupplierController::class, 'index']); + Route::post('/', [SupplierController::class, 'store']); + Route::get('/{supplier}', [SupplierController::class, 'show']); + Route::put('/{supplier}', [SupplierController::class, 'update']); + Route::delete('/{supplier}', [SupplierController::class, 'destroy']); + Route::get('/{supplier}/productos', [SupplierController::class, 'products']); + Route::get('/{supplier}/compras', [SupplierController::class, 'purchases']); + }); + // WHATSAPP Route::prefix('whatsapp')->group(function () { Route::post('/send-document', [WhatsappController::class, 'sendDocument']);