feat: agregar soporte para unidades de medida y permitir cantidades decimales en inventarios

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-09 16:32:07 -06:00
parent 7f6db1b83c
commit 41a84d05a0
13 changed files with 256 additions and 16 deletions

View File

@ -24,6 +24,7 @@ public function rules(): array
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
'category_id' => ['required', 'exists:categories,id'],
'unit_of_measure_id' => ['required', 'exists:units_of_measurement,id'],
'track_serials' => ['nullable', 'boolean'],
// Campos de Price
@ -60,6 +61,7 @@ public function messages(): array
/**
* Validación condicional: retail_price > cost solo si cost > 0
* Y validación track_serials con unidades decimales
*/
public function withValidator($validator)
{
@ -73,6 +75,17 @@ public function withValidator($validator)
'El precio de venta debe ser mayor que el costo.'
);
}
// Validar incompatibilidad track_serials + unidades decimales
if ($this->input('track_serials')) {
$unit = \App\Models\UnitOfMeasurement::find($this->input('unit_of_measure_id'));
if ($unit && $unit->allows_decimals) {
$validator->errors()->add(
'track_serials',
'No se pueden usar seriales con unidades fraccionarias (kg, L, m). Cambia a Unidad/Pieza/Caja o desactiva los seriales.'
);
}
}
});
}
}

View File

@ -26,6 +26,7 @@ public function rules(): array
'sku' => ['nullable', 'string', 'max:50'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
'category_id' => ['nullable', 'exists:categories,id'],
'unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
'track_serials' => ['nullable', 'boolean'],
// Campos de Price
@ -61,6 +62,7 @@ public function messages(): array
/**
* Validación condicional: retail_price > cost solo si cost > 0
* Y validación track_serials con unidades decimales
*/
public function withValidator($validator)
{
@ -74,6 +76,20 @@ public function withValidator($validator)
'El precio de venta debe ser mayor que el costo.'
);
}
// Validar incompatibilidad track_serials + unidades decimales
$trackSerials = $this->input('track_serials', $this->route('inventario')?->track_serials);
$unitId = $this->input('unit_of_measure_id', $this->route('inventario')?->unit_of_measure_id);
if ($trackSerials && $unitId) {
$unit = \App\Models\UnitOfMeasurement::find($unitId);
if ($unit && $unit->allows_decimals) {
$validator->errors()->add(
'track_serials',
'No se pueden usar seriales con unidades fraccionarias (kg, L, m). Cambia a Unidad/Pieza/Caja o desactiva los seriales.'
);
}
}
});
}
}

View File

@ -20,6 +20,7 @@ class Inventory extends Model
{
protected $fillable = [
'category_id',
'unit_of_measure_id',
'name',
'sku',
'barcode',
@ -42,29 +43,29 @@ public function warehouses()
}
/**
* Stock total en todos los almacenes
* Stock total en todos los almacenes (ahora soporta decimales)
*/
public function getStockAttribute(): int
public function getStockAttribute(): float
{
return $this->warehouses()->sum('inventory_warehouse.stock');
return (float) $this->warehouses()->sum('inventory_warehouse.stock');
}
/**
* Alias para compatibilidad
*/
public function getTotalStockAttribute(): int
public function getTotalStockAttribute(): float
{
return $this->stock;
}
/**
* Stock en un almacén específico
* Stock en un almacén específico (ahora soporta decimales)
*/
public function stockInWarehouse(int $warehouseId): int
public function stockInWarehouse(int $warehouseId): float
{
return $this->warehouses()
return (float) ($this->warehouses()
->where('warehouse_id', $warehouseId)
->value('inventory_warehouse.stock') ?? 0;
->value('inventory_warehouse.stock') ?? 0);
}
public function category()
@ -72,6 +73,11 @@ public function category()
return $this->belongsTo(Category::class);
}
public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function price()
{
return $this->hasOne(Price::class);

View File

@ -21,7 +21,7 @@ class InventoryMovement extends Model
];
protected $casts = [
'quantity' => 'integer',
'quantity' => 'decimal:3',
'unit_cost' => 'decimal:2',
'created_at' => 'datetime',
];

View File

@ -15,9 +15,9 @@ class InventoryWarehouse extends Model
];
protected $casts = [
'stock' => 'integer',
'min_stock' => 'integer',
'max_stock' => 'integer',
'stock' => 'decimal:3',
'min_stock' => 'decimal:3',
'max_stock' => 'decimal:3',
];
// Relaciones
@ -38,7 +38,7 @@ public function isOverStock(): bool {
return $this->max_stock && $this->stock >= $this->max_stock;
}
public function hasAvailableStock(int $quantity): bool {
public function hasAvailableStock(float $quantity): bool {
return $this->stock >= $quantity;
}
}

View File

@ -15,9 +15,9 @@ class ReturnDetail extends Model
];
protected $casts = [
'quantity_returned' => 'decimal:3',
'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2',
'quantity_returned' => 'integer',
];
/**

View File

@ -28,6 +28,7 @@ class SaleDetail extends Model
];
protected $casts = [
'quantity' => 'decimal:3',
'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2',
'discount_percentage' => 'decimal:2',

View File

@ -0,0 +1,50 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Unidad de Medida
*
* Define las unidades de medida disponibles para los productos
* (Unidad, Kilogramo, Litro, Metro, etc.)
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class UnitOfMeasurement extends Model
{
protected $table = 'units_of_measurement';
protected $fillable = [
'name',
'abbreviation',
'allows_decimals',
'is_active',
];
protected $casts = [
'allows_decimals' => 'boolean',
'is_active' => 'boolean',
];
/**
* Scope para unidades activas
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Relación con inventarios
*/
public function inventories()
{
return $this->hasMany(Inventory::class, 'unit_of_measure_id');
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('units_of_measurement', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('abbreviation', 10);
$table->boolean('allows_decimals')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('units_of_measurement');
}
};

View File

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. inventory_warehouse.stock
Schema::table('inventory_warehouse', function (Blueprint $table) {
$table->decimal('stock', 15, 3)->default(0)->change();
$table->decimal('min_stock', 15, 3)->default(0)->change();
$table->decimal('max_stock', 15, 3)->default(0)->change();
});
// 2. sale_details.quantity
Schema::table('sale_details', function (Blueprint $table) {
$table->decimal('quantity', 15, 3)->change();
});
// 3. inventory_movements.quantity
Schema::table('inventory_movements', function (Blueprint $table) {
$table->decimal('quantity', 15, 3)->change();
});
// 4. return_details.quantity_returned
Schema::table('return_details', function (Blueprint $table) {
$table->decimal('quantity_returned', 15, 3)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Revertir a integer
Schema::table('inventory_warehouse', function (Blueprint $table) {
$table->integer('stock')->default(0)->change();
$table->integer('min_stock')->default(0)->change();
$table->integer('max_stock')->default(0)->change();
});
Schema::table('sale_details', function (Blueprint $table) {
$table->integer('quantity')->change();
});
Schema::table('inventory_movements', function (Blueprint $table) {
$table->integer('quantity')->change();
});
Schema::table('return_details', function (Blueprint $table) {
$table->integer('quantity_returned')->change();
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventories', function (Blueprint $table) {
$table->foreignId('unit_of_measure_id')
->after('category_id')
->constrained('units_of_measurement')
->onDelete('restrict');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventories', function (Blueprint $table) {
$table->dropForeign(['unit_of_measure_id']);
$table->dropColumn('unit_of_measure_id');
});
}
};

View File

@ -22,5 +22,7 @@ public function run(): void
$this->call(RoleSeeder::class);
$this->call(UserSeeder::class);
$this->call(SettingSeeder::class);
$this->call(ClientTierSeeder::class);
$this->call(UnitsSeeder::class);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use App\Models\UnitOfMeasurement;
use Illuminate\Database\Seeder;
class UnitsSeeder extends Seeder
{
public function run(): void
{
$units = [
['name' => 'Pieza', 'abbreviation' => 'u', 'allows_decimals' => false],
['name' => 'Kilogramo', 'abbreviation' => 'kg', 'allows_decimals' => true],
['name' => 'Litro', 'abbreviation' => 'L', 'allows_decimals' => true],
['name' => 'Metro', 'abbreviation' => 'm', 'allows_decimals' => true],
];
foreach ($units as $unit) {
UnitOfMeasurement::firstOrCreate(
['name' => $unit['name']],
$unit
);
}
}
}