From e5e3412fea1b72a00c1c6664df8b994024db39cf Mon Sep 17 00:00:00 2001 From: Juan Felipe Zapata Moreno Date: Tue, 24 Feb 2026 01:30:11 -0600 Subject: [PATCH] feat: Implement unit equivalence functionality --- .../App/UnitEquivalenceController.php | 69 ++++++ .../App/UnitOfMeasurementController.php | 15 +- .../Requests/App/InventoryEntryRequest.php | 35 ++- .../Requests/App/InventoryExitRequest.php | 8 +- .../App/InventoryMovementUpdateRequest.php | 3 +- .../Requests/App/InventoryStoreRequest.php | 2 +- .../Requests/App/InventoryTransferRequest.php | 8 +- .../Requests/App/InventoryUpdateRequest.php | 2 +- app/Http/Requests/App/ReturnStoreRequest.php | 20 +- app/Http/Requests/App/SaleStoreRequest.php | 7 +- .../App/UnitEquivalenceStoreRequest.php | 65 ++++++ .../App/UnitEquivalenceUpdateRequest.php | 32 +++ .../App/UnitOfMeasurementUpdateRequest.php | 11 +- app/Models/Inventory.php | 57 ++++- app/Models/InventoryMovement.php | 48 +++- app/Models/ReturnDetail.php | 7 +- app/Models/SaleDetail.php | 21 +- app/Models/UnitEquivalence.php | 37 +++ app/Services/InventoryMovementService.php | 215 +++++++++++++----- app/Services/ReturnService.php | 64 ++++-- app/Services/SaleService.php | 61 +++-- ..._200000_create_unit_equivalences_table.php | 28 +++ ...unit_equivalence_to_sale_details_table.php | 25 ++ ...uivalence_to_inventory_movements_table.php | 26 +++ ...it_equivalence_to_return_details_table.php | 25 ++ .../seed/2026_01_29_115028_seed_tiers.php | 24 -- database/seeders/DatabaseSeeder.php | 6 +- database/seeders/UnitsSeeder.php | 2 +- routes/api.php | 15 +- 29 files changed, 755 insertions(+), 183 deletions(-) create mode 100644 app/Http/Controllers/App/UnitEquivalenceController.php create mode 100644 app/Http/Requests/App/UnitEquivalenceStoreRequest.php create mode 100644 app/Http/Requests/App/UnitEquivalenceUpdateRequest.php create mode 100644 app/Models/UnitEquivalence.php create mode 100644 database/migrations/2026_02_23_200000_create_unit_equivalences_table.php create mode 100644 database/migrations/2026_02_23_200001_add_unit_equivalence_to_sale_details_table.php create mode 100644 database/migrations/2026_02_23_200002_add_unit_equivalence_to_inventory_movements_table.php create mode 100644 database/migrations/2026_02_23_200003_add_unit_equivalence_to_return_details_table.php delete mode 100644 database/migrations/seed/2026_01_29_115028_seed_tiers.php diff --git a/app/Http/Controllers/App/UnitEquivalenceController.php b/app/Http/Controllers/App/UnitEquivalenceController.php new file mode 100644 index 0000000..8ca5f7c --- /dev/null +++ b/app/Http/Controllers/App/UnitEquivalenceController.php @@ -0,0 +1,69 @@ +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.', + ]); + } +} diff --git a/app/Http/Controllers/App/UnitOfMeasurementController.php b/app/Http/Controllers/App/UnitOfMeasurementController.php index cfb928d..7e52fb8 100644 --- a/app/Http/Controllers/App/UnitOfMeasurementController.php +++ b/app/Http/Controllers/App/UnitOfMeasurementController.php @@ -7,6 +7,7 @@ use App\Http\Requests\App\UnitOfMeasurementStoreRequest; use App\Http\Requests\App\UnitOfMeasurementUpdateRequest; use App\Models\UnitOfMeasurement; +use Illuminate\Http\Request; use Notsoweb\ApiResponse\Enums\ApiResponse; /** @@ -14,9 +15,19 @@ */ 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([ 'units' => $units diff --git a/app/Http/Requests/App/InventoryEntryRequest.php b/app/Http/Requests/App/InventoryEntryRequest.php index 707ef76..3cc5b6c 100644 --- a/app/Http/Requests/App/InventoryEntryRequest.php +++ b/app/Http/Requests/App/InventoryEntryRequest.php @@ -28,6 +28,7 @@ public function rules(): array 'products.*.unit_cost' => 'required|numeric|min:0', 'products.*.serial_numbers' => 'nullable|array', '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', 'serial_numbers' => 'nullable|array', 'serial_numbers.*' => 'required|string|distinct|unique:inventory_serials,serial_number', + 'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id', ]; } @@ -97,7 +99,7 @@ public function withValidator($validator) foreach ($products as $index => $product) { $inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']); - if (!$inventory || !$inventory->unitOfMeasure) { + if (! $inventory || ! $inventory->unitOfMeasure) { continue; } @@ -105,7 +107,7 @@ public function withValidator($validator) $quantity = $product['quantity']; $isDecimal = floor($quantity) != $quantity; - if ($isDecimal && !$inventory->unitOfMeasure->allows_decimals) { + if ($isDecimal && ! $inventory->unitOfMeasure->allows_decimals) { $field = $this->has('products') ? "products.{$index}.quantity" : 'quantity'; $validator->errors()->add( $field, @@ -113,10 +115,35 @@ 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; - if (!empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { + if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { $field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers'; $validator->errors()->add( $field, diff --git a/app/Http/Requests/App/InventoryExitRequest.php b/app/Http/Requests/App/InventoryExitRequest.php index 3ba2ea3..7cce670 100644 --- a/app/Http/Requests/App/InventoryExitRequest.php +++ b/app/Http/Requests/App/InventoryExitRequest.php @@ -26,6 +26,7 @@ public function rules(): array 'products.*.quantity' => 'required|numeric|min:0.001', 'products.*.serial_numbers' => 'nullable|array', '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', 'serial_numbers' => 'nullable|array', 'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', + 'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id', ]; } @@ -85,7 +87,7 @@ public function withValidator($validator) foreach ($products as $index => $product) { $inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']); - if (!$inventory || !$inventory->unitOfMeasure) { + if (! $inventory || ! $inventory->unitOfMeasure) { continue; } @@ -93,7 +95,7 @@ public function withValidator($validator) $isDecimal = floor($quantity) != $quantity; // Si la cantidad es decimal pero la unidad no permite decimales - if ($isDecimal && !$inventory->unitOfMeasure->allows_decimals) { + if ($isDecimal && ! $inventory->unitOfMeasure->allows_decimals) { $field = $this->has('products') ? "products.{$index}.quantity" : 'quantity'; $validator->errors()->add( $field, @@ -103,7 +105,7 @@ public function withValidator($validator) $serialNumbers = $product['serial_numbers'] ?? null; - if (!empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { + if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { $field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers'; $validator->errors()->add( $field, diff --git a/app/Http/Requests/App/InventoryMovementUpdateRequest.php b/app/Http/Requests/App/InventoryMovementUpdateRequest.php index 1479eff..59589ac 100644 --- a/app/Http/Requests/App/InventoryMovementUpdateRequest.php +++ b/app/Http/Requests/App/InventoryMovementUpdateRequest.php @@ -2,9 +2,7 @@ namespace App\Http\Requests\App; -use App\Models\InventoryMovement; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class InventoryMovementUpdateRequest extends FormRequest { @@ -24,6 +22,7 @@ public function rules(): array 'notes' => 'sometimes|nullable|string|max:500', 'serial_numbers' => ['nullable', 'array'], 'serial_numbers.*' => ['string', 'max:255'], + 'unit_of_measure_id' => 'sometimes|nullable|exists:units_of_measurement,id', ]; } diff --git a/app/Http/Requests/App/InventoryStoreRequest.php b/app/Http/Requests/App/InventoryStoreRequest.php index b6a5669..927b97e 100644 --- a/app/Http/Requests/App/InventoryStoreRequest.php +++ b/app/Http/Requests/App/InventoryStoreRequest.php @@ -21,7 +21,7 @@ public function rules(): array return [ // Campos de Inventory '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'], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode'], 'category_id' => ['required', 'exists:categories,id'], diff --git a/app/Http/Requests/App/InventoryTransferRequest.php b/app/Http/Requests/App/InventoryTransferRequest.php index 132cbd8..3ce35b4 100644 --- a/app/Http/Requests/App/InventoryTransferRequest.php +++ b/app/Http/Requests/App/InventoryTransferRequest.php @@ -27,6 +27,7 @@ public function rules(): array 'products.*.quantity' => 'required|numeric|min:0.001', 'products.*.serial_numbers' => 'nullable|array', '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', 'serial_numbers' => 'nullable|array', 'serial_numbers.*' => 'required|string|exists:inventory_serials,serial_number', + 'unit_of_measure_id' => 'nullable|exists:units_of_measurement,id', ]; } @@ -91,7 +93,7 @@ public function withValidator($validator) foreach ($products as $index => $product) { $inventory = Inventory::with('unitOfMeasure')->find($product['inventory_id']); - if (!$inventory || !$inventory->unitOfMeasure) { + if (! $inventory || ! $inventory->unitOfMeasure) { continue; } @@ -99,7 +101,7 @@ public function withValidator($validator) $quantity = $product['quantity']; $isDecimal = floor($quantity) != $quantity; - if ($isDecimal && !$inventory->unitOfMeasure->allows_decimals) { + if ($isDecimal && ! $inventory->unitOfMeasure->allows_decimals) { $field = $this->has('products') ? "products.{$index}.quantity" : 'quantity'; $validator->errors()->add( $field, @@ -110,7 +112,7 @@ public function withValidator($validator) // VALIDACIÓN 2: No permitir seriales con unidades decimales $serialNumbers = $product['serial_numbers'] ?? null; - if (!empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { + if (! empty($serialNumbers) && $inventory->unitOfMeasure->allows_decimals) { $field = $this->has('products') ? "products.{$index}.serial_numbers" : 'serial_numbers'; $validator->errors()->add( $field, diff --git a/app/Http/Requests/App/InventoryUpdateRequest.php b/app/Http/Requests/App/InventoryUpdateRequest.php index 5b87efd..c984507 100644 --- a/app/Http/Requests/App/InventoryUpdateRequest.php +++ b/app/Http/Requests/App/InventoryUpdateRequest.php @@ -23,7 +23,7 @@ public function rules(): array return [ // Campos de Inventory 'name' => ['nullable', 'string', 'max:100'], - 'key_sat' => ['nullable', 'string', 'max:9'], + 'key_sat' => ['nullable', 'string', 'max:20'], 'sku' => ['nullable', 'string', 'max:50'], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId], 'category_id' => ['nullable', 'exists:categories,id'], diff --git a/app/Http/Requests/App/ReturnStoreRequest.php b/app/Http/Requests/App/ReturnStoreRequest.php index 350de1f..d805344 100644 --- a/app/Http/Requests/App/ReturnStoreRequest.php +++ b/app/Http/Requests/App/ReturnStoreRequest.php @@ -2,11 +2,11 @@ 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\SaleDetail; -use App\Models\ReturnDetail; -use App\Models\InventorySerial; +use Illuminate\Foundation\Http\FormRequest; class ReturnStoreRequest extends FormRequest { @@ -31,6 +31,7 @@ public function rules(): array 'items.*.quantity_returned' => ['required', 'integer', 'min:1'], 'items.*.serial_numbers' => ['nullable', 'array'], 'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'], + 'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'], ]; } @@ -59,7 +60,7 @@ public function withValidator($validator) // 1. Validar que la venta exista y esté completada $sale = Sale::find($this->sale_id); - if (!$sale) { + if (! $sale) { return; } @@ -84,7 +85,7 @@ public function withValidator($validator) foreach ($this->items ?? [] as $index => $item) { $saleDetail = SaleDetail::find($item['sale_detail_id']); - if (!$saleDetail) { + if (! $saleDetail) { continue; } @@ -94,6 +95,7 @@ public function withValidator($validator) "items.{$index}.sale_detail_id", 'El producto no pertenece a esta venta.' ); + continue; } @@ -106,13 +108,13 @@ public function withValidator($validator) if ($item['quantity_returned'] > $maxReturnable) { $validator->errors()->add( "items.{$index}.quantity_returned", - "Solo puedes devolver {$maxReturnable} unidades de {$saleDetail->product_name}. " . + "Solo puedes devolver {$maxReturnable} unidades de {$saleDetail->product_name}. ". "Ya se devolvieron {$alreadyReturned} de {$saleDetail->quantity} vendidas." ); } // Validar seriales solo si se proporcionaron (array vacío = sin serials) - if (!empty($item['serial_numbers'])) { + if (! empty($item['serial_numbers'])) { if (count($item['serial_numbers']) !== $item['quantity_returned']) { $validator->errors()->add( "items.{$index}.serial_numbers", @@ -126,7 +128,7 @@ public function withValidator($validator) ->where('status', 'vendido') ->first(); - if (!$serial) { + if (! $serial) { $validator->errors()->add( "items.{$index}.serial_numbers.{$serialIndex}", "El serial {$serialNumber} no pertenece a esta venta o ya fue devuelto." @@ -140,7 +142,7 @@ public function withValidator($validator) if ($this->cash_register_id) { $cashRegister = \App\Models\CashRegister::find($this->cash_register_id); - if ($cashRegister && !$cashRegister->isOpen()) { + if ($cashRegister && ! $cashRegister->isOpen()) { $validator->errors()->add( 'cash_register_id', 'La caja registradora debe estar abierta para procesar devoluciones.' diff --git a/app/Http/Requests/App/SaleStoreRequest.php b/app/Http/Requests/App/SaleStoreRequest.php index 570363d..1d2d9da 100644 --- a/app/Http/Requests/App/SaleStoreRequest.php +++ b/app/Http/Requests/App/SaleStoreRequest.php @@ -53,7 +53,10 @@ public function rules(): array // Productos normales: ["SN-001", "SN-002"] // Bundles: { inventory_id: ["SN-001", "SN-002"], ... } 'items.*.serial_numbers' => ['nullable', 'array'], - ]; + + // Equivalencia de unidad de medida + 'items.*.unit_of_measure_id' => ['nullable', 'exists:units_of_measurement,id'], + ]; } /** @@ -112,7 +115,7 @@ public function withValidator($validator) if ($this->cash_received < $this->total) { $validator->errors()->add( 'cash_received', - 'El dinero recibido debe ser mayor o igual al total de la venta ($' . number_format($this->total, 2) . ').' + 'El dinero recibido debe ser mayor o igual al total de la venta ($'.number_format($this->total, 2).').' ); } } diff --git a/app/Http/Requests/App/UnitEquivalenceStoreRequest.php b/app/Http/Requests/App/UnitEquivalenceStoreRequest.php new file mode 100644 index 0000000..f0c9be0 --- /dev/null +++ b/app/Http/Requests/App/UnitEquivalenceStoreRequest.php @@ -0,0 +1,65 @@ + [ + '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.', + ]; + } +} diff --git a/app/Http/Requests/App/UnitEquivalenceUpdateRequest.php b/app/Http/Requests/App/UnitEquivalenceUpdateRequest.php new file mode 100644 index 0000000..95f21ce --- /dev/null +++ b/app/Http/Requests/App/UnitEquivalenceUpdateRequest.php @@ -0,0 +1,32 @@ + ['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.', + ]; + } +} diff --git a/app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php b/app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php index 01e82e2..1ec7778 100644 --- a/app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php +++ b/app/Http/Requests/App/UnitOfMeasurementUpdateRequest.php @@ -17,7 +17,7 @@ public function authorize(): bool public function rules(): array { - $unitId = $this->route('unidad'); + $unitId = $this->route('unit')?->id ?? $this->route('unit'); return [ 'name' => ['sometimes', 'string', 'max:50', 'unique:units_of_measurement,name,' . $unitId], @@ -45,15 +45,18 @@ public function messages(): array public function withValidator($validator) { $validator->after(function ($validator) { - $unitId = $this->route('unidad'); - $unit = UnitOfMeasurement::find($unitId); + $unit = $this->route('unit'); + + if ($unit && !($unit instanceof UnitOfMeasurement)) { + $unit = UnitOfMeasurement::find($unit); + } if (!$unit) { return; } // 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 $hasProductsWithSerials = $unit->inventories() ->where('track_serials', true) diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index f66d299..80cedc9 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -1,9 +1,11 @@ -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) */ diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index d8a6406..9bca15b 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -1,4 +1,6 @@ - 'decimal:3', + 'unit_quantity' => 'decimal:3', 'unit_cost' => 'decimal:2', + 'unit_cost_original' => 'decimal:2', 'created_at' => 'datetime', ]; // Relaciones - public function inventory() { + public function inventory() + { return $this->belongsTo(Inventory::class); } - public function supplier() { + public function supplier() + { return $this->belongsTo(Supplier::class); } - public function warehouseFrom() { + public function warehouseFrom() + { return $this->belongsTo(Warehouse::class, 'warehouse_from_id'); } - public function warehouseTo() { + public function warehouseTo() + { return $this->belongsTo(Warehouse::class, 'warehouse_to_id'); } - public function user() { + public function user() + { 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'); } // Relación polimórfica para la referencia - public function reference() { + public function reference() + { return $this->morphTo(); } // Scopes - public function scopeByType($query, string $type) { + public function scopeByType($query, string $type) + { return $query->where('movement_type', $type); } - public function scopeEntry($query) { + public function scopeEntry($query) + { return $query->where('movement_type', 'entry'); } - public function scopeExit($query) { + public function scopeExit($query) + { return $query->where('movement_type', 'exit'); } - public function scopeTransfer($query) { + public function scopeTransfer($query) + { return $query->where('movement_type', 'transfer'); } } diff --git a/app/Models/ReturnDetail.php b/app/Models/ReturnDetail.php index 72e4e32..024dcc7 100644 --- a/app/Models/ReturnDetail.php +++ b/app/Models/ReturnDetail.php @@ -1,4 +1,6 @@ - 'decimal:3', + 'unit_quantity_returned' => 'decimal:3', 'unit_price' => 'decimal:2', 'subtotal' => 'decimal:2', ]; diff --git a/app/Models/SaleDetail.php b/app/Models/SaleDetail.php index 6a0cb9f..eaa1118 100644 --- a/app/Models/SaleDetail.php +++ b/app/Models/SaleDetail.php @@ -1,9 +1,11 @@ - 'decimal:3', + 'unit_quantity' => 'decimal:3', 'unit_price' => 'decimal:2', 'subtotal' => 'decimal:2', 'discount_percentage' => 'decimal:2', 'discount_amount' => 'decimal:2', ]; - public function warehouse() { + public function warehouse() + { return $this->belongsTo(Warehouse::class); } @@ -51,6 +57,11 @@ public function inventory() return $this->belongsTo(Inventory::class); } + public function unitOfMeasure() + { + return $this->belongsTo(UnitOfMeasurement::class); + } + public function serials() { return $this->hasMany(InventorySerial::class, 'sale_detail_id'); @@ -117,7 +128,7 @@ public function bundle() */ public function isPartOfBundle(): bool { - return !is_null($this->bundle_id); + return ! is_null($this->bundle_id); } /** @@ -126,7 +137,7 @@ public function isPartOfBundle(): bool */ public function bundleComponents() { - if (!$this->isPartOfBundle()) { + if (! $this->isPartOfBundle()) { return collect([]); } diff --git a/app/Models/UnitEquivalence.php b/app/Models/UnitEquivalence.php new file mode 100644 index 0000000..c027454 --- /dev/null +++ b/app/Models/UnitEquivalence.php @@ -0,0 +1,37 @@ + '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); + } +} diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 91fb7a5..4a110df 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -25,24 +25,35 @@ public function entry(array $data): InventoryMovement return DB::transaction(function () use ($data) { $inventory = Inventory::findOrFail($data['inventory_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']); - $quantity = (float) $data['quantity']; - $unitCost = (float) $data['unit_cost']; $serialNumbers = $data['serial_numbers'] ?? null; // cargar la unidad de medida $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 - $requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; + $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; if ($requiresSerials) { if (empty($serialNumbers)) { - throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.'); + throw new \Exception('Este producto requiere números de serie. Debe proporcionar '.$quantity.' seriales.'); } // Filtrar seriales vacíos y hacer trim - $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { - return !empty($serial); + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { + return ! empty($serial); }); // Re-indexar el array @@ -50,7 +61,7 @@ public function entry(array $data): InventoryMovement $serialCount = count($serialNumbers); if ($serialCount != $quantity) { - throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Seriales recibidos: " . implode(', ', $serialNumbers)); + throw new \Exception("La cantidad de seriales ({$serialCount}) no coincide con la cantidad ({$quantity}). Seriales recibidos: ".implode(', ', $serialNumbers)); } } @@ -77,6 +88,9 @@ public function entry(array $data): InventoryMovement 'movement_type' => 'entry', 'quantity' => $quantity, '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, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, @@ -84,7 +98,7 @@ public function entry(array $data): InventoryMovement ]); // Crear seriales DESPUÉS del movimiento (para asignar movement_id) - if ($requiresSerials && !empty($serialNumbers)) { + if ($requiresSerials && ! empty($serialNumbers)) { $serials = []; foreach ($serialNumbers as $serialNumber) { $serials[] = [ @@ -100,7 +114,7 @@ public function entry(array $data): InventoryMovement InventorySerial::insert($serials); // Activar track_serials si no estaba activo - if (!$inventory->track_serials) { + if (! $inventory->track_serials) { $inventory->update(['track_serials' => true]); } @@ -126,10 +140,22 @@ public function bulkEntry(array $data): array foreach ($data['products'] as $productData) { $inventory = Inventory::findOrFail($productData['inventory_id']); - $quantity = (float) $productData['quantity']; - $unitCost = (float)$productData['unit_cost']; + $inventory->load('unitOfMeasure'); $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 if ($inventory->track_serials) { if (empty($serialNumbers)) { @@ -137,8 +163,8 @@ public function bulkEntry(array $data): array } // Filtrar seriales vacíos y hacer trim - $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { - return !empty($serial); + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { + return ! empty($serial); }); // Re-indexar el array @@ -146,7 +172,7 @@ public function bulkEntry(array $data): array $serialCount = count($serialNumbers); if ($serialCount != $quantity) { - throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Seriales recibidos: " . implode(', ', $serialNumbers)); + throw new \Exception("El producto '{$inventory->name}': cantidad de seriales ({$serialCount}) no coincide con cantidad ({$quantity}). Seriales recibidos: ".implode(', ', $serialNumbers)); } // Actualizar el array limpio @@ -176,6 +202,9 @@ public function bulkEntry(array $data): array 'movement_type' => 'entry', 'quantity' => $quantity, '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, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, @@ -183,7 +212,7 @@ public function bulkEntry(array $data): array ]); // Crear seriales DESPUÉS (para asignar movement_id) - if (!empty($serialNumbers)) { + if (! empty($serialNumbers)) { $serials = []; foreach ($serialNumbers as $serialNumber) { $serials[] = [ @@ -199,7 +228,7 @@ public function bulkEntry(array $data): array InventorySerial::insert($serials); // Activar track_serials si no estaba activo - if (!$inventory->track_serials) { + if (! $inventory->track_serials) { $inventory->update(['track_serials' => true]); } @@ -228,11 +257,21 @@ public function bulkExit(array $data): array foreach ($data['products'] as $productData) { $inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']); - $quantity = (float) $productData['quantity']; $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 - $requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; + $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; // Validar y procesar seriales si el producto los requiere if ($requiresSerials) { @@ -241,8 +280,8 @@ public function bulkExit(array $data): array } // Filtrar seriales vacíos y hacer trim - $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { - return !empty($serial); + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { + return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); @@ -257,7 +296,7 @@ public function bulkExit(array $data): array ->where('inventory_id', $inventory->id) ->first(); - if (!$serial) { + if (! $serial) { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto."); } @@ -290,6 +329,8 @@ public function bulkExit(array $data): array 'warehouse_to_id' => null, 'movement_type' => 'exit', 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); @@ -317,10 +358,20 @@ public function bulkTransfer(array $data): array } foreach ($data['products'] as $productData) { - $inventory = Inventory::findOrFail($productData['inventory_id']); - $quantity = (float) $productData['quantity']; + $inventory = Inventory::with('unitOfMeasure')->findOrFail($productData['inventory_id']); $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 if ($inventory->track_serials) { if (empty($serialNumbers)) { @@ -328,8 +379,8 @@ public function bulkTransfer(array $data): array } // Filtrar seriales vacíos y hacer trim - $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { - return !empty($serial); + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { + return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); @@ -344,7 +395,7 @@ public function bulkTransfer(array $data): array ->where('inventory_id', $inventory->id) ->first(); - if (!$serial) { + if (! $serial) { throw new \Exception("Producto '{$inventory->name}': El serial '{$serialNumber}' no pertenece a este producto."); } @@ -378,6 +429,8 @@ public function bulkTransfer(array $data): array 'warehouse_to_id' => $warehouseTo->id, 'movement_type' => 'transfer', 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); @@ -403,6 +456,7 @@ protected function calculateWeightedAverageCost( } $totalValue = ($currentStock * $currentCost) + ($entryQuantity * $entryCost); $totalQuantity = $currentStock + $entryQuantity; + return round($totalValue / $totalQuantity, 2); } @@ -422,24 +476,34 @@ public function exit(array $data): InventoryMovement return DB::transaction(function () use ($data) { $inventory = Inventory::findOrFail($data['inventory_id']); $warehouse = Warehouse::findOrFail($data['warehouse_id']); - $quantity = (float) $data['quantity']; $serialNumbers = $data['serial_numbers'] ?? null; // Cargar la unidad de medida $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 - $requiresSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; + $requiresSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; // Validar y procesar seriales si el producto los requiere if ($requiresSerials) { if (empty($serialNumbers)) { - throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.'); + throw new \Exception('Este producto requiere números de serie. Debe proporcionar '.$quantity.' seriales.'); } // Filtrar seriales vacíos y hacer trim - $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { - return !empty($serial); + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { + return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); @@ -454,7 +518,7 @@ public function exit(array $data): InventoryMovement ->where('inventory_id', $inventory->id) ->first(); - if (!$serial) { + if (! $serial) { throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto."); } @@ -487,6 +551,8 @@ public function exit(array $data): InventoryMovement 'warehouse_to_id' => null, 'movement_type' => 'exit', 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); @@ -502,26 +568,36 @@ public function transfer(array $data): InventoryMovement $inventory = Inventory::with('unitOfMeasure')->findOrFail($data['inventory_id']); $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']); $warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']); - $quantity = (float) $data['quantity']; $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 if ($warehouseFrom->id === $warehouseTo->id) { throw new \Exception('No se puede traspasar al mismo almacén.'); } // 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; // Validar y procesar seriales si el producto los requiere if ($requiresSerials) { if (empty($serialNumbers)) { - throw new \Exception('Este producto requiere números de serie. Debe proporcionar ' . $quantity . ' seriales.'); + throw new \Exception('Este producto requiere números de serie. Debe proporcionar '.$quantity.' seriales.'); } // Filtrar seriales vacíos y hacer trim - $serialNumbers = array_filter(array_map('trim', $serialNumbers), function($serial) { - return !empty($serial); + $serialNumbers = array_filter(array_map('trim', $serialNumbers), function ($serial) { + return ! empty($serial); }); $serialNumbers = array_values($serialNumbers); @@ -536,7 +612,7 @@ public function transfer(array $data): InventoryMovement ->where('inventory_id', $inventory->id) ->first(); - if (!$serial) { + if (! $serial) { throw new \Exception("El serial '{$serialNumber}' no pertenece a este producto."); } @@ -570,6 +646,8 @@ public function transfer(array $data): InventoryMovement 'warehouse_to_id' => $warehouseTo->id, 'movement_type' => 'transfer', 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'user_id' => auth()->id(), 'notes' => $data['notes'] ?? null, ]); @@ -655,7 +733,7 @@ public function getMainWarehouseId(): int { $warehouse = Warehouse::where('is_main', true)->first(); - if (!$warehouse) { + if (! $warehouse) { throw new \Exception('No existe un almacén principal configurado.'); } @@ -706,19 +784,35 @@ public function updateMovement(int $movementId, array $data): InventoryMovement */ 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 = [ - 'quantity' => $data['quantity'] ?? $movement->quantity, + 'quantity' => $quantity, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'notes' => $data['notes'] ?? $movement->notes, ]; // Pasar serial_numbers si fueron proporcionados - if (!empty($data['serial_numbers'])) { + if (! empty($data['serial_numbers'])) { $updateData['serial_numbers'] = $data['serial_numbers']; } // Campos específicos por tipo de movimiento 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['warehouse_to_id'] = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; } elseif ($movement->movement_type === 'exit') { @@ -807,7 +901,7 @@ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $dat // --- Ajuste de stock y seriales --- $inventory->load('unitOfMeasure'); - $usesSerials = $inventory->track_serials && !$inventory->unitOfMeasure?->allows_decimals; + $usesSerials = $inventory->track_serials && ! $inventory->unitOfMeasure?->allows_decimals; if ($usesSerials) { // Productos con seriales: el stock se sincroniza automáticamente desde syncStock() @@ -820,9 +914,9 @@ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $dat if ($oldWarehouseStock < $oldQuantity) { throw new \Exception( - "No se puede cambiar el almacén de esta entrada porque el stock actual " - . "del almacén origen ({$oldWarehouseStock}) no es suficiente para retirar " - . "las {$oldQuantity} unidades originales. Los productos ya fueron vendidos o movidos." + 'No se puede cambiar el almacén de esta entrada porque el stock actual ' + ."del almacén origen ({$oldWarehouseStock}) no es suficiente para retirar " + ."las {$oldQuantity} unidades originales. Los productos ya fueron vendidos o movidos." ); } @@ -838,9 +932,9 @@ protected function applyEntryDeltaUpdate(InventoryMovement $movement, array $dat if ($currentWarehouseStock < abs($delta)) { throw new \Exception( "No se puede reducir esta entrada de {$oldQuantity} a {$newQuantity} " - . "porque el stock actual del almacén ({$currentWarehouseStock}) no es suficiente " - . "para absorber la reducción de " . abs($delta) . " unidades. " - . "Los productos ya fueron vendidos o movidos." + ."porque el stock actual del almacén ({$currentWarehouseStock}) no es suficiente " + .'para absorber la reducción de '.abs($delta).' unidades. ' + .'Los productos ya fueron vendidos o movidos.' ); } } @@ -894,7 +988,7 @@ protected function applyTransferUpdate(InventoryMovement $movement, array $data) */ public function syncStockFromSerials(Inventory $inventory): void { - if (!$inventory->track_serials) { + if (! $inventory->track_serials) { return; } @@ -934,7 +1028,7 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat { $inventory = $movement->inventory; - if (!$inventory->track_serials) { + if (! $inventory->track_serials) { return; } @@ -946,16 +1040,16 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat $oldQuantity = (int) $movement->quantity; $newQuantity = (int) ($data['quantity'] ?? $movement->quantity); $quantityChanged = $newQuantity != $oldQuantity; - $serialsProvided = !empty($data['serial_numbers']); + $serialsProvided = ! empty($data['serial_numbers']); $warehouseToId = $data['warehouse_to_id'] ?? $movement->warehouse_to_id; // Si NO cambió la cantidad Y NO se proporcionaron seriales, no hacer nada - if (!$quantityChanged && !$serialsProvided) { + if (! $quantityChanged && ! $serialsProvided) { return; } // Si cambió la cantidad pero NO se proporcionaron seriales, lanzar error - if ($quantityChanged && !$serialsProvided) { + if ($quantityChanged && ! $serialsProvided) { throw new \Exception('Debe proporcionar los números de serie cuando cambia la cantidad del movimiento.'); } @@ -967,7 +1061,7 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat // Validar que la cantidad de seriales proporcionados coincida con la nueva cantidad if (count($newSerialNumbers) !== $newQuantity) { throw new \Exception( - 'La cantidad de números de serie (' . count($newSerialNumbers) . ') debe coincidir con la cantidad del movimiento (' . $newQuantity . ').' + 'La cantidad de números de serie ('.count($newSerialNumbers).') debe coincidir con la cantidad del movimiento ('.$newQuantity.').' ); } @@ -977,7 +1071,7 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat $serialesToRemove = array_diff($existingSerialNumbers, $newSerialNumbers); // Validar que los seriales a eliminar estén disponibles (no vendidos/devueltos) - if (!empty($serialesToRemove)) { + if (! empty($serialesToRemove)) { $unavailable = $currentSerials ->whereIn('serial_number', $serialesToRemove) ->where('status', '!=', 'disponible'); @@ -1002,7 +1096,7 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat } // Eliminar seriales que ya no están en la lista - if (!empty($serialesToRemove)) { + if (! empty($serialesToRemove)) { InventorySerial::where('movement_id', $movement->id) ->whereIn('serial_number', $serialesToRemove) ->where('status', 'disponible') @@ -1010,14 +1104,14 @@ protected function updateMovementSerials(InventoryMovement $movement, array $dat } // Actualizar almacén de los seriales que se conservan (por si cambió el almacén) - if (!empty($serialesToKeep)) { + if (! empty($serialesToKeep)) { InventorySerial::where('movement_id', $movement->id) ->whereIn('serial_number', $serialesToKeep) ->update(['warehouse_id' => $warehouseToId]); } // Crear los nuevos seriales - if (!empty($serialesToAdd)) { + if (! empty($serialesToAdd)) { $serials = []; foreach ($serialesToAdd as $serialNumber) { $serials[] = [ @@ -1044,7 +1138,7 @@ protected function revertSerials(InventoryMovement $movement): void { $inventory = $movement->inventory; - if (!$inventory->track_serials) { + if (! $inventory->track_serials) { return; } @@ -1061,5 +1155,4 @@ protected function revertSerials(InventoryMovement $movement): void break; } } - } diff --git a/app/Services/ReturnService.php b/app/Services/ReturnService.php index a92f5cc..fbf4c06 100644 --- a/app/Services/ReturnService.php +++ b/app/Services/ReturnService.php @@ -2,13 +2,14 @@ namespace App\Services; +use App\Models\CashRegister; use App\Models\Client; -use App\Models\Returns; +use App\Models\Inventory; +use App\Models\InventorySerial; use App\Models\ReturnDetail; +use App\Models\Returns; use App\Models\Sale; use App\Models\SaleDetail; -use App\Models\InventorySerial; -use App\Models\CashRegister; use Illuminate\Support\Facades\DB; class ReturnService @@ -17,6 +18,7 @@ public function __construct( protected ClientTierService $clientTierService, protected InventoryMovementService $movementService ) {} + /** * Crear una nueva devolución con sus detalles */ @@ -33,7 +35,7 @@ public function createReturn(array $data): Returns // Validar que la caja esté abierta si se especificó cash_register_id if (isset($data['cash_register_id'])) { $cashRegister = CashRegister::findOrFail($data['cash_register_id']); - if (!$cashRegister->isOpen()) { + if (! $cashRegister->isOpen()) { throw new \Exception('La caja registradora debe estar abierta para procesar devoluciones.'); } } @@ -42,8 +44,24 @@ public function createReturn(array $data): Returns $subtotal = 0; $tax = 0; - foreach ($data['items'] as $item) { + foreach ($data['items'] as &$item) { $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) $alreadyReturned = ReturnDetail::where('sale_detail_id', $saleDetail->id) @@ -51,16 +69,17 @@ public function createReturn(array $data): Returns $maxReturnable = $saleDetail->quantity - $alreadyReturned; - if ($item['quantity_returned'] > $maxReturnable) { + if ($baseQuantityReturned > $maxReturnable) { 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." ); } - $itemSubtotal = $saleDetail->unit_price * $item['quantity_returned']; + $itemSubtotal = $saleDetail->unit_price * $baseQuantityReturned; $subtotal += $itemSubtotal; } + unset($item); // Calcular impuesto proporcional if ($sale->subtotal > 0) { @@ -95,33 +114,36 @@ public function createReturn(array $data): Returns // 4. Crear detalles y restaurar seriales foreach ($data['items'] as $item) { $saleDetail = SaleDetail::findOrFail($item['sale_detail_id']); + $baseQuantityReturned = $item['_base_quantity_returned']; + $usesEquivalence = $item['_uses_equivalence']; $returnDetail = ReturnDetail::create([ 'return_id' => $return->id, 'sale_detail_id' => $saleDetail->id, 'inventory_id' => $saleDetail->inventory_id, '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, - 'subtotal' => $saleDetail->unit_price * $item['quantity_returned'], + 'subtotal' => $saleDetail->unit_price * $baseQuantityReturned, ]); - $inventory = $saleDetail->inventory; if ($inventory->track_serials) { // Validación de cantidad de seriales - if (!empty($item['serial_numbers'])) { - if (count($item['serial_numbers']) != $item['quantity_returned']) { + if (! empty($item['serial_numbers'])) { + if (count($item['serial_numbers']) != $baseQuantityReturned) { 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}." ); } } // Gestionar seriales - if (!empty($item['serial_numbers'])) { + if (! empty($item['serial_numbers'])) { // Seriales específicos proporcionados foreach ($item['serial_numbers'] as $serialNumber) { $serial = InventorySerial::where('serial_number', $serialNumber) @@ -129,7 +151,7 @@ public function createReturn(array $data): Returns ->where('status', 'vendido') ->first(); - if (!$serial) { + if (! $serial) { throw new \Exception( "El serial {$serialNumber} no pertenece a esta venta o ya fue devuelto." ); @@ -145,10 +167,10 @@ public function createReturn(array $data): Returns // Seleccionar automáticamente los primeros N seriales vendidos $serials = InventorySerial::where('sale_detail_id', $saleDetail->id) ->where('status', 'vendido') - ->limit($item['quantity_returned']) + ->limit($baseQuantityReturned) ->get(); - if ($serials->count() < $item['quantity_returned']) { + if ($serials->count() < $baseQuantityReturned) { throw new \Exception( "No hay suficientes seriales disponibles para devolver de {$saleDetail->product_name}" ); @@ -165,7 +187,7 @@ public function createReturn(array $data): Returns } else { // Restaurar stock en el almacén $warehouseId = $saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId(); - $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $item['quantity_returned']); + $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $baseQuantityReturned); } } @@ -267,7 +289,7 @@ public function getReturnableItems(Sale $sale): array $availableSerials = InventorySerial::where('sale_detail_id', $detail->id) ->where('status', 'vendido') ->get() - ->map(fn($serial) => [ + ->map(fn ($serial) => [ 'serial_number' => $serial->serial_number, 'status' => $serial->status, ]); @@ -305,7 +327,7 @@ private function generateReturnNumber(): string ? (intval(substr($lastReturn->return_number, -4)) + 1) : 1; - return $prefix . $date . '-' . str_pad($sequential, 4, '0', STR_PAD_LEFT); + return $prefix.$date.'-'.str_pad($sequential, 4, '0', STR_PAD_LEFT); } /** diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index e4a56a4..deff6be 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -1,12 +1,14 @@ -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 $saleDetail = SaleDetail::create([ 'sale_id' => $sale->id, @@ -95,17 +111,16 @@ public function createSale(array $data) 'bundle_id' => $item['bundle_id'] ?? null, 'bundle_sale_group' => $item['bundle_sale_group'] ?? null, 'warehouse_id' => $item['warehouse_id'] ?? null, + 'unit_of_measure_id' => $usesEquivalence ? $inputUnitId : null, + 'unit_quantity' => $usesEquivalence ? $inputQuantity : null, 'product_name' => $item['product_name'], - 'quantity' => $item['quantity'], + 'quantity' => $baseQuantity, 'unit_price' => $item['unit_price'], 'subtotal' => $item['subtotal'], 'discount_percentage' => $discountPercentage, 'discount_amount' => $itemDiscountAmount, ]); - // Obtener el inventario - $inventory = Inventory::find($item['inventory_id']); - if ($inventory->track_serials) { // Si se proporcionaron números de serie específicos 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 $serials = InventorySerial::where('inventory_id', $inventory->id) ->where('status', 'disponible') - ->limit($item['quantity']) + ->limit($baseQuantity) ->get(); - if ($serials->count() < $item['quantity']) { + if ($serials->count() < $baseQuantity) { 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) $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); - $this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']); - $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$item['quantity']); + $this->movementService->validateStock($inventory->id, $warehouseId, $baseQuantity); + $this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$baseQuantity); } } @@ -168,7 +183,6 @@ public function createSale(array $data) /** * Cancelar una venta y restaurar el stock - * */ public function cancelSale(Sale $sale) { @@ -244,7 +258,7 @@ private function generateInvoiceNumber(): string // Incrementar secuencial $sequential = $lastSale ? (intval(substr($lastSale->invoice_number, -4)) + 1) : 1; - return $prefix . $date . '-' . str_pad($sequential, 4, '0', STR_PAD_LEFT); + return $prefix.$date.'-'.str_pad($sequential, 4, '0', STR_PAD_LEFT); } private function getCurrentCashRegister($userId) @@ -320,7 +334,7 @@ private function validateStockForAllItems(array $items): void $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId(); $key = "{$inventoryId}_{$warehouseId}"; - if (!isset($aggregated[$key])) { + if (! isset($aggregated[$key])) { $aggregated[$key] = [ 'inventory_id' => $inventoryId, 'warehouse_id' => $warehouseId, @@ -328,11 +342,18 @@ 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 if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { - if (!isset($serialsByProduct[$inventoryId])) { + if (! isset($serialsByProduct[$inventoryId])) { $serialsByProduct[$inventoryId] = []; } $serialsByProduct[$inventoryId] = array_merge( @@ -347,7 +368,7 @@ private function validateStockForAllItems(array $items): void $inventory = Inventory::find($entry['inventory_id']); if ($inventory->track_serials) { - if (!empty($serialsByProduct[$entry['inventory_id']])) { + if (! empty($serialsByProduct[$entry['inventory_id']])) { // Validar que los seriales específicos existan y estén disponibles foreach ($serialsByProduct[$entry['inventory_id']] as $serialNumber) { $serial = InventorySerial::where('inventory_id', $inventory->id) @@ -355,7 +376,7 @@ private function validateStockForAllItems(array $items): void ->where('status', 'disponible') ->first(); - if (!$serial) { + if (! $serial) { throw new \Exception( "Serial {$serialNumber} no disponible para {$inventory->name}" ); @@ -369,7 +390,7 @@ private function validateStockForAllItems(array $items): void if ($availableSerials < $entry['quantity']) { throw new \Exception( - "Stock insuficiente de seriales para {$inventory->name}. " . + "Stock insuficiente de seriales para {$inventory->name}. ". "Disponibles: {$availableSerials}, Requeridos: {$entry['quantity']}" ); } diff --git a/database/migrations/2026_02_23_200000_create_unit_equivalences_table.php b/database/migrations/2026_02_23_200000_create_unit_equivalences_table.php new file mode 100644 index 0000000..1e31844 --- /dev/null +++ b/database/migrations/2026_02_23_200000_create_unit_equivalences_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_23_200001_add_unit_equivalence_to_sale_details_table.php b/database/migrations/2026_02_23_200001_add_unit_equivalence_to_sale_details_table.php new file mode 100644 index 0000000..b2b5e73 --- /dev/null +++ b/database/migrations/2026_02_23_200001_add_unit_equivalence_to_sale_details_table.php @@ -0,0 +1,25 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_02_23_200002_add_unit_equivalence_to_inventory_movements_table.php b/database/migrations/2026_02_23_200002_add_unit_equivalence_to_inventory_movements_table.php new file mode 100644 index 0000000..515cc81 --- /dev/null +++ b/database/migrations/2026_02_23_200002_add_unit_equivalence_to_inventory_movements_table.php @@ -0,0 +1,26 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_02_23_200003_add_unit_equivalence_to_return_details_table.php b/database/migrations/2026_02_23_200003_add_unit_equivalence_to_return_details_table.php new file mode 100644 index 0000000..6c72b31 --- /dev/null +++ b/database/migrations/2026_02_23_200003_add_unit_equivalence_to_return_details_table.php @@ -0,0 +1,25 @@ +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'); + }); + } +}; diff --git a/database/migrations/seed/2026_01_29_115028_seed_tiers.php b/database/migrations/seed/2026_01_29_115028_seed_tiers.php deleted file mode 100644 index 88fa2f3..0000000 --- a/database/migrations/seed/2026_01_29_115028_seed_tiers.php +++ /dev/null @@ -1,24 +0,0 @@ - - * + * * @version 1.0.0 */ class DatabaseSeeder extends Seeder @@ -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); } } diff --git a/database/seeders/UnitsSeeder.php b/database/seeders/UnitsSeeder.php index 5d6966e..7547e27 100644 --- a/database/seeders/UnitsSeeder.php +++ b/database/seeders/UnitsSeeder.php @@ -11,7 +11,7 @@ class UnitsSeeder extends Seeder public function run(): void { $units = [ - ['name' => 'Serials', 'abbreviation' => 'ser', 'allows_decimals' => false], + ['name' => 'Seriales', 'abbreviation' => 'ser', 'allows_decimals' => false], ['name' => 'Pieza', 'abbreviation' => 'u', 'allows_decimals' => true], ['name' => 'Kilogramo', 'abbreviation' => 'kg', 'allows_decimals' => true], ['name' => 'Litro', 'abbreviation' => 'L', 'allows_decimals' => true], diff --git a/routes/api.php b/routes/api.php index fb6cc27..70ed53d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,6 +17,7 @@ use App\Http\Controllers\App\InventoryMovementController; use App\Http\Controllers\App\KardexController; use App\Http\Controllers\App\SupplierController; +use App\Http\Controllers\App\UnitEquivalenceController; use App\Http\Controllers\App\UnitOfMeasurementController; use App\Http\Controllers\App\WarehouseController; use App\Http\Controllers\App\WhatsappController; @@ -63,14 +64,22 @@ 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 Route::prefix('unidades-medida')->group(function () { Route::get('/', [UnitOfMeasurementController::class, 'index']); Route::get('/active', [UnitOfMeasurementController::class, 'active']); - Route::get('/{unidad}', [UnitOfMeasurementController::class, 'show']); + Route::get('/{unit}', [UnitOfMeasurementController::class, 'show']); Route::post('/', [UnitOfMeasurementController::class, 'store']); - Route::put('/{unidad}', [UnitOfMeasurementController::class, 'update']); - Route::delete('/{unidad}', [UnitOfMeasurementController::class, 'destroy']); + Route::put('/{unit}', [UnitOfMeasurementController::class, 'update']); + Route::delete('/{unit}', [UnitOfMeasurementController::class, 'destroy']); }); //CATEGORIAS