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'],
|
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
|
||||||
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
|
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
|
||||||
'category_id' => ['required', 'exists:categories,id'],
|
'category_id' => ['required', 'exists:categories,id'],
|
||||||
|
'unit_of_measure_id' => ['required', 'exists:units_of_measurement,id'],
|
||||||
'track_serials' => ['nullable', 'boolean'],
|
'track_serials' => ['nullable', 'boolean'],
|
||||||
|
|
||||||
// Campos de Price
|
// Campos de Price
|
||||||
@ -60,6 +61,7 @@ public function messages(): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validación condicional: retail_price > cost solo si cost > 0
|
* Validación condicional: retail_price > cost solo si cost > 0
|
||||||
|
* Y validación track_serials con unidades decimales
|
||||||
*/
|
*/
|
||||||
public function withValidator($validator)
|
public function withValidator($validator)
|
||||||
{
|
{
|
||||||
@ -73,6 +75,17 @@ public function withValidator($validator)
|
|||||||
'El precio de venta debe ser mayor que el costo.'
|
'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'],
|
'sku' => ['nullable', 'string', 'max:50'],
|
||||||
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
|
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
|
||||||
'category_id' => ['nullable', 'exists:categories,id'],
|
'category_id' => ['nullable', 'exists:categories,id'],
|
||||||
|
'unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
|
||||||
'track_serials' => ['nullable', 'boolean'],
|
'track_serials' => ['nullable', 'boolean'],
|
||||||
|
|
||||||
// Campos de Price
|
// Campos de Price
|
||||||
@ -61,6 +62,7 @@ public function messages(): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validación condicional: retail_price > cost solo si cost > 0
|
* Validación condicional: retail_price > cost solo si cost > 0
|
||||||
|
* Y validación track_serials con unidades decimales
|
||||||
*/
|
*/
|
||||||
public function withValidator($validator)
|
public function withValidator($validator)
|
||||||
{
|
{
|
||||||
@ -74,6 +76,20 @@ public function withValidator($validator)
|
|||||||
'El precio de venta debe ser mayor que el costo.'
|
'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 = [
|
protected $fillable = [
|
||||||
'category_id',
|
'category_id',
|
||||||
|
'unit_of_measure_id',
|
||||||
'name',
|
'name',
|
||||||
'sku',
|
'sku',
|
||||||
'barcode',
|
'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
|
* Alias para compatibilidad
|
||||||
*/
|
*/
|
||||||
public function getTotalStockAttribute(): int
|
public function getTotalStockAttribute(): float
|
||||||
{
|
{
|
||||||
return $this->stock;
|
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)
|
->where('warehouse_id', $warehouseId)
|
||||||
->value('inventory_warehouse.stock') ?? 0;
|
->value('inventory_warehouse.stock') ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function category()
|
public function category()
|
||||||
@ -72,6 +73,11 @@ public function category()
|
|||||||
return $this->belongsTo(Category::class);
|
return $this->belongsTo(Category::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function unitOfMeasure()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(UnitOfMeasurement::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function price()
|
public function price()
|
||||||
{
|
{
|
||||||
return $this->hasOne(Price::class);
|
return $this->hasOne(Price::class);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class InventoryMovement extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'quantity' => 'integer',
|
'quantity' => 'decimal:3',
|
||||||
'unit_cost' => 'decimal:2',
|
'unit_cost' => 'decimal:2',
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|||||||
@ -15,9 +15,9 @@ class InventoryWarehouse extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'stock' => 'integer',
|
'stock' => 'decimal:3',
|
||||||
'min_stock' => 'integer',
|
'min_stock' => 'decimal:3',
|
||||||
'max_stock' => 'integer',
|
'max_stock' => 'decimal:3',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Relaciones
|
// Relaciones
|
||||||
@ -38,7 +38,7 @@ public function isOverStock(): bool {
|
|||||||
return $this->max_stock && $this->stock >= $this->max_stock;
|
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;
|
return $this->stock >= $quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,9 +15,9 @@ class ReturnDetail extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'quantity_returned' => 'decimal:3',
|
||||||
'unit_price' => 'decimal:2',
|
'unit_price' => 'decimal:2',
|
||||||
'subtotal' => 'decimal:2',
|
'subtotal' => 'decimal:2',
|
||||||
'quantity_returned' => 'integer',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class SaleDetail extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'quantity' => 'decimal:3',
|
||||||
'unit_price' => 'decimal:2',
|
'unit_price' => 'decimal:2',
|
||||||
'subtotal' => 'decimal:2',
|
'subtotal' => 'decimal:2',
|
||||||
'discount_percentage' => '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(RoleSeeder::class);
|
||||||
$this->call(UserSeeder::class);
|
$this->call(UserSeeder::class);
|
||||||
$this->call(SettingSeeder::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