feat: Implement unit equivalence functionality
This commit is contained in:
parent
bbc95f4ea2
commit
e5e3412fea
69
app/Http/Controllers/App/UnitEquivalenceController.php
Normal file
69
app/Http/Controllers/App/UnitEquivalenceController.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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.'
|
||||
|
||||
@ -53,6 +53,9 @@ 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).').'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
65
app/Http/Requests/App/UnitEquivalenceStoreRequest.php
Normal file
65
app/Http/Requests/App/UnitEquivalenceStoreRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/App/UnitEquivalenceUpdateRequest.php
Normal file
32
app/Http/Requests/App/UnitEquivalenceUpdateRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<?php namespace App\Models;
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use App\Services\InventoryMovementService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@ -98,6 +100,57 @@ public function availableSerials()
|
||||
->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)
|
||||
*/
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<?php namespace App\Models;
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\InventoryMovementObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
@ -9,6 +11,7 @@
|
||||
class InventoryMovement extends Model
|
||||
{
|
||||
use Extended;
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
@ -17,7 +20,10 @@ class InventoryMovement extends Model
|
||||
'warehouse_to_id',
|
||||
'movement_type',
|
||||
'quantity',
|
||||
'unit_of_measure_id',
|
||||
'unit_quantity',
|
||||
'unit_cost',
|
||||
'unit_cost_original',
|
||||
'supplier_id',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
@ -28,54 +34,72 @@ class InventoryMovement extends Model
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => '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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<?php namespace App\Models;
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@ -10,12 +12,15 @@ class ReturnDetail extends Model
|
||||
'inventory_id',
|
||||
'product_name',
|
||||
'quantity_returned',
|
||||
'unit_of_measure_id',
|
||||
'unit_quantity_returned',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_returned' => 'decimal:3',
|
||||
'unit_quantity_returned' => 'decimal:3',
|
||||
'unit_price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
];
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<?php namespace App\Models;
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
/**
|
||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
||||
*/
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
@ -21,6 +23,8 @@ class SaleDetail extends Model
|
||||
'bundle_id',
|
||||
'bundle_sale_group',
|
||||
'warehouse_id',
|
||||
'unit_of_measure_id',
|
||||
'unit_quantity',
|
||||
'product_name',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
@ -31,13 +35,15 @@ class SaleDetail extends Model
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => '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([]);
|
||||
}
|
||||
|
||||
|
||||
37
app/Models/UnitEquivalence.php
Normal file
37
app/Models/UnitEquivalence.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
<?php namespace App\Services;
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Bundle;
|
||||
use App\Models\CashRegister;
|
||||
use App\Models\Client;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
use App\Models\Inventory;
|
||||
use App\Models\InventorySerial;
|
||||
use App\Models\Sale;
|
||||
use App\Models\SaleDetail;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@ -16,9 +18,9 @@ public function __construct(
|
||||
protected ClientTierService $clientTierService,
|
||||
protected InventoryMovementService $movementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Crear una nueva venta con sus detalles
|
||||
*
|
||||
*/
|
||||
public function createSale(array $data)
|
||||
{
|
||||
@ -88,6 +90,20 @@ public function createSale(array $data)
|
||||
$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
|
||||
$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']}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user