feat: agregar gestión de proveedores y unidad de medida en inventarios

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-10 16:39:36 -06:00
parent 562397402c
commit aff2448356
12 changed files with 320 additions and 20 deletions

View File

@ -187,6 +187,7 @@ public function downloadTemplate()
'sku', 'sku',
'codigo_barras', 'codigo_barras',
'categoria', 'categoria',
'unidad_medida',
'precio_venta', 'precio_venta',
'impuesto' 'impuesto'
]; ];
@ -197,22 +198,25 @@ public function downloadTemplate()
'sku' => 'SAM-A55-BLK', 'sku' => 'SAM-A55-BLK',
'codigo_barras' => '7502276853456', 'codigo_barras' => '7502276853456',
'categoria' => 'Electrónica', 'categoria' => 'Electrónica',
'unidad_medida' => 'Pieza',
'precio_venta' => 7500.00, 'precio_venta' => 7500.00,
'impuesto' => 16 'impuesto' => 16
], ],
[ [
'nombre' => 'Coca Cola 600ml', 'nombre' => 'Cable UTP CAT6 (Metro)',
'sku' => 'COCA-600', 'sku' => 'UTP6-MTR',
'codigo_barras' => '750227686666', 'codigo_barras' => '750227686666',
'categoria' => 'Bebidas', 'categoria' => 'Cables',
'precio_venta' => 18.00, 'unidad_medida' => 'Metro',
'impuesto' => 8 'precio_venta' => 50.00,
'impuesto' => 16
], ],
[ [
'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',
'unidad_medida' => 'Pieza',
'precio_venta' => 12000.00, 'precio_venta' => 12000.00,
'impuesto' => 16 'impuesto' => 16
], ],

View File

@ -25,7 +25,7 @@ public function __construct(
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user']) $query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier'])
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
if ($request->has('q') && $request->q){ if ($request->has('q') && $request->q){
@ -69,7 +69,7 @@ public function index(Request $request)
*/ */
public function show(int $id) public function show(int $id)
{ {
$movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user']) $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user', 'supplier'])
->find($id); ->find($id);
if (!$movement) { if (!$movement) {

View File

@ -0,0 +1,124 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Supplier;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class SupplierController extends Controller
{
public function index(Request $request)
{
$query = Supplier::query();
// Filtro por búsqueda
if ($request->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
]);
}
}

View File

@ -17,6 +17,7 @@ public function rules(): array
if ($this->has('products')) { if ($this->has('products')) {
return [ return [
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'supplier_id' => 'nullable|exists:suppliers,id',
'invoice_reference' => 'required|string|max:255', 'invoice_reference' => 'required|string|max:255',
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
@ -33,6 +34,7 @@ public function rules(): array
return [ return [
'inventory_id' => 'required|exists:inventories,id', 'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'supplier_id' => 'nullable|exists:suppliers,id',
'quantity' => 'required|numeric|min:0.001', 'quantity' => 'required|numeric|min:0.001',
'unit_cost' => 'required|numeric|min:0', 'unit_cost' => 'required|numeric|min:0',
'invoice_reference' => 'required|string|max:255', 'invoice_reference' => 'required|string|max:255',

View File

@ -6,6 +6,7 @@
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\UnitOfMeasurement;
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;
@ -47,6 +48,7 @@ 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,
'unidad_medida' => $row['unidad_medida'] ?? null,
'precio_venta' => $row['precio_venta'] ?? null, 'precio_venta' => $row['precio_venta'] ?? null,
'impuesto' => $row['impuesto'] ?? null, 'impuesto' => $row['impuesto'] ?? null,
]; ];
@ -63,7 +65,7 @@ public function model(array $row)
} }
try { try {
// Buscar producto existente por SKU o código de barras
$existingInventory = null; $existingInventory = null;
if(!empty($row['sku'])) { if(!empty($row['sku'])) {
$existingInventory = Inventory::where('sku', trim($row['sku']))->first(); $existingInventory = Inventory::where('sku', trim($row['sku']))->first();
@ -78,8 +80,27 @@ public function model(array $row)
} }
// Producto nuevo // 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) { if ($precioVenta <= 0) {
$this->skipped++; $this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta debe ser mayor a 0"; $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; $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 = 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->unit_of_measure_id = $unitId;
$inventory->is_active = true; $inventory->is_active = true;
$inventory->track_serials = false; // Por defecto no rastrea seriales $inventory->track_serials = false;
$inventory->save(); $inventory->save();
// Crear el precio del producto (sin costo inicial) // Crear el precio del producto
Price::create([ Price::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'cost' => 0, // El costo se actualiza con movimientos de entrada 'cost' => 0,
'retail_price' => $precioVenta, 'retail_price' => $precioVenta,
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0, 'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
]); ]);
@ -119,7 +167,7 @@ public function model(array $row)
return $inventory; return $inventory;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->skipped++; $this->skipped++;
$this->errors[] = "Error en fila: " . $e->getMessage(); $this->errors[] = "Error creando producto '{$row['nombre']}': " . $e->getMessage();
return null; return null;
} }
} }
@ -148,6 +196,17 @@ private function updateExistingProduct(Inventory $inventory, array $row)
$inventory->category_id = $category->id; $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(); $inventory->save();
// Actualizar precio de venta e impuesto (NO el costo) // Actualizar precio de venta e impuesto (NO el costo)

View File

@ -13,6 +13,7 @@ class InventoryMovement extends Model
'movement_type', 'movement_type',
'quantity', 'quantity',
'unit_cost', 'unit_cost',
'supplier_id',
'reference_type', 'reference_type',
'reference_id', 'reference_id',
'user_id', 'user_id',
@ -31,6 +32,10 @@ public function inventory() {
return $this->belongsTo(Inventory::class); return $this->belongsTo(Inventory::class);
} }
public function supplier() {
return $this->belongsTo(Supplier::class);
}
public function warehouseFrom() { public function warehouseFrom() {
return $this->belongsTo(Warehouse::class, 'warehouse_from_id'); return $this->belongsTo(Warehouse::class, 'warehouse_from_id');
} }

31
app/Models/Supplier.php Normal file
View File

@ -0,0 +1,31 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Descripción
*/
class Supplier extends Model
{
protected $fillable = [
'business_name',
'rfc',
'email',
'phone',
'address',
'postal_code',
'notes'
];
public function inventoryMovements()
{
return $this->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();
}
}

View File

@ -99,6 +99,7 @@ public function entry(array $data): InventoryMovement
'movement_type' => 'entry', 'movement_type' => 'entry',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_cost' => $unitCost, 'unit_cost' => $unitCost,
'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'] ?? null, 'invoice_reference' => $data['invoice_reference'] ?? null,
@ -182,7 +183,6 @@ public function bulkEntry(array $data): array
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
} }
// Registrar movimiento
$movement = InventoryMovement::create([ $movement = InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => null, 'warehouse_from_id' => null,
@ -190,6 +190,7 @@ public function bulkEntry(array $data): array
'movement_type' => 'entry', 'movement_type' => 'entry',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_cost' => $unitCost, 'unit_cost' => $unitCost,
'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
'invoice_reference' => $data['invoice_reference'], 'invoice_reference' => $data['invoice_reference'],

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Preguntamos si la tabla NO existe antes de crearla
if (!Schema::hasTable('suppliers')) {
Schema::create('suppliers', function (Blueprint $table) {
$table->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');
}
};

View File

@ -9,7 +9,6 @@
use App\Models\PermissionType; use App\Models\PermissionType;
use App\Models\Role; use App\Models\Role;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Notsoweb\LaravelCore\Traits\MySql\RolePermission; use Notsoweb\LaravelCore\Traits\MySql\RolePermission;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
@ -179,6 +178,15 @@ public function run(): void
$movementsEdit = $this->onEdit('movements', 'Actualizar registro', $movementsType, 'api'); $movementsEdit = $this->onEdit('movements', 'Actualizar registro', $movementsType, 'api');
$movementsDestroy = $this->onDestroy('movements', 'Eliminar 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 ==================== // ==================== ROLES ====================
// Desarrollador // Desarrollador
@ -248,7 +256,11 @@ public function run(): void
$unitsIndex, $unitsIndex,
$unitsCreate, $unitsCreate,
$unitsEdit, $unitsEdit,
$unitsDestroy $unitsDestroy,
$supplierIndex,
$supplierCreate,
$supplierEdit,
$supplierDestroy
); );
//Operador PDV (solo permisos de operación de caja y ventas) //Operador PDV (solo permisos de operación de caja y ventas)

View File

@ -11,7 +11,8 @@ class UnitsSeeder extends Seeder
public function run(): void public function run(): void
{ {
$units = [ $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' => 'Kilogramo', 'abbreviation' => 'kg', 'allows_decimals' => true],
['name' => 'Litro', 'abbreviation' => 'L', 'allows_decimals' => true], ['name' => 'Litro', 'abbreviation' => 'L', 'allows_decimals' => true],
['name' => 'Metro', 'abbreviation' => 'm', 'allows_decimals' => true], ['name' => 'Metro', 'abbreviation' => 'm', 'allows_decimals' => true],

View File

@ -15,6 +15,7 @@
use App\Http\Controllers\App\InvoiceRequestController; use App\Http\Controllers\App\InvoiceRequestController;
use App\Http\Controllers\App\InventoryMovementController; use App\Http\Controllers\App\InventoryMovementController;
use App\Http\Controllers\App\KardexController; use App\Http\Controllers\App\KardexController;
use App\Http\Controllers\App\SupplierController;
use App\Http\Controllers\App\UnitOfMeasurementController; use App\Http\Controllers\App\UnitOfMeasurementController;
use App\Http\Controllers\App\WarehouseController; use App\Http\Controllers\App\WarehouseController;
use App\Http\Controllers\App\WhatsappController; use App\Http\Controllers\App\WhatsappController;
@ -142,6 +143,16 @@
Route::post('/{id}/upload', [InvoiceRequestController::class, 'uploadInvoiceFile']); 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 // WHATSAPP
Route::prefix('whatsapp')->group(function () { Route::prefix('whatsapp')->group(function () {
Route::post('/send-document', [WhatsappController::class, 'sendDocument']); Route::post('/send-document', [WhatsappController::class, 'sendDocument']);