feat: agregar soporte para unidades de medida y permitir cantidades decimales en inventarios
This commit is contained in:
parent
7f6db1b83c
commit
41a84d05a0
@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -21,7 +21,7 @@ class InventoryMovement extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'quantity' => 'decimal:3',
|
||||
'unit_cost' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,9 +15,9 @@ class ReturnDetail extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_returned' => 'decimal:3',
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'quantity_returned' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -28,6 +28,7 @@ class SaleDetail extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:3',
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'discount_percentage' => 'decimal:2',
|
||||
|
||||
50
app/Models/UnitOfMeasurement.php
Normal file
50
app/Models/UnitOfMeasurement.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
27
database/seeders/UnitsSeeder.php
Normal file
27
database/seeders/UnitsSeeder.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user