feat: Implement unit equivalence functionality

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-24 01:30:11 -06:00
parent bbc95f4ea2
commit e5e3412fea
29 changed files with 755 additions and 183 deletions

View File

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\UnitEquivalenceStoreRequest;
use App\Http\Requests\App\UnitEquivalenceUpdateRequest;
use App\Models\Inventory;
use App\Models\UnitEquivalence;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class UnitEquivalenceController extends Controller
{
public function index(Inventory $inventory)
{
$equivalences = $inventory->unitEquivalences()
->with('unitOfMeasure')
->get()
->map(function ($eq) use ($inventory) {
$basePrice = $inventory->price?->retail_price ?? 0;
return [
'id' => $eq->id,
'unit_of_measure_id' => $eq->unit_of_measure_id,
'unit_name' => $eq->unitOfMeasure->name,
'unit_abbreviation' => $eq->unitOfMeasure->abbreviation,
'conversion_factor' => $eq->conversion_factor,
'retail_price' => $eq->retail_price ?? round($basePrice * $eq->conversion_factor, 2),
'is_active' => $eq->is_active,
'created_at' => $eq->created_at,
];
});
return ApiResponse::OK->response([
'equivalences' => $equivalences,
'base_unit' => $inventory->unitOfMeasure,
]);
}
public function store(UnitEquivalenceStoreRequest $request, Inventory $inventory)
{
$equivalence = $inventory->unitEquivalences()->create($request->validated());
$equivalence->load('unitOfMeasure');
return ApiResponse::CREATED->response([
'message' => 'Equivalencia creada correctamente.',
'equivalence' => $equivalence,
]);
}
public function update(UnitEquivalenceUpdateRequest $request, Inventory $inventory, UnitEquivalence $equivalencia)
{
$equivalencia->update($request->validated());
return ApiResponse::OK->response([
'message' => 'Equivalencia actualizada correctamente.',
'equivalence' => $equivalencia->fresh('unitOfMeasure'),
]);
}
public function destroy(Inventory $inventory, UnitEquivalence $equivalencia)
{
$equivalencia->delete();
return ApiResponse::OK->response([
'message' => 'Equivalencia eliminada correctamente.',
]);
}
}

View File

@ -7,6 +7,7 @@
use App\Http\Requests\App\UnitOfMeasurementStoreRequest; use App\Http\Requests\App\UnitOfMeasurementStoreRequest;
use App\Http\Requests\App\UnitOfMeasurementUpdateRequest; use App\Http\Requests\App\UnitOfMeasurementUpdateRequest;
use App\Models\UnitOfMeasurement; use App\Models\UnitOfMeasurement;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse; use Notsoweb\ApiResponse\Enums\ApiResponse;
/** /**
@ -14,9 +15,19 @@
*/ */
class UnitOfMeasurementController extends Controller class UnitOfMeasurementController extends Controller
{ {
public function index() public function index(Request $request)
{ {
$units = UnitOfMeasurement::orderBy('name')->get(); $query = UnitOfMeasurement::query();
// Filtro por búsqueda
if ($request->has('q')) {
$query->where(function($q) use ($request) {
$q->where('name', 'like', "%{$request->q}%")
->orWhere('abbreviation', 'like', "%{$request->q}%");
});
}
$units = $query->orderBy('name')->paginate(config('app.pagination'));
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'units' => $units 'units' => $units

View File

@ -28,6 +28,7 @@ public function rules(): array
'products.*.unit_cost' => 'required|numeric|min:0', 'products.*.unit_cost' => 'required|numeric|min:0',
'products.*.serial_numbers' => 'nullable|array', 'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number', 'products.*.serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
]; ];
} }
@ -41,6 +42,7 @@ public function rules(): array
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array', 'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number', 'serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number',
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
]; ];
} }
@ -113,7 +115,32 @@ public function withValidator($validator)
); );
} }
// VALIDACIÓN 2: No permitir seriales con unidades decimales // VALIDACIÓN 2: Validar equivalencia de unidad si se especifica
$unitOfMeasureId = $product['unit_of_measure_id'] ?? null;
if ($unitOfMeasureId && $unitOfMeasureId != $inventory->unit_of_measure_id) {
$hasEquivalence = $inventory->unitEquivalences()
->where('unit_of_measure_id', $unitOfMeasureId)
->where('is_active', true)
->exists();
if (! $hasEquivalence) {
$field = $this->has('products') ? "products.{$index}.unit_of_measure_id" : 'unit_of_measure_id';
$validator->errors()->add(
$field,
"El producto '{$inventory->name}' no tiene equivalencia configurada para esta unidad de medida."
);
}
if ($inventory->track_serials) {
$field = $this->has('products') ? "products.{$index}.unit_of_measure_id" : 'unit_of_measure_id';
$validator->errors()->add(
$field,
'No se pueden usar equivalencias de unidad para productos con rastreo de seriales.'
);
}
}
// VALIDACIÓN 3: No permitir seriales con unidades decimales
$serialNumbers = $product['serial_numbers'] ?? null; $serialNumbers = $product['serial_numbers'] ?? null;
if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) {

View File

@ -26,6 +26,7 @@ public function rules(): array
'products.*.quantity' => 'required|numeric|min:0.001', 'products.*.quantity' => 'required|numeric|min:0.001',
'products.*.serial_numbers' => 'nullable|array', 'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', 'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
]; ];
} }
@ -37,6 +38,7 @@ public function rules(): array
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array', 'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', 'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
]; ];
} }

View File

@ -2,9 +2,7 @@
namespace App\Http\Requests\App; namespace App\Http\Requests\App;
use App\Models\InventoryMovement;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class InventoryMovementUpdateRequest extends FormRequest class InventoryMovementUpdateRequest extends FormRequest
{ {
@ -24,6 +22,7 @@ public function rules(): array
'notes' => 'sometimes|nullable|string|max:500', 'notes' => 'sometimes|nullable|string|max:500',
'serial_numbers' => ['nullable', 'array'], 'serial_numbers' => ['nullable', 'array'],
'serial_numbers.*' => ['string', 'max:255'], 'serial_numbers.*' => ['string', 'max:255'],
'unit_of_measure_id' => 'sometimes|nullable|exists:units_of_measurement,id',
]; ];
} }

View File

@ -21,7 +21,7 @@ public function rules(): array
return [ return [
// Campos de Inventory // Campos de Inventory
'name' => ['required', 'string', 'max:100'], 'name' => ['required', 'string', 'max:100'],
'key_sat' => ['nullable', 'integer', 'max:9'], 'key_sat' => ['nullable', 'string', 'max:20'],
'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'],

View File

@ -27,6 +27,7 @@ public function rules(): array
'products.*.quantity' => 'required|numeric|min:0.001', 'products.*.quantity' => 'required|numeric|min:0.001',
'products.*.serial_numbers' => 'nullable|array', 'products.*.serial_numbers' => 'nullable|array',
'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', 'products.*.serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'products.*.unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
]; ];
} }
@ -39,6 +40,7 @@ public function rules(): array
'notes' => 'nullable|string|max:1000', 'notes' => 'nullable|string|max:1000',
'serial_numbers' => 'nullable|array', 'serial_numbers' => 'nullable|array',
'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', 'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number',
'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id',
]; ];
} }

View File

@ -23,7 +23,7 @@ public function rules(): array
return [ return [
// Campos de Inventory // Campos de Inventory
'name' => ['nullable', 'string', 'max:100'], 'name' => ['nullable', 'string', 'max:100'],
'key_sat' => ['nullable', 'string', 'max:9'], 'key_sat' => ['nullable', 'string', 'max:20'],
'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'],

View File

@ -2,11 +2,11 @@
namespace App\Http\Requests\App; namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest; use App\Models\InventorySerial;
use App\Models\ReturnDetail;
use App\Models\Sale; use App\Models\Sale;
use App\Models\SaleDetail; use App\Models\SaleDetail;
use App\Models\ReturnDetail; use Illuminate\Foundation\Http\FormRequest;
use App\Models\InventorySerial;
class ReturnStoreRequest extends FormRequest class ReturnStoreRequest extends FormRequest
{ {
@ -31,6 +31,7 @@ public function rules(): array
'items.*.quantity_returned' => ['required', 'integer', 'min:1'], 'items.*.quantity_returned' => ['required', 'integer', 'min:1'],
'items.*.serial_numbers' => ['nullable', 'array'], 'items.*.serial_numbers' => ['nullable', 'array'],
'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'], 'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'],
'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
]; ];
} }
@ -94,6 +95,7 @@ public function withValidator($validator)
"items.{$index}.sale_detail_id", "items.{$index}.sale_detail_id",
'El producto no pertenece a esta venta.' 'El producto no pertenece a esta venta.'
); );
continue; continue;
} }

View File

@ -53,6 +53,9 @@ public function rules(): array
// Productos normales: ["SN-001", "SN-002"] // Productos normales: ["SN-001", "SN-002"]
// Bundles: { inventory_id: ["SN-001", "SN-002"], ... } // Bundles: { inventory_id: ["SN-001", "SN-002"], ... }
'items.*.serial_numbers' => ['nullable', 'array'], 'items.*.serial_numbers' => ['nullable', 'array'],
// Equivalencia de unidad de medida
'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'],
]; ];
} }

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UnitEquivalenceStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'unit_of_measure_id' => [
'required',
'exists:units_of_measurement,id',
Rule::unique('unit_equivalences')->where(function ($query) {
return $query->where('inventory_id', $this->route('inventory')->id);
}),
],
'conversion_factor' => ['required', 'numeric', 'min:0.001'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'is_active' => ['boolean'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$inventory = $this->route('inventory');
if ($inventory->track_serials) {
$validator->errors()->add(
'unit_of_measure_id',
'No se pueden crear equivalencias para productos con rastreo de seriales.'
);
}
if ($this->unit_of_measure_id == $inventory->unit_of_measure_id) {
$validator->errors()->add(
'unit_of_measure_id',
'No se puede crear una equivalencia con la unidad base del producto.'
);
}
});
}
public function messages(): array
{
return [
'unit_of_measure_id.required' => 'La unidad de medida es obligatoria.',
'unit_of_measure_id.exists' => 'La unidad de medida seleccionada no existe.',
'unit_of_measure_id.unique' => 'Ya existe una equivalencia para esta unidad en este producto.',
'conversion_factor.required' => 'El factor de conversión es obligatorio.',
'conversion_factor.numeric' => 'El factor de conversión debe ser un número.',
'conversion_factor.min' => 'El factor de conversión debe ser mayor a 0.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class UnitEquivalenceUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'conversion_factor' => ['sometimes', 'numeric', 'min:0.001'],
'retail_price' => ['nullable', 'numeric', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
];
}
public function messages(): array
{
return [
'conversion_factor.numeric' => 'El factor de conversión debe ser un número.',
'conversion_factor.min' => 'El factor de conversión debe ser mayor a 0.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
];
}
}

View File

@ -17,7 +17,7 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
$unitId = $this->route('unidad'); $unitId = $this->route('unit')?->id ?? $this->route('unit');
return [ return [
'name' => ['sometimes', 'string', 'max:50', 'unique:units_of_measurement,name,' . $unitId], 'name' => ['sometimes', 'string', 'max:50', 'unique:units_of_measurement,name,' . $unitId],
@ -45,15 +45,18 @@ public function messages(): array
public function withValidator($validator) public function withValidator($validator)
{ {
$validator->after(function ($validator) { $validator->after(function ($validator) {
$unitId = $this->route('unidad'); $unit = $this->route('unit');
$unit = UnitOfMeasurement::find($unitId);
if ($unit && !($unit instanceof UnitOfMeasurement)) {
$unit = UnitOfMeasurement::find($unit);
}
if (!$unit) { if (!$unit) {
return; return;
} }
// Si se intenta cambiar allows_decimals // Si se intenta cambiar allows_decimals
if ($this->has('allows_decimals') && $this->allows_decimals !== $unit->allows_decimals) { if ($this->has('allows_decimals') && (bool) $this->allows_decimals !== (bool) $unit->allows_decimals) {
// Verificar si hay productos con track_serials usando esta unidad // Verificar si hay productos con track_serials usando esta unidad
$hasProductsWithSerials = $unit->inventories() $hasProductsWithSerials = $unit->inventories()
->where('track_serials', true) ->where('track_serials', true)

View File

@ -1,9 +1,11 @@
<?php namespace App\Models; <?php
namespace App\Models;
/** /**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved * @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/ */
use App\Services\InventoryMovementService; use App\Services\InventoryMovementService;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -98,6 +100,57 @@ public function availableSerials()
->where('status', 'disponible'); ->where('status', 'disponible');
} }
public function unitEquivalences()
{
return $this->hasMany(UnitEquivalence::class);
}
/**
* Obtener factor de conversión para una unidad dada.
* Retorna 1.0 si es la unidad base del producto.
*/
public function getConversionFactor(int $unitOfMeasureId): float
{
if ($this->unit_of_measure_id === $unitOfMeasureId) {
return 1.0;
}
$equivalence = $this->unitEquivalences()
->where('unit_of_measure_id', $unitOfMeasureId)
->where('is_active', true)
->first();
if (! $equivalence) {
throw new \Exception(
"No existe equivalencia activa para la unidad seleccionada en el producto '{$this->name}'."
);
}
return (float) $equivalence->conversion_factor;
}
/**
* Convertir cantidad de cualquier unidad a unidades base
*/
public function convertToBaseUnits(float $quantity, int $unitOfMeasureId): float
{
return round($quantity * $this->getConversionFactor($unitOfMeasureId), 3);
}
/**
* Convertir costo por unidad de entrada a costo por unidad base
*/
public function convertCostToBaseUnit(float $costPerUnit, int $unitOfMeasureId): float
{
$factor = $this->getConversionFactor($unitOfMeasureId);
if ($factor <= 0) {
throw new \Exception('Factor de conversión inválido.');
}
return round($costPerUnit / $factor, 2);
}
/** /**
* Stock basado en seriales disponibles (para productos con track_serials) * Stock basado en seriales disponibles (para productos con track_serials)
*/ */

View File

@ -1,4 +1,6 @@
<?php namespace App\Models; <?php
namespace App\Models;
use App\Observers\InventoryMovementObserver; use App\Observers\InventoryMovementObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Attributes\ObservedBy;
@ -9,6 +11,7 @@
class InventoryMovement extends Model class InventoryMovement extends Model
{ {
use Extended; use Extended;
const UPDATED_AT = null; const UPDATED_AT = null;
protected $fillable = [ protected $fillable = [
@ -17,7 +20,10 @@ class InventoryMovement extends Model
'warehouse_to_id', 'warehouse_to_id',
'movement_type', 'movement_type',
'quantity', 'quantity',
'unit_of_measure_id',
'unit_quantity',
'unit_cost', 'unit_cost',
'unit_cost_original',
'supplier_id', 'supplier_id',
'reference_type', 'reference_type',
'reference_id', 'reference_id',
@ -28,54 +34,72 @@ class InventoryMovement extends Model
protected $casts = [ protected $casts = [
'quantity' => 'decimal:3', 'quantity' => 'decimal:3',
'unit_quantity' => 'decimal:3',
'unit_cost' => 'decimal:2', 'unit_cost' => 'decimal:2',
'unit_cost_original' => 'decimal:2',
'created_at' => 'datetime', 'created_at' => 'datetime',
]; ];
// Relaciones // Relaciones
public function inventory() { public function inventory()
{
return $this->belongsTo(Inventory::class); return $this->belongsTo(Inventory::class);
} }
public function supplier() { public function supplier()
{
return $this->belongsTo(Supplier::class); 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');
} }
public function warehouseTo() { public function warehouseTo()
{
return $this->belongsTo(Warehouse::class, 'warehouse_to_id'); return $this->belongsTo(Warehouse::class, 'warehouse_to_id');
} }
public function user() { public function user()
{
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function serials() { public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function serials()
{
return $this->hasMany(InventorySerial::class, 'movement_id'); return $this->hasMany(InventorySerial::class, 'movement_id');
} }
// Relación polimórfica para la referencia // Relación polimórfica para la referencia
public function reference() { public function reference()
{
return $this->morphTo(); return $this->morphTo();
} }
// Scopes // Scopes
public function scopeByType($query, string $type) { public function scopeByType($query, string $type)
{
return $query->where('movement_type', $type); return $query->where('movement_type', $type);
} }
public function scopeEntry($query) { public function scopeEntry($query)
{
return $query->where('movement_type', 'entry'); return $query->where('movement_type', 'entry');
} }
public function scopeExit($query) { public function scopeExit($query)
{
return $query->where('movement_type', 'exit'); return $query->where('movement_type', 'exit');
} }
public function scopeTransfer($query) { public function scopeTransfer($query)
{
return $query->where('movement_type', 'transfer'); return $query->where('movement_type', 'transfer');
} }
} }

View File

@ -1,4 +1,6 @@
<?php namespace App\Models; <?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -10,12 +12,15 @@ class ReturnDetail extends Model
'inventory_id', 'inventory_id',
'product_name', 'product_name',
'quantity_returned', 'quantity_returned',
'unit_of_measure_id',
'unit_quantity_returned',
'unit_price', 'unit_price',
'subtotal', 'subtotal',
]; ];
protected $casts = [ protected $casts = [
'quantity_returned' => 'decimal:3', 'quantity_returned' => 'decimal:3',
'unit_quantity_returned' => 'decimal:3',
'unit_price' => 'decimal:2', 'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2', 'subtotal' => 'decimal:2',
]; ];

View File

@ -1,9 +1,11 @@
<?php namespace App\Models; <?php
namespace App\Models;
/** /**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved * @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/ */
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
@ -21,6 +23,8 @@ class SaleDetail extends Model
'bundle_id', 'bundle_id',
'bundle_sale_group', 'bundle_sale_group',
'warehouse_id', 'warehouse_id',
'unit_of_measure_id',
'unit_quantity',
'product_name', 'product_name',
'quantity', 'quantity',
'unit_price', 'unit_price',
@ -31,13 +35,15 @@ class SaleDetail extends Model
protected $casts = [ protected $casts = [
'quantity' => 'decimal:3', 'quantity' => 'decimal:3',
'unit_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',
'discount_amount' => 'decimal:2', 'discount_amount' => 'decimal:2',
]; ];
public function warehouse() { public function warehouse()
{
return $this->belongsTo(Warehouse::class); return $this->belongsTo(Warehouse::class);
} }
@ -51,6 +57,11 @@ public function inventory()
return $this->belongsTo(Inventory::class); return $this->belongsTo(Inventory::class);
} }
public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function serials() public function serials()
{ {
return $this->hasMany(InventorySerial::class, 'sale_detail_id'); return $this->hasMany(InventorySerial::class, 'sale_detail_id');

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UnitEquivalence extends Model
{
protected $fillable = [
'inventory_id',
'unit_of_measure_id',
'conversion_factor',
'retail_price',
'is_active',
];
protected $casts = [
'conversion_factor' => 'decimal:3',
'retail_price' => 'decimal:2',
'is_active' => 'boolean',
];
public function inventory()
{
return $this->belongsTo(Inventory::class);
}
public function unitOfMeasure()
{
return $this->belongsTo(UnitOfMeasurement::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}

View File

@ -25,13 +25,24 @@ public function entry(array $data): InventoryMovement
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$inventory = Inventory::findOrFail($data['inventory_id']); $inventory = Inventory::findOrFail($data['inventory_id']);
$warehouse = Warehouse::findOrFail($data['warehouse_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']);
$quantity = (float) $data['quantity'];
$unitCost = (float) $data['unit_cost'];
$serialNumbers = $data['serial_numbers'] ?? null; $serialNumbers = $data['serial_numbers'] ?? null;
// cargar la unidad de medida // cargar la unidad de medida
$inventory->load('unitOfMeasure'); $inventory->load('unitOfMeasure');
// Conversión de equivalencia de unidades
$inputUnitId = $data['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $data['quantity'];
$inputUnitCost = (float) $data['unit_cost'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception('No se pueden usar equivalencias de unidad para productos con rastreo de seriales.');
}
$quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
$unitCost = $usesEquivalence ? $inventory->convertCostToBaseUnit($inputUnitCost, $inputUnitId) : $inputUnitCost;
// Solo validar seriales si track_serials Y la unidad NO permite decimales // Solo validar seriales si track_serials Y la unidad NO permite decimales
$requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals;
@ -77,6 +88,9 @@ public function entry(array $data): InventoryMovement
'movement_type' => 'entry', 'movement_type' => 'entry',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_cost' => $unitCost, 'unit_cost' => $unitCost,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'unit_cost_original' => $usesEquivalence ? $inputUnitCost : null,
'supplier_id' => $data['supplier_id'] ?? null, 'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
@ -126,10 +140,22 @@ public function bulkEntry(array $data): array
foreach ($data['products'] as $productData) { foreach ($data['products'] as $productData) {
$inventory = Inventory::findOrFail($productData['inventory_id']); $inventory = Inventory::findOrFail($productData['inventory_id']);
$quantity = (float) $productData['quantity']; $inventory->load('unitOfMeasure');
$unitCost = (float)$productData['unit_cost'];
$serialNumbers = $productData['serial_numbers'] ?? null; $serialNumbers = $productData['serial_numbers'] ?? null;
// Conversión de equivalencia de unidades
$inputUnitId = $productData['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $productData['quantity'];
$inputUnitCost = (float) $productData['unit_cost'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name}).");
}
$quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
$unitCost = $usesEquivalence ? $inventory->convertCostToBaseUnit($inputUnitCost, $inputUnitId) : $inputUnitCost;
// Validar seriales si el producto los requiere // Validar seriales si el producto los requiere
if ($inventory->track_serials) { if ($inventory->track_serials) {
if (empty($serialNumbers)) { if (empty($serialNumbers)) {
@ -176,6 +202,9 @@ public function bulkEntry(array $data): array
'movement_type' => 'entry', 'movement_type' => 'entry',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_cost' => $unitCost, 'unit_cost' => $unitCost,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'unit_cost_original' => $usesEquivalence ? $inputUnitCost : null,
'supplier_id' => $data['supplier_id'] ?? null, 'supplier_id' => $data['supplier_id'] ?? null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
@ -228,9 +257,19 @@ public function bulkExit(array $data): array
foreach ($data['products'] as $productData) { foreach ($data['products'] as $productData) {
$inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']); $inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']);
$quantity = (float) $productData['quantity'];
$serialNumbers = $productData['serial_numbers'] ?? null; $serialNumbers = $productData['serial_numbers'] ?? null;
// Conversión de equivalencia de unidades
$inputUnitId = $productData['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $productData['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name}).");
}
$quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
// Solo exigir seriales si track_serials Y unidad NO permite decimales // Solo exigir seriales si track_serials Y unidad NO permite decimales
$requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals;
@ -290,6 +329,8 @@ public function bulkExit(array $data): array
'warehouse_to_id' => null, 'warehouse_to_id' => null,
'movement_type' => 'exit', 'movement_type' => 'exit',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
@ -317,10 +358,20 @@ public function bulkTransfer(array $data): array
} }
foreach ($data['products'] as $productData) { foreach ($data['products'] as $productData) {
$inventory = Inventory::findOrFail($productData['inventory_id']); $inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']);
$quantity = (float) $productData['quantity'];
$serialNumbers = (array) ($productData['serial_numbers'] ?? null); $serialNumbers = (array) ($productData['serial_numbers'] ?? null);
// Conversión de equivalencia de unidades
$inputUnitId = $productData['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $productData['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name}).");
}
$quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
// Validar y procesar seriales si el producto los requiere // Validar y procesar seriales si el producto los requiere
if ($inventory->track_serials) { if ($inventory->track_serials) {
if (empty($serialNumbers)) { if (empty($serialNumbers)) {
@ -378,6 +429,8 @@ public function bulkTransfer(array $data): array
'warehouse_to_id' => $warehouseTo->id, 'warehouse_to_id' => $warehouseTo->id,
'movement_type' => 'transfer', 'movement_type' => 'transfer',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
@ -403,6 +456,7 @@ protected function calculateWeightedAverageCost(
} }
$totalValue = ($currentStock * $currentCost) + ($entryQuantity * $entryCost); $totalValue = ($currentStock * $currentCost) + ($entryQuantity * $entryCost);
$totalQuantity = $currentStock + $entryQuantity; $totalQuantity = $currentStock + $entryQuantity;
return round($totalValue / $totalQuantity, 2); return round($totalValue / $totalQuantity, 2);
} }
@ -422,12 +476,22 @@ public function exit(array $data): InventoryMovement
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$inventory = Inventory::findOrFail($data['inventory_id']); $inventory = Inventory::findOrFail($data['inventory_id']);
$warehouse = Warehouse::findOrFail($data['warehouse_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']);
$quantity = (float) $data['quantity'];
$serialNumbers = $data['serial_numbers'] ?? null; $serialNumbers = $data['serial_numbers'] ?? null;
// Cargar la unidad de medida // Cargar la unidad de medida
$inventory->load('unitOfMeasure'); $inventory->load('unitOfMeasure');
// Conversión de equivalencia de unidades
$inputUnitId = $data['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $data['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception('No se pueden usar equivalencias de unidad para productos con rastreo de seriales.');
}
$quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
// Solo validar seriales si track_serials Y la unidad NO permite decimales // Solo validar seriales si track_serials Y la unidad NO permite decimales
$requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals;
@ -487,6 +551,8 @@ public function exit(array $data): InventoryMovement
'warehouse_to_id' => null, 'warehouse_to_id' => null,
'movement_type' => 'exit', 'movement_type' => 'exit',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
@ -502,9 +568,19 @@ public function transfer(array $data): InventoryMovement
$inventory = Inventory::with('unitOfMeasure')->findOrFail($data['inventory_id']); $inventory = Inventory::with('unitOfMeasure')->findOrFail($data['inventory_id']);
$warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']); $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']);
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']); $warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
$quantity = (float) $data['quantity'];
$serialNumbers = $data['serial_numbers'] ?? null; $serialNumbers = $data['serial_numbers'] ?? null;
// Conversión de equivalencia de unidades
$inputUnitId = $data['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $data['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception('No se pueden usar equivalencias de unidad para productos con rastreo de seriales.');
}
$quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
// Validar que no sea el mismo almacén // Validar que no sea el mismo almacén
if ($warehouseFrom->id === $warehouseTo->id) { if ($warehouseFrom->id === $warehouseTo->id) {
throw new \Exception('No se puede traspasar al mismo almacén.'); throw new \Exception('No se puede traspasar al mismo almacén.');
@ -570,6 +646,8 @@ public function transfer(array $data): InventoryMovement
'warehouse_to_id' => $warehouseTo->id, 'warehouse_to_id' => $warehouseTo->id,
'movement_type' => 'transfer', 'movement_type' => 'transfer',
'quantity' => $quantity, 'quantity' => $quantity,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
@ -706,8 +784,20 @@ public function updateMovement(int $movementId, array $data): InventoryMovement
*/ */
protected function prepareUpdateData(InventoryMovement $movement, array $data): array protected function prepareUpdateData(InventoryMovement $movement, array $data): array
{ {
$inventory = $movement->inventory;
// Determinar si se usa equivalencia de unidades
$inputUnitId = $data['unit_of_measure_id'] ?? $movement->unit_of_measure_id ?? $inventory->unit_of_measure_id;
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
// Convertir cantidad a unidades base si usa equivalencia
$inputQuantity = (float) ($data['quantity'] ?? ($movement->unit_quantity ?? $movement->quantity));
$quantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
$updateData = [ $updateData = [
'quantity' => $data['quantity'] ?? $movement->quantity, 'quantity' => $quantity,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'notes' => $data['notes'] ?? $movement->notes, 'notes' => $data['notes'] ?? $movement->notes,
]; ];
@ -718,7 +808,11 @@ protected function prepareUpdateData(InventoryMovement $movement, array $data):
// Campos específicos por tipo de movimiento // Campos específicos por tipo de movimiento
if ($movement->movement_type === 'entry') { if ($movement->movement_type === 'entry') {
$updateData['unit_cost'] = $data['unit_cost'] ?? $movement->unit_cost; $inputUnitCost = (float) ($data['unit_cost'] ?? ($movement->unit_cost_original ?? $movement->unit_cost));
$unitCost = $usesEquivalence ? $inventory->convertCostToBaseUnit($inputUnitCost, $inputUnitId) : $inputUnitCost;
$updateData['unit_cost'] = $unitCost;
$updateData['unit_cost_original'] = $usesEquivalence ? $inputUnitCost : null;
$updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference; $updateData['invoice_reference'] = $data['invoice_reference'] ?? $movement->invoice_reference;
$updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; $updateData['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id;
} elseif ($movement->movement_type === 'exit') { } elseif ($movement->movement_type === 'exit') {
@ -820,7 +914,7 @@ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $dat
if ($oldWarehouseStock < $oldQuantity) { if ($oldWarehouseStock < $oldQuantity) {
throw new \Exception( throw new \Exception(
"No se puede cambiar el almacén de esta entrada porque el stock actual " 'No se puede cambiar el almacén de esta entrada porque el stock actual '
."del almacén origen ({$oldWarehouseStock}) no es suficiente para retirar " ."del almacén origen ({$oldWarehouseStock}) no es suficiente para retirar "
."las {$oldQuantity} unidades originales. Los productos ya fueron vendidos o movidos." ."las {$oldQuantity} unidades originales. Los productos ya fueron vendidos o movidos."
); );
@ -839,8 +933,8 @@ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $dat
throw new \Exception( throw new \Exception(
"No se puede reducir esta entrada de {$oldQuantity} a {$newQuantity} " "No se puede reducir esta entrada de {$oldQuantity} a {$newQuantity} "
."porque el stock actual del almacén ({$currentWarehouseStock}) no es suficiente " ."porque el stock actual del almacén ({$currentWarehouseStock}) no es suficiente "
. "para absorber la reducción de " . abs($delta) . " unidades. " .'para absorber la reducción de '.abs($delta).' unidades. '
. "Los productos ya fueron vendidos o movidos." .'Los productos ya fueron vendidos o movidos.'
); );
} }
} }
@ -1061,5 +1155,4 @@ protected function revertSerials(InventoryMovement $movement): void
break; break;
} }
} }
} }

View File

@ -2,13 +2,14 @@
namespace App\Services; namespace App\Services;
use App\Models\CashRegister;
use App\Models\Client; use App\Models\Client;
use App\Models\Returns; use App\Models\Inventory;
use App\Models\InventorySerial;
use App\Models\ReturnDetail; use App\Models\ReturnDetail;
use App\Models\Returns;
use App\Models\Sale; use App\Models\Sale;
use App\Models\SaleDetail; use App\Models\SaleDetail;
use App\Models\InventorySerial;
use App\Models\CashRegister;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class ReturnService class ReturnService
@ -17,6 +18,7 @@ public function __construct(
protected ClientTierService $clientTierService, protected ClientTierService $clientTierService,
protected InventoryMovementService $movementService protected InventoryMovementService $movementService
) {} ) {}
/** /**
* Crear una nueva devolución con sus detalles * Crear una nueva devolución con sus detalles
*/ */
@ -42,8 +44,24 @@ public function createReturn(array $data): Returns
$subtotal = 0; $subtotal = 0;
$tax = 0; $tax = 0;
foreach ($data['items'] as $item) { foreach ($data['items'] as &$item) {
$saleDetail = SaleDetail::findOrFail($item['sale_detail_id']); $saleDetail = SaleDetail::findOrFail($item['sale_detail_id']);
$inventory = Inventory::find($saleDetail->inventory_id);
// Conversión de equivalencia de unidades en devolución
$inputUnitId = $item['unit_of_measure_id'] ?? null;
$inputQuantityReturned = (float) $item['quantity_returned'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
$baseQuantityReturned = $usesEquivalence
? $inventory->convertToBaseUnits($inputQuantityReturned, $inputUnitId)
: $inputQuantityReturned;
// Guardar la cantidad convertida para uso posterior
$item['_base_quantity_returned'] = $baseQuantityReturned;
$item['_uses_equivalence'] = $usesEquivalence;
$item['_input_unit_id'] = $inputUnitId;
$item['_input_quantity_returned'] = $inputQuantityReturned;
// Validar que no se devuelva más de lo vendido (restando lo ya devuelto) // Validar que no se devuelva más de lo vendido (restando lo ya devuelto)
$alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id) $alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id)
@ -51,16 +69,17 @@ public function createReturn(array $data): Returns
$maxReturnable = $saleDetail->quantity - $alreadyReturned; $maxReturnable = $saleDetail->quantity - $alreadyReturned;
if ($item['quantity_returned'] > $maxReturnable) { if ($baseQuantityReturned > $maxReturnable) {
throw new \Exception( throw new \Exception(
"Solo puedes devolver {$maxReturnable} unidades de {$saleDetail->product_name}. " . "Solo puedes devolver {$maxReturnable} unidades base de {$saleDetail->product_name}. ".
"Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas." "Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas."
); );
} }
$itemSubtotal = $saleDetail->unit_price * $item['quantity_returned']; $itemSubtotal = $saleDetail->unit_price * $baseQuantityReturned;
$subtotal += $itemSubtotal; $subtotal += $itemSubtotal;
} }
unset($item);
// Calcular impuesto proporcional // Calcular impuesto proporcional
if ($sale->subtotal > 0) { if ($sale->subtotal > 0) {
@ -95,26 +114,29 @@ public function createReturn(array $data): Returns
// 4. Crear detalles y restaurar seriales // 4. Crear detalles y restaurar seriales
foreach ($data['items'] as $item) { foreach ($data['items'] as $item) {
$saleDetail = SaleDetail::findOrFail($item['sale_detail_id']); $saleDetail = SaleDetail::findOrFail($item['sale_detail_id']);
$baseQuantityReturned = $item['_base_quantity_returned'];
$usesEquivalence = $item['_uses_equivalence'];
$returnDetail = ReturnDetail::create([ $returnDetail = ReturnDetail::create([
'return_id' => $return->id, 'return_id' => $return->id,
'sale_detail_id' => $saleDetail->id, 'sale_detail_id' => $saleDetail->id,
'inventory_id' => $saleDetail->inventory_id, 'inventory_id' => $saleDetail->inventory_id,
'product_name' => $saleDetail->product_name, 'product_name' => $saleDetail->product_name,
'quantity_returned' => $item['quantity_returned'], 'quantity_returned' => $baseQuantityReturned,
'unit_of_measure_id' => $usesEquivalence ? $item['_input_unit_id'] : null,
'unit_quantity_returned' => $usesEquivalence ? $item['_input_quantity_returned'] : null,
'unit_price' => $saleDetail->unit_price, 'unit_price' => $saleDetail->unit_price,
'subtotal' => $saleDetail->unit_price * $item['quantity_returned'], 'subtotal' => $saleDetail->unit_price * $baseQuantityReturned,
]); ]);
$inventory = $saleDetail->inventory; $inventory = $saleDetail->inventory;
if ($inventory->track_serials) { if ($inventory->track_serials) {
// Validación de cantidad de seriales // Validación de cantidad de seriales
if (! empty($item['serial_numbers'])) { if (! empty($item['serial_numbers'])) {
if (count($item['serial_numbers']) != $item['quantity_returned']) { if (count($item['serial_numbers']) != $baseQuantityReturned) {
throw new \Exception( throw new \Exception(
"La cantidad de seriales proporcionados no coincide con la cantidad a devolver " . 'La cantidad de seriales proporcionados no coincide con la cantidad a devolver '.
"para {$saleDetail->product_name}." "para {$saleDetail->product_name}."
); );
} }
@ -145,10 +167,10 @@ public function createReturn(array $data): Returns
// Seleccionar automáticamente los primeros N seriales vendidos // Seleccionar automáticamente los primeros N seriales vendidos
$serials = InventorySerial::where('sale_detail_id', $saleDetail->id) $serials = InventorySerial::where('sale_detail_id', $saleDetail->id)
->where('status', 'vendido') ->where('status', 'vendido')
->limit($item['quantity_returned']) ->limit($baseQuantityReturned)
->get(); ->get();
if ($serials->count() < $item['quantity_returned']) { if ($serials->count() < $baseQuantityReturned) {
throw new \Exception( throw new \Exception(
"No hay suficientes seriales disponibles para devolver de {$saleDetail->product_name}" "No hay suficientes seriales disponibles para devolver de {$saleDetail->product_name}"
); );
@ -165,7 +187,7 @@ public function createReturn(array $data): Returns
} else { } else {
// Restaurar stock en el almacén // Restaurar stock en el almacén
$warehouseId = $saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId(); $warehouseId = $saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $item['quantity_returned']); $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $baseQuantityReturned);
} }
} }

View File

@ -1,12 +1,14 @@
<?php namespace App\Services; <?php
namespace App\Services;
use App\Models\Bundle; use App\Models\Bundle;
use App\Models\CashRegister; use App\Models\CashRegister;
use App\Models\Client; use App\Models\Client;
use App\Models\Sale;
use App\Models\SaleDetail;
use App\Models\Inventory; use App\Models\Inventory;
use App\Models\InventorySerial; use App\Models\InventorySerial;
use App\Models\Sale;
use App\Models\SaleDetail;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -16,9 +18,9 @@ public function __construct(
protected ClientTierService $clientTierService, protected ClientTierService $clientTierService,
protected InventoryMovementService $movementService protected InventoryMovementService $movementService
) {} ) {}
/** /**
* Crear una nueva venta con sus detalles * Crear una nueva venta con sus detalles
*
*/ */
public function createSale(array $data) public function createSale(array $data)
{ {
@ -88,6 +90,20 @@ public function createSale(array $data)
$itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2); $itemDiscountAmount = round($item['subtotal'] * ($discountPercentage / 100), 2);
} }
// Obtener el inventario para conversión de unidades
$inventory = Inventory::find($item['inventory_id']);
// Conversión de equivalencia de unidades
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $item['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
if ($usesEquivalence && $inventory->track_serials) {
throw new \Exception("No se pueden usar equivalencias de unidad para productos con rastreo de seriales ({$inventory->name}).");
}
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
// Crear detalle de venta // Crear detalle de venta
$saleDetail = SaleDetail::create([ $saleDetail = SaleDetail::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
@ -95,17 +111,16 @@ public function createSale(array $data)
'bundle_id' => $item['bundle_id'] ?? null, 'bundle_id' => $item['bundle_id'] ?? null,
'bundle_sale_group' => $item['bundle_sale_group'] ?? null, 'bundle_sale_group' => $item['bundle_sale_group'] ?? null,
'warehouse_id' => $item['warehouse_id'] ?? null, 'warehouse_id' => $item['warehouse_id'] ?? null,
'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null,
'unit_quantity' => $usesEquivalence ? $inputQuantity : null,
'product_name' => $item['product_name'], 'product_name' => $item['product_name'],
'quantity' => $item['quantity'], 'quantity' => $baseQuantity,
'unit_price' => $item['unit_price'], 'unit_price' => $item['unit_price'],
'subtotal' => $item['subtotal'], 'subtotal' => $item['subtotal'],
'discount_percentage' => $discountPercentage, 'discount_percentage' => $discountPercentage,
'discount_amount' => $itemDiscountAmount, 'discount_amount' => $itemDiscountAmount,
]); ]);
// Obtener el inventario
$inventory = Inventory::find($item['inventory_id']);
if ($inventory->track_serials) { if ($inventory->track_serials) {
// Si se proporcionaron números de serie específicos // Si se proporcionaron números de serie específicos
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
@ -125,10 +140,10 @@ public function createSale(array $data)
// Asignar automáticamente los primeros N seriales disponibles // Asignar automáticamente los primeros N seriales disponibles
$serials = InventorySerial::where('inventory_id', $inventory->id) $serials = InventorySerial::where('inventory_id', $inventory->id)
->where('status', 'disponible') ->where('status', 'disponible')
->limit($item['quantity']) ->limit($baseQuantity)
->get(); ->get();
if ($serials->count() < $item['quantity']) { if ($serials->count() < $baseQuantity) {
throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}"); throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}");
} }
@ -143,8 +158,8 @@ public function createSale(array $data)
// Obtener almacén (del item o el principal) // Obtener almacén (del item o el principal)
$warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
$this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']); $this->movementService->validateStock($inventory->id, $warehouseId, $baseQuantity);
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$item['quantity']); $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$baseQuantity);
} }
} }
@ -168,7 +183,6 @@ public function createSale(array $data)
/** /**
* Cancelar una venta y restaurar el stock * Cancelar una venta y restaurar el stock
*
*/ */
public function cancelSale(Sale $sale) public function cancelSale(Sale $sale)
{ {
@ -328,7 +342,14 @@ private function validateStockForAllItems(array $items): void
]; ];
} }
$aggregated[$key]['quantity'] += $item['quantity']; // Convertir a unidades base si usa equivalencia
$inventory = Inventory::find($inventoryId);
$inputUnitId = $item['unit_of_measure_id'] ?? $inventory->unit_of_measure_id;
$inputQuantity = (float) $item['quantity'];
$usesEquivalence = $inputUnitId && $inputUnitId != $inventory->unit_of_measure_id;
$baseQuantity = $usesEquivalence ? $inventory->convertToBaseUnits($inputQuantity, $inputUnitId) : $inputQuantity;
$aggregated[$key]['quantity'] += $baseQuantity;
// Acumular seriales específicos por producto // Acumular seriales específicos por producto
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {

View File

@ -0,0 +1,28 @@
<?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
{
Schema::create('unit_equivalences', function (Blueprint $table) {
$table->id();
$table->foreignId('inventory_id')->constrained('inventories')->cascadeOnDelete();
$table->foreignId('unit_of_measure_id')->constrained('units_of_measurement');
$table->decimal('conversion_factor', 15, 3);
$table->decimal('retail_price', 15, 2)->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['inventory_id', 'unit_of_measure_id']);
});
}
public function down(): void
{
Schema::dropIfExists('unit_equivalences');
}
};

View File

@ -0,0 +1,25 @@
<?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
{
Schema::table('sale_details', function (Blueprint $table) {
$table->foreignId('unit_of_measure_id')->nullable()->after('warehouse_id')
->constrained('units_of_measurement')->nullOnDelete();
$table->decimal('unit_quantity', 15, 3)->nullable()->after('unit_of_measure_id');
});
}
public function down(): void
{
Schema::table('sale_details', function (Blueprint $table) {
$table->dropConstrainedForeignId('unit_of_measure_id');
$table->dropColumn('unit_quantity');
});
}
};

View File

@ -0,0 +1,26 @@
<?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
{
Schema::table('inventory_movements', function (Blueprint $table) {
$table->foreignId('unit_of_measure_id')->nullable()->after('quantity')
->constrained('units_of_measurement')->nullOnDelete();
$table->decimal('unit_quantity', 15, 3)->nullable()->after('unit_of_measure_id');
$table->decimal('unit_cost_original', 15, 2)->nullable()->after('unit_cost');
});
}
public function down(): void
{
Schema::table('inventory_movements', function (Blueprint $table) {
$table->dropConstrainedForeignId('unit_of_measure_id');
$table->dropColumn(['unit_quantity', 'unit_cost_original']);
});
}
};

View File

@ -0,0 +1,25 @@
<?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
{
Schema::table('return_details', function (Blueprint $table) {
$table->foreignId('unit_of_measure_id')->nullable()->after('quantity_returned')
->constrained('units_of_measurement')->nullOnDelete();
$table->decimal('unit_quantity_returned', 15, 3)->nullable()->after('unit_of_measure_id');
});
}
public function down(): void
{
Schema::table('return_details', function (Blueprint $table) {
$table->dropConstrainedForeignId('unit_of_measure_id');
$table->dropColumn('unit_quantity_returned');
});
}
};

View File

@ -1,24 +0,0 @@
<?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
{
//
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

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

View File

@ -11,7 +11,7 @@ class UnitsSeeder extends Seeder
public function run(): void public function run(): void
{ {
$units = [ $units = [
['name' => 'Serials', 'abbreviation' => 'ser', 'allows_decimals' => false], ['name' => 'Seriales', 'abbreviation' => 'ser', 'allows_decimals' => false],
['name' => 'Pieza', 'abbreviation' => 'u', 'allows_decimals' => true], ['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],

View File

@ -17,6 +17,7 @@
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\SupplierController;
use App\Http\Controllers\App\UnitEquivalenceController;
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;
@ -63,14 +64,22 @@
Route::post('/traspaso', [InventoryMovementController::class, 'transfer']); Route::post('/traspaso', [InventoryMovementController::class, 'transfer']);
}); });
// EQUIVALENCIAS DE UNIDAD
Route::prefix('inventario/{inventory}/equivalencias')->group(function () {
Route::get('/', [UnitEquivalenceController::class, 'index']);
Route::post('/', [UnitEquivalenceController::class, 'store']);
Route::put('/{equivalencia}', [UnitEquivalenceController::class, 'update']);
Route::delete('/{equivalencia}', [UnitEquivalenceController::class, 'destroy']);
});
// UNIDADES DE MEDIDA // UNIDADES DE MEDIDA
Route::prefix('unidades-medida')->group(function () { Route::prefix('unidades-medida')->group(function () {
Route::get('/', [UnitOfMeasurementController::class, 'index']); Route::get('/', [UnitOfMeasurementController::class, 'index']);
Route::get('/active', [UnitOfMeasurementController::class, 'active']); Route::get('/active', [UnitOfMeasurementController::class, 'active']);
Route::get('/{unidad}', [UnitOfMeasurementController::class, 'show']); Route::get('/{unit}', [UnitOfMeasurementController::class, 'show']);
Route::post('/', [UnitOfMeasurementController::class, 'store']); Route::post('/', [UnitOfMeasurementController::class, 'store']);
Route::put('/{unidad}', [UnitOfMeasurementController::class, 'update']); Route::put('/{unit}', [UnitOfMeasurementController::class, 'update']);
Route::delete('/{unidad}', [UnitOfMeasurementController::class, 'destroy']); Route::delete('/{unit}', [UnitOfMeasurementController::class, 'destroy']);
}); });
//CATEGORIAS //CATEGORIAS