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',
'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
],

View File

@ -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) {

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')) {
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',

View File

@ -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,7 +65,7 @@ public function model(array $row)
}
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();
@ -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)

View File

@ -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');
}

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',
'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'],

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\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)

View File

@ -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],

View File

@ -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']);