] * * @since [v4.0] */ public function index(FilterRequest $request): array { $this->authorize('index', Consumable::class); // See ComponentsController for the orderItems.order.supplier eager-load rationale. $consumables = Consumable::with('company', 'location', 'category', 'defaultSupplier', 'manufacturer', 'orderItems.order.supplier') ->withCount('users as consumables_users_count'); // This array is what determines which fields should be allowed to be sorted on ON the table itself. // These must match a column on the consumables table directly. $allowed_columns = [ 'id', 'name', 'min_amt', 'purchase_date', 'purchase_cost', 'company', 'category', 'model_number', 'item_no', 'manufacturer', 'location', 'qty', 'image', // These are *relationships* so we wouldn't normally include them in this array, // since they would normally create a `column not found` error, // BUT we account for them in the ordering switch down at the end of this method // DO NOT ADD ANYTHING TO THIS LIST WITHOUT CHECKING THE ORDERING SWITCH BELOW! 'company', 'location', 'category', 'supplier', 'manufacturer', ]; // This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter if ($request->filled('filter') || $request->filled('search')) { $consumables->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search')); } if ($request->filled('name')) { $consumables->where('consumables.name', '=', $request->input('name')); } if ($request->filled('company_id')) { // expand_company_hierarchy=1 opts the company show-page tabs into the // parent/child rollup so a child shows items inherited from its parent. if ($request->boolean('expand_company_hierarchy')) { $consumables->whereIn('consumables.company_id', Company::reachableCompanyIds($request->input('company_id'))); } else { $consumables->where('consumables.company_id', '=', $request->input('company_id')); } } if ($request->filled('order_number')) { // Reroute through the HasOrders orders() HasManyThrough since // the parent consumables.order_number column no longer exists. $orderNumber = $request->input('order_number'); $consumables->whereHas('orders', function ($query) use ($orderNumber) { $query->where('orders.order_number', '=', $orderNumber); }); } if ($request->filled('category_id')) { $consumables->where('consumables.category_id', '=', $request->input('category_id')); } if ($request->filled('model_number')) { $consumables->where('consumables.model_number', '=', $request->input('model_number')); } if ($request->filled('manufacturer_id')) { $consumables->where('consumables.manufacturer_id', '=', $request->input('manufacturer_id')); } if ($request->filled('supplier_id')) { $consumables->where('consumables.default_supplier_id', '=', $request->input('supplier_id')); } if ($request->filled('location_id')) { $consumables->where('consumables.location_id', '=', $request->input('location_id')); } if ($request->filled('notes')) { $consumables->where('consumables.notes', '=', $request->input('notes')); } // Make sure the offset and limit are actually integers and do not exceed system limits $total = $consumables->count(); $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); $limit = app('api_limit_value'); $order = $request->input('order') === 'asc' ? 'asc' : 'desc'; switch ($request->input('sort')) { case 'category': $consumables = $consumables->OrderCategory($order); break; case 'location': $consumables = $consumables->OrderLocation($order); break; case 'manufacturer': $consumables = $consumables->OrderManufacturer($order); break; case 'company': $consumables = $consumables->OrderCompany($order); break; case 'remaining': $consumables = $consumables->OrderRemaining($order); break; case 'percent_remaining': $consumables = $consumables->OrderPercentRemaining($order); break; case 'supplier': $consumables = $consumables->OrderSupplier($order); break; case 'created_by': $consumables = $consumables->OrderByCreatedBy($order); break; case 'purchase_cost': // See AccessoriesController for the rationale — these // three sorts walk order_items rather than removed // parent columns. $consumables = $consumables->OrderByLastPurchaseCost($order); break; case 'purchase_date': $consumables = $consumables->OrderByLastPurchaseDate($order); break; case 'total_cost': $consumables = $consumables->OrderByTotalOrderCost($order); break; default: $sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at'; $consumables = $consumables->orderBy($sort, $order); break; } $consumables = $consumables->skip($offset)->take($limit)->get(); return (new ConsumablesTransformer)->transformConsumables($consumables, $total); } /** * Store a newly created resource in storage. * * @author [A. Gianotto] [] * * @since [v4.0] * * @param ImageUploadRequest $request */ public function store(StoreConsumableRequest $request): JsonResponse { $this->authorize('create', Consumable::class); $consumable = new Consumable; $consumable->fill($request->all()); $consumable->company_id = Company::getIdForCurrentUser($request->input('company_id')); // See AccessoriesController::store for the default-supplier seeding rationale. if (! $request->filled('default_supplier_id') && $request->filled('supplier_id')) { $consumable->default_supplier_id = $request->input('supplier_id'); } $consumable = $request->handleImages($consumable); if ($consumable->save()) { $this->enrichInitialOrderFromRequest($request, $consumable); return response()->json(Helper::formatStandardApiResponse('success', $consumable, trans('admin/consumables/message.create.success'))); } return response()->json(Helper::formatStandardApiResponse('error', null, $consumable->getErrors())); } /** * Display the specified resource. * * @author [A. Gianotto] [] * * @param int $id */ public function show($id): array { $this->authorize('view', Consumable::class); $consumable = Consumable::with('users')->findOrFail($id); return (new ConsumablesTransformer)->transformConsumable($consumable); } /** * Update the specified resource in storage. * * @author [A. Gianotto] [] * * @since [v4.0] * * @param ImageUploadRequest $request * @param int $id */ public function update(StoreConsumableRequest $request, $id): JsonResponse { $this->authorize('update', Consumable::class); $consumable = Consumable::findOrFail($id); // See Api\AccessoriesController::update for the qty / order_number // / supplier_id contract. Same logic mirrored here. $qtyBefore = (int) $consumable->qty; $qtyRequested = $request->has('qty') ? (int) $request->input('qty') : $qtyBefore; $qtyDelta = $qtyRequested - $qtyBefore; // supplier_id, purchase_date, purchase_cost, and order_number // are create-only on the parent. Post-create acquisitions live // as Orders + OrderItems, so update-mode drops all four. $consumable->fill($request->except([ 'qty', 'order_number', 'purchase_cost', 'purchase_date', 'supplier_id', ])); $consumable->company_id = Company::getIdForCurrentUser($request->input('company_id')); $consumable = $request->handleImages($consumable); if (! $consumable->save()) { return response()->json(Helper::formatStandardApiResponse('error', null, $consumable->getErrors())); } if ($qtyDelta !== 0) { $orderId = $this->resolveOrderForAdjustment($request, $consumable, $qtyDelta); try { $consumable->adjustQuantity( $qtyDelta, $request->input('note') ?: "API qty change: {$qtyBefore} → {$qtyRequested}", $orderId, ); } catch (DomainException) { return response()->json( Helper::formatStandardApiResponse('error', null, trans('general.adjust_quantity_below_zero')), 422, ); } } return response()->json(Helper::formatStandardApiResponse('success', $consumable, trans('admin/consumables/message.update.success'))); } /** * See Api\AccessoriesController::adjustQuantity for the shape/contract. */ public function adjustQuantity(AdjustQuantityRequest $request, Consumable $consumable): JsonResponse { return $this->adjustQuantityAsJson($request, $consumable); } /** * Remove the specified resource from storage. * * @author [A. Gianotto] [] * * @since [v4.0] * * @param int $id */ public function destroy($id): JsonResponse { $this->authorize('delete', Consumable::class); $consumable = Consumable::findOrFail($id); $this->authorize('delete', $consumable); $consumable->delete(); return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.delete.success'))); } /** * Returns a JSON response containing details on the users associated with this consumable. * * @author [A. Gianotto] [] * * @see \App\Http\Controllers\Consumables\ConsumablesController::getView() method that returns the form. * @since [v1.0] * * @param int $consumableId */ public function getDataView($consumableId): array { $consumable = Consumable::with(['consumableAssignments' => function ($query) { $query->orderBy($query->getModel()->getTable().'.created_at', 'DESC'); }, 'consumableAssignments.adminuser' => function ($query) {}, 'consumableAssignments.user' => function ($query) {}, ])->find($consumableId); if (! Company::isCurrentUserHasAccess($consumable)) { return ['total' => 0, 'rows' => []]; } $this->authorize('view', Consumable::class); $rows = []; foreach ($consumable->consumableAssignments as $consumable_assignment) { $rows[] = [ 'avatar' => ($consumable_assignment->user) ? e($consumable_assignment->user->present()->gravatar) : '', 'user' => ($consumable_assignment->user) ? [ 'id' => (int) $consumable_assignment->user->id, 'name' => e($consumable_assignment->user->display_name), ] : null, 'created_at' => Helper::getFormattedDateObject($consumable_assignment->created_at, 'datetime'), 'note' => ($consumable_assignment->note) ? e($consumable_assignment->note) : null, 'created_by' => ($consumable_assignment->adminuser) ? [ 'id' => (int) $consumable_assignment->adminuser->id, 'name' => e($consumable_assignment->adminuser->display_name), ] : null, ]; } $consumableCount = $consumable->users->count(); $data = ['total' => $consumableCount, 'rows' => $rows]; return $data; } /** * Checkout a consumable * * @author [A. Gutierrez] [] * * @param int $id * * @since [v4.9.5] */ public function checkout(Request $request, $id): JsonResponse { // Check if the consumable exists if (! $consumable = Consumable::with('users')->find($id)) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.does_not_exist'))); } $this->authorize('checkout', $consumable); $consumable->checkout_qty = $request->input('checkout_qty', 1); // Make sure there is at least one available to checkout if ($consumable->numRemaining() <= 0) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable'))); } // Make sure there is a valid category if (! $consumable->category) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]))); } // Make sure there is at least one available to checkout if ($consumable->numRemaining() <= 0 || $consumable->checkout_qty > $consumable->numRemaining()) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()]))); } // Resolve the raw target first, then enforce FMCS explicitly. // Scoped lookup can hide cross-company users and make failures ambiguous. $user = User::withoutGlobalScopes()->find($request->input('assigned_to')); // withoutGlobalScopes bypasses SoftDeletes so we can tell "no such // user" from "user in another company" for FMCS messaging. Trashed // users must not be treated as valid checkout targets. if ($user && ! empty($user->deleted_at)) { $user = null; } if (! $user) { return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found')); } if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $user->companies()->where('companies.id', $consumable->company_id)->exists())) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company'))); } // Update the consumable data $consumable->assigned_to = $request->input('assigned_to'); // Concurrency guard. The unlocked numRemaining() check above is // advisory only — two simultaneous checkout requests can both read // "1 remaining", both pass the check, both attach a pivot row, and // land the register at -1. Re-fetch the parent row under // lockForUpdate INSIDE the transaction, re-check availability // against the locked snapshot, and only then write. Any concurrent // checkout blocks on the row lock until this transaction commits. // Mirrors the pattern already used by License checkout (which locks // LicenseSeat rows). $errorResponse = null; DB::transaction(function () use ($consumable, $request, $user, &$errorResponse): void { $locked = Consumable::whereKey($consumable->id)->lockForUpdate()->first(); if (! $locked || $locked->numRemaining() < $consumable->checkout_qty) { $errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', [ 'requested' => $consumable->checkout_qty, 'remaining' => $locked ? $locked->numRemaining() : 0, ]))); return; } for ($i = 0; $i < $consumable->checkout_qty; $i++) { $consumable->users()->attach($consumable->id, [ 'consumable_id' => $consumable->id, // The pivot's created_by is the operator recording the // checkout, not the checkout target. Sibling paths // (web ConsumableCheckoutController, web + API // ComponentCheckoutController) all key off auth()->id(). 'created_by' => auth()->id(), 'assigned_to' => $request->input('assigned_to'), 'note' => $request->input('note'), ] ); } event(new CheckoutableCheckedOut( $consumable, $user, auth()->user(), $request->input('note'), [], $consumable->checkout_qty, )); }); if ($errorResponse) { return $errorResponse; } return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success'))); } /** * Gets a paginated collection for the select2 menus * * @see SelectlistTransformer */ public function selectlist(Request $request): array { $this->authorize('view.selectlists'); $consumables = Consumable::select([ 'consumables.id', 'consumables.name', ]); if ($request->filled('search')) { $consumables = $consumables->where('consumables.name', 'LIKE', '%'.$request->input('search').'%'); } $consumables = $consumables->orderBy('name', 'ASC')->paginate(50); return (new SelectlistTransformer)->transformSelectlist($consumables); } public function history(Request $request, Consumable $consumable): JsonResponse|array { $this->authorize('history', $consumable); $historyQuery = $consumable->getHistory($request); $total = (clone $historyQuery)->count(); $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); $limit = app('api_limit_value'); $history = (clone $historyQuery)->skip($offset)->take($limit)->get(); return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE); } }