feat(inventory): movimientos masivos y costeo unitario
- Habilita entradas, salidas y traspasos masivos con validación. - Implementa cálculo de costo promedio ponderado y campo de costo unitario. - Agrega filtro por almacén y ajusta manejo de costos nulos.
This commit is contained in:
parent
5a646d84d5
commit
9a78d92dbf
@ -25,7 +25,7 @@ public function __construct(
|
||||
public function index(Request $request)
|
||||
{
|
||||
$products = Inventory::with(['category', 'price'])->withCount('serials')
|
||||
->where('is_active', true);
|
||||
->where('is_active', true);
|
||||
|
||||
|
||||
// Filtro por búsqueda de texto (nombre, SKU, código de barras)
|
||||
@ -90,6 +90,50 @@ public function destroy(Inventory $inventario)
|
||||
return ApiResponse::OK->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener productos disponibles en un almacén específico
|
||||
*/
|
||||
public function getProductsByWarehouse(Request $request, int $warehouseId)
|
||||
{
|
||||
$query = Inventory::query()
|
||||
->with(['category', 'price'])
|
||||
->where('is_active', true)
|
||||
->whereHas('warehouses', function ($q) use ($warehouseId) {
|
||||
$q->where('warehouse_id', $warehouseId)
|
||||
->where('stock', '>', 0);
|
||||
});
|
||||
|
||||
// Filtro por búsqueda de texto
|
||||
if ($request->has('q') && $request->q) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
}
|
||||
|
||||
// Filtro por categoría
|
||||
if ($request->has('category_id') && $request->category_id) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$products = $query->orderBy('name')->get();
|
||||
|
||||
// Agregar el stock específico de este almacén a cada producto
|
||||
$products->each(function ($product) use ($warehouseId) {
|
||||
$warehouseStock = $product->warehouses()
|
||||
->where('warehouse_id', $warehouseId)
|
||||
->first();
|
||||
|
||||
$product->warehouse_stock = $warehouseStock ? $warehouseStock->pivot->stock : 0;
|
||||
});
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'products' => $products,
|
||||
'warehouse_id' => $warehouseId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Importar productos desde Excel
|
||||
*/
|
||||
|
||||
@ -29,6 +29,14 @@ public function index(Request $request)
|
||||
$query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($request->has('q') && $request->q){
|
||||
$query->whereHas('inventory', function($qy) use ($request){
|
||||
$qy->where('name', 'like', "%{$request->q}%")
|
||||
->orWhere('sku', 'like', "%{$request->q}%")
|
||||
->orWhere('barcode', $request->q);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->has('movement_type')) {
|
||||
$query->where('movement_type', $request->movement_type);
|
||||
}
|
||||
@ -82,12 +90,25 @@ public function show(int $id)
|
||||
public function entry(InventoryEntryRequest $request)
|
||||
{
|
||||
try {
|
||||
$movement = $this->movementService->entry($request->validated());
|
||||
$validated = $request->validated();
|
||||
|
||||
if(isset($validated['products'])){
|
||||
$movements = $this->movementService->bulkEntry($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Entrada registrada correctamente',
|
||||
'movement' => $movements,
|
||||
'total_products' => count($movements),
|
||||
]);
|
||||
} else {
|
||||
$movement = $this->movementService->entry($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Entrada registrada correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseTo']),
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Entrada registrada correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseTo']),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
@ -101,12 +122,24 @@ public function entry(InventoryEntryRequest $request)
|
||||
public function exit(InventoryExitRequest $request)
|
||||
{
|
||||
try {
|
||||
$movement = $this->movementService->exit($request->validated());
|
||||
$validated = $request->validated();
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Salida registrada correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseFrom']),
|
||||
]);
|
||||
if(isset($validated['products'])){
|
||||
$movements = $this->movementService->bulkExit($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Salidas registradas correctamente',
|
||||
'movements' => $movements,
|
||||
'total_products' => count($movements),
|
||||
]);
|
||||
} else {
|
||||
$movement = $this->movementService->exit($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Salida registrada correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseFrom']),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
@ -120,12 +153,24 @@ public function exit(InventoryExitRequest $request)
|
||||
public function transfer(InventoryTransferRequest $request)
|
||||
{
|
||||
try {
|
||||
$movement = $this->movementService->transfer($request->validated());
|
||||
$validated = $request->validated();
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Traspaso registrado correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']),
|
||||
]);
|
||||
if(isset($validated['products'])){
|
||||
$movements = $this->movementService->bulkTransfer($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Traspasos registrados correctamente',
|
||||
'movements' => $movements,
|
||||
'total_products' => count($movements),
|
||||
]);
|
||||
} else {
|
||||
$movement = $this->movementService->transfer($validated);
|
||||
|
||||
return ApiResponse::CREATED->response([
|
||||
'message' => 'Traspaso registrado correctamente',
|
||||
'movement' => $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return ApiResponse::BAD_REQUEST->response([
|
||||
'message' => $e->getMessage()
|
||||
|
||||
@ -28,6 +28,10 @@ public function index(Request $request)
|
||||
'details.inventory',
|
||||
])->orderBy('created_at', 'desc');
|
||||
|
||||
if (!auth()->user()->hasRole('admin')) {
|
||||
$query->where('user_id', auth()->id());
|
||||
}
|
||||
|
||||
// Filtros
|
||||
if ($request->has('q') && $request->q) {
|
||||
$query->where('return_number', 'like', "%{$request->q}%")
|
||||
@ -72,6 +76,12 @@ public function show(Returns $return)
|
||||
'cashRegister',
|
||||
]);
|
||||
|
||||
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para ver esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $return,
|
||||
]);
|
||||
@ -101,6 +111,12 @@ public function store(ReturnStoreRequest $request)
|
||||
*/
|
||||
public function cancel(Returns $return)
|
||||
{
|
||||
if (!auth()->user()->hasRole('admin') && $return->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para cancelar esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$cancelledReturn = $this->returnService->cancelReturn($return);
|
||||
|
||||
|
||||
@ -20,11 +20,18 @@ public function index(Request $request)
|
||||
$sales = Sale::with(['details.inventory', 'details.serials', 'user', 'client'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
// Filtrar por usuario: solo admin puede ver todas las ventas
|
||||
if (!auth()->user()->hasRole('admin')) {
|
||||
$sales->where('user_id', auth()->id());
|
||||
}
|
||||
|
||||
if ($request->has('q') && $request->q) {
|
||||
$sales->where('invoice_number', 'like', "%{$request->q}%")
|
||||
->orWhereHas('user', fn($query) =>
|
||||
$query->where('name', 'like', "%{$request->q}%")
|
||||
);
|
||||
$sales->where(function ($query) use ($request) {
|
||||
$query->where('invoice_number', 'like', "%{$request->q}%")
|
||||
->orWhereHas('user', fn($q) =>
|
||||
$q->where('name', 'like', "%{$request->q}%")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->has('cash_register_id')) {
|
||||
@ -42,6 +49,13 @@ public function index(Request $request)
|
||||
|
||||
public function show(Sale $sale)
|
||||
{
|
||||
// Solo admin puede ver ventas de otros usuarios
|
||||
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para ver esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
return ApiResponse::OK->response([
|
||||
'model' => $sale->load(['details.inventory', 'user', 'client'])
|
||||
]);
|
||||
@ -58,6 +72,13 @@ public function store(SaleStoreRequest $request)
|
||||
|
||||
public function cancel(Sale $sale)
|
||||
{
|
||||
// Solo admin puede cancelar ventas de otros usuarios
|
||||
if (!auth()->user()->hasRole('admin') && $sale->user_id !== auth()->id()) {
|
||||
return ApiResponse::FORBIDDEN->response([
|
||||
'message' => 'No tienes permiso para cancelar esta venta.'
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$cancelledSale = $this->saleService->cancelSale($sale);
|
||||
|
||||
|
||||
@ -13,11 +13,26 @@ public function authorize(): bool
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
if ($this->has('products')) {
|
||||
return [
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'invoice_reference' => 'required|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
|
||||
// Validación del array de productos
|
||||
'products' => 'required|array|min:1',
|
||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
||||
'products.*.quantity' => 'required|integer|min:1',
|
||||
'products.*.unit_cost' => 'required|numeric|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'inventory_id' => 'required|exists:inventories,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
'invoice_reference' => 'nullable|string|max:255',
|
||||
'unit_cost' => 'required|numeric|min:0',
|
||||
'invoice_reference' => 'required|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
];
|
||||
}
|
||||
@ -25,12 +40,27 @@ public function rules(): array
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes para entrada única
|
||||
'inventory_id.required' => 'El producto es requerido',
|
||||
'inventory_id.exists' => 'El producto no existe',
|
||||
|
||||
// Mensajes para entrada múltiple
|
||||
'products.required' => 'Debe incluir al menos un producto',
|
||||
'products.*.inventory_id.required' => 'El producto es requerido',
|
||||
'products.*.inventory_id.exists' => 'El producto no existe',
|
||||
'products.*.quantity.required' => 'La cantidad es requerida',
|
||||
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
'products.*.unit_cost.required' => 'El costo unitario es requerido',
|
||||
'products.*.unit_cost.numeric' => 'El costo unitario debe ser un número',
|
||||
'products.*.unit_cost.min' => 'El costo unitario no puede ser negativo',
|
||||
|
||||
// Mensajes comunes
|
||||
'warehouse_id.required' => 'El almacén es requerido',
|
||||
'warehouse_id.exists' => 'El almacén no existe',
|
||||
'quantity.required' => 'La cantidad es requerida',
|
||||
'quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
'unit_cost.required' => 'El costo unitario es requerido',
|
||||
'invoice_reference.required' => 'La referencia de la factura es requerida',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,20 @@ public function authorize(): bool
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// Si tiene "products" array, es salida múltiple
|
||||
if ($this->has('products')) {
|
||||
return [
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
|
||||
// Validación del array de productos
|
||||
'products' => 'required|array|min:1',
|
||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
||||
'products.*.quantity' => 'required|integer|min:1',
|
||||
];
|
||||
}
|
||||
|
||||
// Salida única (formato original)
|
||||
return [
|
||||
'inventory_id' => 'required|exists:inventories,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
@ -24,8 +38,18 @@ public function rules(): array
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes para salida única
|
||||
'inventory_id.required' => 'El producto es requerido',
|
||||
'inventory_id.exists' => 'El producto no existe',
|
||||
|
||||
// Mensajes para salida múltiple
|
||||
'products.required' => 'Debe incluir al menos un producto',
|
||||
'products.*.inventory_id.required' => 'El producto es requerido',
|
||||
'products.*.inventory_id.exists' => 'El producto no existe',
|
||||
'products.*.quantity.required' => 'La cantidad es requerida',
|
||||
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
|
||||
// Mensajes comunes
|
||||
'warehouse_id.required' => 'El almacén es requerido',
|
||||
'warehouse_id.exists' => 'El almacén no existe',
|
||||
'quantity.required' => 'La cantidad es requerida',
|
||||
|
||||
@ -55,7 +55,7 @@ public static function rowRules(): array
|
||||
'codigo_barras' => ['nullable', 'string', 'max:100'],
|
||||
'categoria' => ['nullable', 'string', 'max:100'],
|
||||
'stock' => ['required', 'integer', 'min:0'],
|
||||
'costo' => ['required', 'numeric', 'min:0'],
|
||||
'costo' => ['nullable', 'numeric', 'min:0'],
|
||||
'precio_venta' => ['required', 'numeric', 'min:0'],
|
||||
'impuesto' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
@ -73,7 +73,6 @@ public static function rowMessages(): array
|
||||
'stock.required' => 'El stock es requerido.',
|
||||
'stock.integer' => 'El stock debe ser un número entero.',
|
||||
'stock.min' => 'El stock no puede ser negativo.',
|
||||
'costo.required' => 'El costo es requerido.',
|
||||
'costo.numeric' => 'El costo debe ser un número.',
|
||||
'costo.min' => 'El costo no puede ser negativo.',
|
||||
'precio_venta.required' => 'El precio de venta es requerido.',
|
||||
|
||||
@ -27,8 +27,8 @@ public function rules(): array
|
||||
'track_serials' => ['nullable', 'boolean'],
|
||||
|
||||
// Campos de Price
|
||||
'cost' => ['required', 'numeric', 'min:0'],
|
||||
'retail_price' => ['required', 'numeric', 'min:0', 'gt:cost'],
|
||||
'cost' => ['nullable', 'numeric', 'min:0'],
|
||||
'retail_price' => ['required', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
@ -48,9 +48,6 @@ public function messages(): array
|
||||
'category_id.required' => 'La categoría es obligatoria.',
|
||||
'category_id.exists' => 'La categoría seleccionada no es válida.',
|
||||
// Mensajes de Price
|
||||
'cost.required' => 'El costo es obligatorio.',
|
||||
'cost.numeric' => 'El costo debe ser un número.',
|
||||
'cost.min' => 'El costo no puede ser negativo.',
|
||||
'retail_price.required' => 'El precio de venta es obligatorio.',
|
||||
'retail_price.numeric' => 'El precio de venta debe ser un número.',
|
||||
'retail_price.min' => 'El precio de venta no puede ser negativo.',
|
||||
@ -60,4 +57,22 @@ public function messages(): array
|
||||
'tax.max' => 'El impuesto no puede exceder el 100%.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación condicional: retail_price > cost solo si cost > 0
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$cost = $this->input('cost');
|
||||
$retailPrice = $this->input('retail_price');
|
||||
|
||||
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
|
||||
$validator->errors()->add(
|
||||
'retail_price',
|
||||
'El precio de venta debe ser mayor que el costo.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,21 @@ public function authorize(): bool
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// Si tiene "products" array, es traspaso múltiple
|
||||
if ($this->has('products')) {
|
||||
return [
|
||||
'warehouse_from_id' => 'required|exists:warehouses,id',
|
||||
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
|
||||
// Validación del array de productos
|
||||
'products' => 'required|array|min:1',
|
||||
'products.*.inventory_id' => 'required|exists:inventories,id',
|
||||
'products.*.quantity' => 'required|integer|min:1',
|
||||
];
|
||||
}
|
||||
|
||||
// Traspaso único (formato original)
|
||||
return [
|
||||
'inventory_id' => 'required|exists:inventories,id',
|
||||
'warehouse_from_id' => 'required|exists:warehouses,id',
|
||||
@ -25,8 +40,18 @@ public function rules(): array
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
// Mensajes para traspaso único
|
||||
'inventory_id.required' => 'El producto es requerido',
|
||||
'inventory_id.exists' => 'El producto no existe',
|
||||
|
||||
// Mensajes para traspaso múltiple
|
||||
'products.required' => 'Debe incluir al menos un producto',
|
||||
'products.*.inventory_id.required' => 'El producto es requerido',
|
||||
'products.*.inventory_id.exists' => 'El producto no existe',
|
||||
'products.*.quantity.required' => 'La cantidad es requerida',
|
||||
'products.*.quantity.min' => 'La cantidad debe ser al menos 1',
|
||||
|
||||
// Mensajes comunes
|
||||
'warehouse_from_id.required' => 'El almacén origen es requerido',
|
||||
'warehouse_from_id.exists' => 'El almacén origen no existe',
|
||||
'warehouse_to_id.required' => 'El almacén destino es requerido',
|
||||
|
||||
@ -30,7 +30,7 @@ public function rules(): array
|
||||
|
||||
// Campos de Price
|
||||
'cost' => ['nullable', 'numeric', 'min:0'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0', 'gt:cost'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
@ -58,4 +58,22 @@ public function messages(): array
|
||||
'tax.max' => 'El impuesto no puede exceder el 100%.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación condicional: retail_price > cost solo si cost > 0
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$cost = $this->input('cost');
|
||||
$retailPrice = $this->input('retail_price');
|
||||
|
||||
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
|
||||
$validator->errors()->add(
|
||||
'retail_price',
|
||||
'El precio de venta debe ser mayor que el costo.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ public function rules(): array
|
||||
{
|
||||
return [
|
||||
'cost' => ['nullable', 'numeric', 'min:0'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0', 'gt:cost'],
|
||||
'retail_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
@ -38,4 +38,22 @@ public function messages(): array
|
||||
'tax.max' => 'El impuesto no puede exceder el 100%.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validación condicional: retail_price > cost solo si cost > 0
|
||||
*/
|
||||
public function withValidator($validator)
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$cost = $this->input('cost');
|
||||
$retailPrice = $this->input('retail_price');
|
||||
|
||||
if ($cost !== null && $cost > 0 && $retailPrice !== null && $retailPrice <= $cost) {
|
||||
$validator->errors()->add(
|
||||
'retail_price',
|
||||
'El precio de venta debe ser mayor que el costo.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,16 +81,17 @@ public function model(array $row)
|
||||
$existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first();
|
||||
}
|
||||
|
||||
// Si el producto ya existe, solo agregar stock y seriales
|
||||
// Si el producto ya existe, solo agregar stock y costo
|
||||
if ($existingInventory) {
|
||||
return $this->updateExistingProduct($existingInventory, $row);
|
||||
}
|
||||
|
||||
// Producto nuevo: validar precios
|
||||
$costo = (float) $row['costo'];
|
||||
// Producto nuevo: obtener valores
|
||||
$costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : 0;
|
||||
$precioVenta = (float) $row['precio_venta'];
|
||||
|
||||
if ($precioVenta <= $costo) {
|
||||
// Validar precio > costo solo si costo > 0
|
||||
if ($costo > 0 && $precioVenta <= $costo) {
|
||||
$this->skipped++;
|
||||
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta ($precioVenta) debe ser mayor que el costo ($costo)";
|
||||
return null;
|
||||
@ -123,8 +124,16 @@ public function model(array $row)
|
||||
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
|
||||
]);
|
||||
|
||||
// Crear números de serie si se proporcionan
|
||||
$this->addSerials($inventory, $row['numeros_serie'] ?? null, $row['stock'] ?? 0);
|
||||
// Agregar stock inicial si existe
|
||||
$stockFromExcel = (int) ($row['stock'] ?? 0);
|
||||
if ($stockFromExcel > 0) {
|
||||
$this->addStockWithCost(
|
||||
$inventory,
|
||||
$stockFromExcel,
|
||||
$costo,
|
||||
$row['numeros_serie'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
$this->imported++;
|
||||
|
||||
@ -137,64 +146,79 @@ public function model(array $row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un producto existente: suma stock y agrega seriales nuevos
|
||||
* Actualiza un producto existente: suma stock y actualiza costo
|
||||
*/
|
||||
private function updateExistingProduct(Inventory $inventory, array $row)
|
||||
{
|
||||
$serialsAdded = 0;
|
||||
$serialsSkipped = 0;
|
||||
$mainWarehouseId = $this->movementService->getMainWarehouseId();
|
||||
$stockToAdd = (int) ($row['stock'] ?? 0);
|
||||
$costo = isset($row['costo']) && $row['costo'] !== '' ? (float) $row['costo'] : null;
|
||||
|
||||
// Agregar seriales nuevos (ignorar duplicados)
|
||||
if (!empty($row['numeros_serie'])) {
|
||||
$serials = explode(',', $row['numeros_serie']);
|
||||
// Si hay stock para agregar
|
||||
if ($stockToAdd > 0) {
|
||||
// Si tiene números de serie
|
||||
if (!empty($row['numeros_serie'])) {
|
||||
$serials = explode(',', $row['numeros_serie']);
|
||||
$serialsAdded = 0;
|
||||
$serialsSkipped = 0;
|
||||
|
||||
foreach ($serials as $serial) {
|
||||
$serial = trim($serial);
|
||||
if (empty($serial)) continue;
|
||||
foreach ($serials as $serial) {
|
||||
$serial = trim($serial);
|
||||
if (empty($serial)) continue;
|
||||
|
||||
// Verificar si el serial ya existe (global, no solo en este producto)
|
||||
$exists = InventorySerial::where('serial_number', $serial)->exists();
|
||||
// Verificar si el serial ya existe
|
||||
$exists = InventorySerial::where('serial_number', $serial)->exists();
|
||||
|
||||
if (!$exists) {
|
||||
InventorySerial::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_id' => $mainWarehouseId,
|
||||
'serial_number' => $serial,
|
||||
'status' => 'disponible',
|
||||
]);
|
||||
$serialsAdded++;
|
||||
} else {
|
||||
$serialsSkipped++;
|
||||
if (!$exists) {
|
||||
InventorySerial::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_id' => $mainWarehouseId,
|
||||
'serial_number' => $serial,
|
||||
'status' => 'disponible',
|
||||
]);
|
||||
$serialsAdded++;
|
||||
} else {
|
||||
$serialsSkipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar stock basado en seriales
|
||||
$inventory->syncStock();
|
||||
|
||||
if ($serialsSkipped > 0) {
|
||||
$this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados";
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar stock basado en seriales disponibles
|
||||
$inventory->syncStock();
|
||||
} else {
|
||||
// Producto sin seriales: sumar stock en almacén principal
|
||||
$stockToAdd = (int) ($row['stock'] ?? 0);
|
||||
if ($stockToAdd > 0) {
|
||||
// Registrar movimiento de entrada con costo si existe
|
||||
if ($costo !== null && $costo > 0) {
|
||||
$this->movementService->entry([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_id' => $mainWarehouseId,
|
||||
'quantity' => $stockToAdd,
|
||||
'unit_cost' => $costo,
|
||||
'invoice_reference' => 'IMP-' . date('YmdHis'),
|
||||
'notes' => 'Importación desde Excel - actualización',
|
||||
]);
|
||||
} else {
|
||||
// Sin costo, solo agregar stock sin movimiento de entrada
|
||||
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updated++;
|
||||
|
||||
if ($serialsSkipped > 0) {
|
||||
$this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados";
|
||||
}
|
||||
|
||||
return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega seriales a un producto nuevo
|
||||
* Agrega stock inicial a un producto nuevo con registro de movimiento
|
||||
*/
|
||||
private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel)
|
||||
private function addStockWithCost(Inventory $inventory, int $quantity, float $cost, ?string $serialsString): void
|
||||
{
|
||||
$mainWarehouseId = $this->movementService->getMainWarehouseId();
|
||||
|
||||
// Si tiene números de serie
|
||||
if (!empty($serialsString)) {
|
||||
$serials = explode(',', $serialsString);
|
||||
|
||||
@ -209,12 +233,24 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sincronizar stock basado en seriales
|
||||
$inventory->syncStock();
|
||||
}
|
||||
|
||||
// Registrar movimiento de entrada con costo si existe
|
||||
if ($cost > 0) {
|
||||
$this->movementService->entry([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_id' => $mainWarehouseId,
|
||||
'quantity' => $quantity,
|
||||
'unit_cost' => $cost,
|
||||
'invoice_reference' => 'IMP-' . date('YmdHis'),
|
||||
'notes' => 'Importación desde Excel - stock inicial',
|
||||
]);
|
||||
} else {
|
||||
// Producto sin seriales: registrar stock en almacén principal
|
||||
if ($stockFromExcel > 0) {
|
||||
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockFromExcel);
|
||||
}
|
||||
// Sin costo, solo agregar stock
|
||||
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $quantity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ class InventoryMovement extends Model
|
||||
'warehouse_to_id',
|
||||
'movement_type',
|
||||
'quantity',
|
||||
'unit_cost',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
'user_id',
|
||||
@ -21,6 +22,7 @@ class InventoryMovement extends Model
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'integer',
|
||||
'unit_cost' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<?php namespace App\Services;
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Models\InventoryMovement;
|
||||
@ -14,7 +16,7 @@
|
||||
class InventoryMovementService
|
||||
{
|
||||
/**
|
||||
* Entrada de inventario (compra, ajuste positivo)
|
||||
* Entrada de inventario
|
||||
*/
|
||||
public function entry(array $data): InventoryMovement
|
||||
{
|
||||
@ -22,6 +24,22 @@ public function entry(array $data): InventoryMovement
|
||||
$inventory = Inventory::findOrFail($data['inventory_id']);
|
||||
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
|
||||
$quantity = $data['quantity'];
|
||||
$unitCost = $data['unit_cost'];
|
||||
|
||||
//Obtener stock actual para calcular costo promedio ponderado
|
||||
$curentStock = $inventory->stock;
|
||||
$currentCost = $inventory->price?->cost ?? 0.00;
|
||||
|
||||
// Calcular nuevo costo promedio ponderado
|
||||
$newCost = $this->calculateWeightedAverageCost(
|
||||
$curentStock,
|
||||
$currentCost,
|
||||
$quantity,
|
||||
$unitCost
|
||||
);
|
||||
|
||||
// Actualizar costo en prices
|
||||
$this->updateProductCost($inventory, $newCost);
|
||||
|
||||
// Actualizar stock en inventory_warehouse
|
||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
||||
@ -33,6 +51,7 @@ public function entry(array $data): InventoryMovement
|
||||
'warehouse_to_id' => $warehouse->id,
|
||||
'movement_type' => 'entry',
|
||||
'quantity' => $quantity,
|
||||
'unit_cost' => $unitCost,
|
||||
'user_id' => auth()->id(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'invoice_reference' => $data['invoice_reference'] ?? null,
|
||||
@ -40,6 +59,166 @@ public function entry(array $data): InventoryMovement
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Entrada múltiple de inventario (varios productos)
|
||||
*/
|
||||
public function bulkEntry(array $data): array
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
|
||||
$movements = [];
|
||||
|
||||
foreach ($data['products'] as $productData) {
|
||||
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
||||
$quantity = $productData['quantity'];
|
||||
$unitCost = $productData['unit_cost'];
|
||||
|
||||
// Obtener stock actual para calcular costo promedio ponderado
|
||||
$currentStock = $inventory->stock;
|
||||
$currentCost = $inventory->price?->cost ?? 0.00;
|
||||
|
||||
// Calcular nuevo costo promedio ponderado
|
||||
$newCost = $this->calculateWeightedAverageCost(
|
||||
$currentStock,
|
||||
$currentCost,
|
||||
$quantity,
|
||||
$unitCost
|
||||
);
|
||||
|
||||
// Actualizar costo en prices
|
||||
$this->updateProductCost($inventory, $newCost);
|
||||
|
||||
// Actualizar stock en inventory_warehouse
|
||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
||||
|
||||
// Registrar movimiento
|
||||
$movement = InventoryMovement::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_from_id' => null,
|
||||
'warehouse_to_id' => $warehouse->id,
|
||||
'movement_type' => 'entry',
|
||||
'quantity' => $quantity,
|
||||
'unit_cost' => $unitCost,
|
||||
'user_id' => auth()->id(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'invoice_reference' => $data['invoice_reference'],
|
||||
]);
|
||||
|
||||
$movements[] = $movement->load(['inventory', 'warehouseTo']);
|
||||
}
|
||||
|
||||
return $movements;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Salida múltiple de inventario (varios productos)
|
||||
*/
|
||||
public function bulkExit(array $data): array
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
|
||||
$movements = [];
|
||||
|
||||
foreach ($data['products'] as $productData) {
|
||||
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
||||
$quantity = $productData['quantity'];
|
||||
|
||||
// Validar stock disponible
|
||||
$this->validateStock($inventory->id, $warehouse->id, $quantity);
|
||||
|
||||
// Decrementar stock en inventory_warehouse
|
||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
|
||||
|
||||
// Registrar movimiento
|
||||
$movement = InventoryMovement::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_from_id' => $warehouse->id,
|
||||
'warehouse_to_id' => null,
|
||||
'movement_type' => 'exit',
|
||||
'quantity' => $quantity,
|
||||
'user_id' => auth()->id(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$movements[] = $movement->load(['inventory', 'warehouseFrom']);
|
||||
}
|
||||
|
||||
return $movements;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Traspaso múltiple entre almacenes (varios productos)
|
||||
*/
|
||||
public function bulkTransfer(array $data): array
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']);
|
||||
$warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']);
|
||||
$movements = [];
|
||||
|
||||
// Validar que no sea el mismo almacén
|
||||
if ($warehouseFrom->id === $warehouseTo->id) {
|
||||
throw new \Exception('No se puede traspasar al mismo almacén.');
|
||||
}
|
||||
|
||||
foreach ($data['products'] as $productData) {
|
||||
$inventory = Inventory::findOrFail($productData['inventory_id']);
|
||||
$quantity = $productData['quantity'];
|
||||
|
||||
// Validar stock disponible en almacén origen
|
||||
$this->validateStock($inventory->id, $warehouseFrom->id, $quantity);
|
||||
|
||||
// Decrementar en origen
|
||||
$this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity);
|
||||
|
||||
// Incrementar en destino
|
||||
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
|
||||
|
||||
// Registrar movimiento
|
||||
$movement = InventoryMovement::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'warehouse_from_id' => $warehouseFrom->id,
|
||||
'warehouse_to_id' => $warehouseTo->id,
|
||||
'movement_type' => 'transfer',
|
||||
'quantity' => $quantity,
|
||||
'user_id' => auth()->id(),
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$movements[] = $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']);
|
||||
}
|
||||
|
||||
return $movements;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular costo promedio ponderado
|
||||
*/
|
||||
protected function calculateWeightedAverageCost(
|
||||
int $currentStock,
|
||||
float $currentCost,
|
||||
int $entryQuantity,
|
||||
float $entryCost
|
||||
): float {
|
||||
if ($currentStock <= 0) {
|
||||
return $entryCost;
|
||||
}
|
||||
$totalValue = ($currentStock * $currentCost) + ($entryQuantity * $entryCost);
|
||||
$totalQuantity = $currentStock + $entryQuantity;
|
||||
return round($totalValue / $totalQuantity, 2);
|
||||
}
|
||||
|
||||
protected function updateProductCost(Inventory $inventory, float $newCost): void
|
||||
{
|
||||
$inventory->price()->updateOrCreate(
|
||||
['inventory_id' => $inventory->id],
|
||||
['cost' => $newCost]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Salida de inventario (merma, ajuste negativo, robo, daño)
|
||||
*/
|
||||
|
||||
@ -19,7 +19,7 @@ public function createProduct(array $data)
|
||||
|
||||
Price::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'cost' => $data['cost'],
|
||||
'cost' => $data['cost'] ?? 0,
|
||||
'retail_price' => $data['retail_price'],
|
||||
'tax' => $data['tax'] ?? 16.00,
|
||||
]);
|
||||
|
||||
@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventory_movements', function (Blueprint $table) {
|
||||
$table->decimal('unit_cost', 15, 2)->nullable()->after('quantity');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_movements', function (Blueprint $table) {
|
||||
$table->dropColumn('unit_cost');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('prices', function (Blueprint $table) {
|
||||
$table->decimal('cost', 15, 2)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('prices', function (Blueprint $table) {
|
||||
$table->decimal('cost', 15, 2)->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -36,9 +36,10 @@
|
||||
// Tus rutas protegidas
|
||||
|
||||
//INVENTARIO
|
||||
Route::resource('inventario', InventoryController::class);
|
||||
Route::get('inventario/almacen/{warehouse}', [InventoryController::class, 'getProductsByWarehouse']);
|
||||
Route::post('inventario/import', [InventoryController::class, 'import']);
|
||||
Route::get('inventario/template/download', [InventoryController::class, 'downloadTemplate']);
|
||||
Route::resource('inventario', InventoryController::class);
|
||||
|
||||
// NÚMEROS DE SERIE DE INVENTARIO
|
||||
Route::resource('inventario.serials', InventorySerialController::class);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user