] * * @since [v4.0] * * @return Response */ public function index(Request $request) { if ($request->user()->cannot('reports.view')) { $this->authorize('view', Accessory::class); } // This array is what determines which fields should be allowed to be sorted on ON the table itself, no relations // Relations will be handled in query scopes a little further down. $allowed_columns = [ 'id', 'name', 'model_number', 'eol', 'notes', 'purchase_cost', 'purchase_date', 'created_at', 'updated_at', 'min_amt', 'company_id', 'notes', 'checkouts_count', 'image', 'qty', // 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', ]; $accessories = Accessory::select('accessories.*') ->with('category', 'company', 'manufacturer', 'checkouts', 'location', 'supplier', 'adminuser') ->withCount('checkouts as checkouts_count'); // 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')) { $accessories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search')); } 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')) { $accessories->whereIn('accessories.company_id', Company::reachableCompanyIds($request->input('company_id'))); } else { $accessories->where('accessories.company_id', '=', $request->input('company_id')); } } if ($request->filled('order_number')) { // Reroute through the HasOrders orders() HasManyThrough since // the parent accessories.order_number column no longer exists. $orderNumber = $request->input('order_number'); $accessories->whereHas('orders', function ($query) use ($orderNumber) { $query->where('orders.order_number', '=', $orderNumber); }); } if ($request->filled('category_id')) { $accessories->where('accessories.category_id', '=', $request->input('category_id')); } if ($request->filled('manufacturer_id')) { $accessories->where('accessories.manufacturer_id', '=', $request->input('manufacturer_id')); } if ($request->filled('supplier_id')) { $accessories->where('accessories.supplier_id', '=', $request->input('supplier_id')); } if ($request->filled('location_id')) { $accessories->where('accessories.location_id', '=', $request->input('location_id')); } if ($request->filled('notes')) { $accessories->where('accessories.notes', '=', $request->input('notes')); } // Make sure the offset and limit are actually integers and do not exceed system limits $total = $accessories->count(); $offset = ($request->input('offset') > $total) ? $total : app('api_offset_value'); $limit = app('api_limit_value'); $order = $request->input('order') === 'asc' ? 'asc' : 'desc'; $sort_override = $request->input('sort'); $column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at'; switch ($sort_override) { case 'category': $accessories = $accessories->OrderCategory($order); break; case 'company': $accessories = $accessories->OrderCompany($order); break; case 'location': $accessories = $accessories->OrderLocation($order); break; case 'manufacturer': $accessories = $accessories->OrderManufacturer($order); break; case 'supplier': $accessories = $accessories->OrderSupplier($order); break; case 'created_by': $accessories = $accessories->OrderByCreatedByName($order); break; case 'total_cost': $accessories = $accessories->orderByRaw('COALESCE(purchase_cost, 0) * qty '.$order); break; case 'percent_remaining': $accessories = $accessories->OrderPercentRemaining($order); break; default: $accessories = $accessories->orderBy($column_sort, $order); break; } $accessories = $accessories->skip($offset)->take($limit)->get(); return (new AccessoriesTransformer)->transformAccessories($accessories, $total); } /** * Store a newly created resource in storage. * * @param ImageUploadRequest $request * @return JsonResponse * * @author [A. Gianotto] [] * * @since [v4.0] */ public function store(StoreAccessoryRequest $request) { $accessory = new Accessory; $accessory->fill($request->all()); $accessory->company_id = Company::getIdForCurrentUser($request->input('company_id')); $accessory = $request->handleImages($accessory); if ($accessory->save()) { return response()->json(Helper::formatStandardApiResponse('success', $accessory, trans('admin/accessories/message.create.success'))); } return response()->json(Helper::formatStandardApiResponse('error', null, $accessory->getErrors())); } /** * Display the specified resource. * * @param int $id * @return array * * @author [A. Gianotto] [] * * @since [v4.0] */ public function show($id) { $this->authorize('view', Accessory::class); $accessory = Accessory::withCount('checkouts as checkouts_count')->findOrFail($id); return (new AccessoriesTransformer)->transformAccessory($accessory); } /** * Display the specified resource. * * @param int $id * @return array * * @author [A. Gianotto] [] * * @since [v4.0] */ public function accessory_detail($id) { $this->authorize('view', Accessory::class); $accessory = Accessory::findOrFail($id); return (new AccessoriesTransformer)->transformAccessory($accessory); } /** * Get the list of checkouts for a specific accessory * * @author [A. Gianotto] [] * * @since [v4.0] * * @param int $id * @return | array */ public function checkedout(Request $request, $id) { $this->authorize('view', Accessory::class); $accessory = Accessory::with('lastCheckout')->findOrFail($id); $offset = request('offset', 0); $limit = request('limit', 50); // Total count of all checkouts for this asset $accessory_checkouts = $accessory->checkouts(); // Check for search text in the request if ($request->filled('search')) { $accessory_checkouts = $accessory_checkouts->TextSearch($request->input('search')); } $total = $accessory_checkouts->count(); $accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get(); $accessory_checkouts->loadMorph('assignedTo', [ User::class => ['companies'], ]); return (new AccessoriesTransformer)->transformCheckedoutAccessory($accessory_checkouts, $total); } /** * Update the specified resource in storage. * * @author [A. Gianotto] [] * * @since [v4.0] * * @param int $id * @return JsonResponse */ public function update(ImageUploadRequest $request, $id) { $this->authorize('update', Accessory::class); $accessory = Accessory::findOrFail($id); // Payload shape is preserved for API back-compat: `qty`, // `order_number`, and `supplier_id` all remain accepted keys. // supplier_id flows through fill() like any other field. `qty` gets // pulled off the fill and routed through adjustQuantity() below so // any change writes a QuantityAdjust action_log entry rather than // silently overwriting. `order_number` no longer lives on the // parent — resolveOrderForAdjustment turns it into an Order + // OrderItem pair and passes the Order's id to the trait. $qtyBefore = (int) $accessory->qty; $qtyRequested = $request->has('qty') ? (int) $request->input('qty') : $qtyBefore; $qtyDelta = $qtyRequested - $qtyBefore; $accessory->fill($request->except(['qty', 'order_number'])); $accessory->company_id = Company::getIdForCurrentUser($request->input('company_id')); $accessory = $request->handleImages($accessory); if (! $accessory->save()) { return response()->json(Helper::formatStandardApiResponse('error', null, $accessory->getErrors())); } if ($qtyDelta !== 0) { $orderId = $this->resolveOrderForAdjustment($request, $accessory, $qtyDelta); try { $accessory->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', $accessory, trans('admin/accessories/message.update.success'))); } /** * Dedicated adjust-quantity endpoint. Signed delta semantics: positive * amount replenishes, negative decrements. Every call becomes a * QuantityAdjust action_log entry. Note is required (unlike the * general update path which synthesizes one). An optional file * attachment lands on the same log row via UploadFileRequest::handleFile. * See Api\AccessoriesController::update for the "qty inside PATCH" * alternative preserved for API back-compat. */ public function adjustQuantity(AdjustQuantityRequest $request, Accessory $accessory): JsonResponse { $error = $this->runAdjustQuantity($request, $accessory, 'accessories'); if ($error !== null) { return response()->json( Helper::formatStandardApiResponse('error', null, $error), 422, ); } return response()->json( Helper::formatStandardApiResponse('success', $accessory->fresh(), trans('general.adjust_quantity_success')), ); } /** * Remove the specified resource from storage. * * @author [A. Gianotto] [] * * @since [v4.0] * * @param int $id * @return JsonResponse */ public function destroy($id) { $this->authorize('delete', Accessory::class); $accessory = Accessory::withCount('checkouts as checkouts_count')->findOrFail($id); $this->authorize($accessory); if ($accessory->checkouts_count > 0) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/general.delete_disabled'))); } $accessory->delete(); return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/accessories/message.delete.success'))); } /** * Save the Accessory checkout information. * * If Slack is enabled and/or asset acceptance is enabled, it will also * trigger a Slack message and send an email. * * @param int $accessoryId * @return JsonResponse * * @author [A. Gianotto] [] */ public function checkout(AccessoryCheckoutRequest $request, Accessory $accessory) { $this->authorize('checkout', $accessory); $target = $this->determineCheckoutTarget(); if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $target->companies()->where('companies.id', $accessory->company_id)->exists())) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company'))); } $accessory->checkout_qty = $request->input('checkout_qty', 1); $payload = null; $overAllocated = false; // Concurrency guard. AccessoryCheckoutRequest validated // number_remaining_after_checkout >= 0 with an unlocked read of // numRemaining(), so two simultaneous checkout requests could both // pass validation, both attach rows, 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. Mirrors the License checkout locking // pattern. DB::transaction(function () use ($accessory, $request, $target, &$payload, &$overAllocated): void { $locked = Accessory::whereKey($accessory->id)->lockForUpdate()->first(); if (! $locked || $locked->numRemaining() < $accessory->checkout_qty) { $overAllocated = true; return; } for ($i = 0; $i < $accessory->checkout_qty; $i++) { $accessory_checkout = new AccessoryCheckout([ 'accessory_id' => $accessory->id, 'created_at' => Carbon::now(), 'assigned_to' => $target->id, 'assigned_type' => $target::class, 'note' => $request->input('note'), ]); $accessory_checkout->created_by = auth()->id(); $accessory_checkout->save(); $payload = [ 'accessory_id' => $accessory->id, 'assigned_to' => $target->id, 'assigned_type' => $target::class, 'note' => $request->input('note'), 'created_by' => auth()->id(), 'pivot' => $accessory_checkout->id, ]; } // Set this value to be able to pass the qty through to the event. event(new CheckoutableCheckedOut( $accessory, $target, auth()->user(), $request->input('note'), [], $accessory->checkout_qty, )); }); if ($overAllocated) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.checkout.unavailable'))); } return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success'))); } /** * Check in the item so that it can be checked out again to someone else * * @param int $accessoryUserId * @param string $backto * @return JsonResponse * * @uses Accessory::checkin_email() to determine if an email can and should be sent * * @author [A. Gianotto] [] * * @internal param int $accessoryId */ public function checkin(Request $request, $accessoryUserId = null) { if (is_null($accessory_checkout = AccessoryCheckout::find($accessoryUserId))) { return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.does_not_exist', ['id' => $accessoryUserId]))); } $accessory = Accessory::find($accessory_checkout->accessory_id); $this->authorize('checkin', $accessory); $accessory->logCheckin(User::find($accessory_checkout->assigned_to), $request->input('note')); // Was the accessory updated? if ($accessory_checkout->delete()) { if (! is_null($accessory_checkout->assigned_to)) { $user = User::find($accessory_checkout->assigned_to); } $payload = [ 'accessory_id' => $accessory->id, 'note' => $request->input('note'), 'created_by' => auth()->id(), 'pivot' => $accessory_checkout->id, ]; return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkin.success'))); } return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.checkin.error'))); } /** * Gets a paginated collection for the select2 menus * * @see SelectlistTransformer */ public function selectlist(Request $request) { $this->authorize('view.selectlists'); $accessories = Accessory::select([ 'accessories.id', 'accessories.name', ]); if ($request->filled('search')) { $accessories = $accessories->where('accessories.name', 'LIKE', '%'.$request->input('search').'%'); } $accessories = $accessories->orderBy('name', 'ASC')->paginate(50); return (new SelectlistTransformer)->transformSelectlist($accessories); } public function history(Request $request, Accessory $accessory): JsonResponse|array { $this->authorize('history', $accessory); $historyQuery = $accessory->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); } }