3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Http/Controllers/Api/AssetsController.php
2026-07-06 16:17:19 +01:00

1978 lines
75 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedIn;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Requests\BulkUpdateAssetsRequest;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAssetRequest;
use App\Http\Requests\UpdateAssetRequest;
use App\Http\Requests\UploadFileRequest;
use App\Http\Traits\MigratesLegacyAssetLocations;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\AccessoryCheckout;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\ComponentAssignment;
use App\Models\CustomField;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Location;
use App\Models\Setting;
use App\Models\User;
use App\View\Label;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class controls all actions related to assets for
* the Snipe-IT Asset Management application.
*
* @version v1.0
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*/
class AssetsController extends Controller
{
use MigratesLegacyAssetLocations;
/**
* Returns JSON listing of all assets
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param int $assetId
*
* @since [v4.0]
*/
public function index(FilterRequest $request, $action = null, $upcoming_status = null): JsonResponse|array
{
// This handles the legacy audit endpoints :(
if ($action == 'audit') {
$action = 'audits';
}
$filter_non_deprecable_assets = false;
/**
* This looks MAD janky (and it is), but the AssetsController@index does a LOT of heavy lifting throughout the
* app. This bit here just makes sure that someone without permission to view assets doesn't
* end up with priv escalations because they asked for a different endpoint.
*
* Since we never gave the specification for which transformer to use before, it should default
* gracefully to just use the AssetTransformer by default, which shouldn't break anything.
*
* It was either this mess, or repeating ALL of the searching and sorting and filtering code,
* which would have been far worse of a mess. *sad face* - snipe (Sept 1, 2021)
*/
if (Route::currentRouteName() == 'api.depreciation-report.index') {
$filter_non_deprecable_assets = true;
$transformer = 'App\Http\Transformers\DepreciationReportTransformer';
$this->authorize('reports.view');
} else {
$transformer = 'App\Http\Transformers\AssetsTransformer';
$this->authorize('index', Asset::class);
}
$settings = Setting::getSettings();
$allowed_columns = [
'id',
'name',
'asset_tag',
'serial',
'model_number',
'last_checkout',
'last_checkin',
'notes',
'expected_checkin',
'order_number',
'image',
'assigned_to',
'created_at',
'updated_at',
'purchase_date',
'purchase_cost',
'last_audit_date',
'next_audit_date',
'warranty_months',
'checkout_counter',
'checkin_counter',
'requests_counter',
'byod',
'asset_eol_date',
'requestable',
'jobtitle',
// 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',
'model',
'location',
'rtd_location',
'category',
'manufacturer',
'supplier',
'status',
'jobtitle',
'assigned_to',
'created_by',
];
$all_custom_fields = CustomField::all(); // used as a 'cache' of custom fields throughout this page load
foreach ($all_custom_fields as $field) {
$allowed_columns[] = $field->db_column_name();
}
$assets = Asset::select('assets.*')
// ->addSelect([
// 'first_checkout_at' => Actionlog::query()
// ->select('created_at')
// ->whereColumn('item_id', 'assets.id')
// ->where('item_type', Asset::class)
// ->where('action_type', 'checkout')
// ->orderBy('created_at')
// ->limit(1),
// ])
->with(
'model',
'location',
'status',
'company',
'defaultLoc',
'assignedTo',
'adminuser',
'model.depreciation',
'model.category',
'model.manufacturer',
'model.fieldset',
'model.depreciation',
'supplier'
); // it might be tempting to add 'assetlog' here, but don't. It blows up update-heavy users.
if ($filter_non_deprecable_assets) {
$non_deprecable_models = AssetModel::select('id')->whereNotNull('depreciation_id')->get();
$assets->InModelList($non_deprecable_models->toArray());
}
// 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')) {
$assets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
/**
* Handle due and overdue audits and checkin dates
*/
switch ($action) {
// Audit (singular) is left over from earlier legacy APIs
case 'audits':
switch ($upcoming_status) {
case 'due':
$assets->DueForAudit($settings);
break;
case 'overdue':
$assets->OverdueForAudit();
break;
case 'due-or-overdue':
$assets->DueOrOverdueForAudit($settings);
break;
}
break;
case 'checkins':
switch ($upcoming_status) {
case 'due':
$assets->DueForCheckin($settings);
break;
case 'overdue':
$assets->OverdueForCheckin();
break;
case 'due-or-overdue':
$assets->DueOrOverdueForCheckin($settings);
break;
}
break;
}
/**
* End handling due and overdue audits and checkin dates
*/
// This is used by the sidenav, mostly
// This bit here accounts for folks actually using the formerly-known-as status like we previously used in the sidenav
// to return a list of all assets with the status *type* of Deployed, etc. The inuput field used to be "status" (which was consistent
// with the relation rename, but it broke the sidebar. This should handle both use cases in the event that someone didn't update
// their API integration code
$status_type_key = null;
if ($request->filled('status_type')) {
$status_type_key = $request->input('status_type');
} elseif ($request->filled('status')) {
$status_type_key = $request->input('status');
}
switch ($status_type_key) {
case 'Deleted':
$assets->onlyTrashed();
break;
case 'Pending':
$assets->join('status_labels AS status_alias', function ($join) {
$join->on('status_alias.id', '=', 'assets.status_id')
->where('status_alias.deployable', '=', 0)
->where('status_alias.pending', '=', 1)
->where('status_alias.archived', '=', 0);
});
break;
case 'RTD':
$assets->whereNull('assets.assigned_to')
->join('status_labels AS status_alias', function ($join) {
$join->on('status_alias.id', '=', 'assets.status_id')
->where('status_alias.deployable', '=', 1)
->where('status_alias.pending', '=', 0)
->where('status_alias.archived', '=', 0);
});
break;
case 'Undeployable':
$assets->Undeployable();
break;
case 'Archived':
$assets->join('status_labels AS status_alias', function ($join) {
$join->on('status_alias.id', '=', 'assets.status_id')
->where('status_alias.deployable', '=', 0)
->where('status_alias.pending', '=', 0)
->where('status_alias.archived', '=', 1);
});
break;
case 'Requestable':
$assets->where('assets.requestable', '=', 1)
->join('status_labels AS status_alias', function ($join) {
$join->on('status_alias.id', '=', 'assets.status_id')
->where('status_alias.deployable', '=', 1)
->where('status_alias.pending', '=', 0)
->where('status_alias.archived', '=', 0);
});
break;
case 'Deployed':
// more sad, horrible workarounds for laravel bugs when doing full text searches
$assets->whereNotNull('assets.assigned_to');
break;
case 'byod':
// This is kind of redundant, since we already check for byod=1 above, but this keeps the
// sidebar nav links a little less chaotic
$assets->where('assets.byod', '=', '1');
break;
default:
if ((! $request->filled('status_id')) && ($settings->show_archived_in_list != '1')) {
// terrible workaround for complex-query Laravel bug in fulltext
$assets->join('status_labels AS status_alias', function ($join) {
$join->on('status_alias.id', '=', 'assets.status_id')
->where('status_alias.archived', '=', 0);
});
// If there is a status ID, don't take show_archived_in_list into consideration
} else {
$assets->join('status_labels AS status_alias', function ($join) {
$join->on('status_alias.id', '=', 'assets.status_id');
});
}
}
// Leave these under the TextSearch scope, else the fuzziness will override the specific ID (status ID, etc) requested
if ($request->filled('status_id')) {
$assets->where('assets.status_id', '=', $request->input('status_id'));
}
if ($request->filled('asset_tag')) {
$assets->where('assets.asset_tag', '=', $request->input('asset_tag'));
}
if ($request->filled('serial')) {
$assets->where('assets.serial', '=', $request->input('serial'));
}
if ($request->input('requestable') == 'true') {
$assets->where('assets.requestable', '=', '1');
}
if ($request->filled('model_id')) {
// If model_id is already an array, just use it as-is
if (is_array($request->input('model_id'))) {
$assets->InModelList($request->input('model_id'));
} else {
// Otherwise, turn it into an array
$assets->InModelList([$request->input('model_id')]);
}
}
if ($request->filled('category_id')) {
$assets->InCategory($request->input('category_id'));
}
if ($request->filled('location_id')) {
$assets->where('assets.location_id', '=', $request->input('location_id'));
}
if ($request->filled('rtd_location_id')) {
$assets->where('assets.rtd_location_id', '=', $request->input('rtd_location_id'));
}
if ($request->filled('supplier_id')) {
$assets->where('assets.supplier_id', '=', $request->input('supplier_id'));
}
if ($request->filled('asset_eol_date')) {
$assets->where('assets.asset_eol_date', '=', $request->input('asset_eol_date'));
}
if (($request->filled('assigned_to')) && ($request->filled('assigned_type'))) {
$assets->where('assets.assigned_to', '=', $request->input('assigned_to'))
->where('assets.assigned_type', '=', $request->input('assigned_type'));
}
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')) {
$assets->whereIn('assets.company_id', Company::reachableCompanyIds($request->input('company_id')));
} else {
$assets->where('assets.company_id', '=', $request->input('company_id'));
}
}
if ($request->filled('manufacturer_id')) {
$assets->ByManufacturer($request->input('manufacturer_id'));
}
if ($request->filled('depreciation_id')) {
$assets->ByDepreciationId($request->input('depreciation_id'));
}
if ($request->filled('byod')) {
$assets->where('assets.byod', '=', $request->input('byod'));
}
if ($request->filled('order_number')) {
$assets->where('assets.order_number', '=', strval($request->input('order_number')));
}
foreach ($all_custom_fields as $field) {
if ($field->db_column_name() && $request->filled($field->db_column_name())) {
$assets->where($field->db_column_name(), '=', $request->input($field->db_column_name()));
}
}
// This is kinda gross, but we need to do this because the Bootstrap Tables
// API passes custom field ordering as custom_fields.fieldname, and we have to strip
// that out to let the default sorter below order them correctly on the assets table.
$sort_override = str_replace('custom_fields.', '', $request->input('sort'));
// This handles all of the pivot sorting (versus the assets.* fields
// in the allowed_columns array)
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'assets.created_at';
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
switch ($sort_override) {
case 'model':
$assets->OrderModels($order);
break;
case 'model_number':
$assets->OrderModelNumber($order);
break;
case 'category':
$assets->OrderCategory($order);
break;
case 'manufacturer':
$assets->OrderManufacturer($order);
break;
case 'company':
$assets->OrderCompany($order);
break;
case 'location':
$assets->OrderLocation($order);
case 'rtd_location':
$assets->OrderRtdLocation($order);
break;
case 'status':
$assets->OrderStatus($order);
break;
case 'supplier':
$assets->OrderSupplier($order);
break;
case 'assigned_to':
$assets->OrderAssigned($order);
break;
case 'jobtitle':
$assets->OrderByJobTitle($order);
break;
case 'created_by':
$assets->OrderByCreatedByName($order);
break;
case 'eol':
$assets->orderBy('assets.asset_eol_date', $order);
break;
default:
$numeric_sort = false;
// Search through the custom fields array to see if we're sorting on a custom field
if (array_search($column_sort, $all_custom_fields->pluck('db_column')->toArray()) !== false) {
// Check to see if this is a numeric field type
foreach ($all_custom_fields as $field) {
if (($field->db_column == $sort_override) && ($field->format == 'NUMERIC')) {
$numeric_sort = true;
break;
}
}
// This may not work for all databases, but it works for MySQL
if ($numeric_sort) {
$assets->orderByRaw(DB::getTablePrefix().'assets.'.$sort_override.' * 1 '.$order);
} else {
$assets->orderBy($sort_override, $order);
}
} else {
$assets->orderBy($column_sort, $order);
}
break;
}
// Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $assets->count()) ? $assets->count() : app('api_offset_value');
$limit = app('api_limit_value');
$total = $assets->count();
$assets = $assets->skip($offset)->take($limit)->get();
/**
* Include additional associated relationships
*/
if ($request->input('components')) {
$assets->loadMissing(['components' => function ($query) {
$query->orderBy('created_at', 'desc');
}]);
}
return (new $transformer)->transformAssets($assets, $total, $request);
}
/**
* Returns JSON with information about an asset (by tag) for detail view.
*
* @param string $tag
*
* @since [v4.2.1]
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*/
public function showByTag(Request $request, $tag): JsonResponse|array
{
$this->authorize('index', Asset::class);
$assets = Asset::where('asset_tag', $tag)->with('status')->with('assignedTo');
// Check if they've passed ?deleted=true
if ($request->input('deleted', 'false') == 'true') {
$assets = $assets->withTrashed();
}
if (($assets = $assets->get()) && ($assets->count()) > 0) {
// If there is exactly one result and the deleted parameter is not passed, we should pull the first (and only)
// asset from the returned collection, since transformAsset() expects an Asset object, NOT a collection
if (($assets->count() == 1) && ($request->input('deleted') != 'true')) {
return (new AssetsTransformer)->transformAsset($assets->first());
// If there is more than one result OR if the endpoint is requesting deleted items (even if there is only one
// match, return the normal collection transformed.
} else {
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
}
}
// If there are 0 results, return the "no such asset" response
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
}
/**
* Returns JSON with information about an asset (by serial) for detail view.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param string $serial
*
* @since [v4.2.1]
*
* @return JsonResponse
*/
public function showBySerial(Request $request, $serial): JsonResponse|array
{
$this->authorize('index', Asset::class);
$assets = Asset::where('serial', $serial)->with([
'status',
'assignedTo',
'company',
'defaultLoc',
'location',
'model.category',
'model.depreciation',
'model.fieldset',
'model.manufacturer',
'supplier',
]);
// Check if they've passed ?deleted=true
if ($request->input('deleted', 'false') == 'true') {
$assets = $assets->withTrashed();
}
$offset = ($request->input('offset') > $assets->count()) ? $assets->count() : app('api_offset_value');
$limit = app('api_limit_value');
$total = $assets->count();
$assets = $assets->skip($offset)->take($limit)->get();
if (($assets) && ($assets->count()) > 0) {
return (new AssetsTransformer)->transformAssets($assets, $total);
}
// If there are 0 results, return the "no such asset" response
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
}
/**
* Returns JSON with information about an asset for detail view.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param int $assetId
*
* @since [v4.0]
*
* @return JsonResponse
*/
public function show(Request $request, $id): JsonResponse|array
{
if ($asset = Asset::with('status')
->with('assignedTo')->withTrashed()
->withCount('checkins as checkins_count', 'checkouts as checkouts_count', 'userRequests as user_requests_count')->find($id)
) {
$this->authorize('view', $asset);
return (new AssetsTransformer)->transformAsset($asset, $request->input('components'));
}
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
}
public function licenses(Asset $asset): array
{
$this->authorize('view', $asset);
$this->authorize('view', License::class);
$licenses = $asset->licenseseats()->get();
return (new AssetsTransformer)->transformLicensesCheckedToAsset($licenses, $licenses->count());
}
/**
* Gets a paginated collection for the select2 menus
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v4.0.16]
* @see SelectlistTransformer
*/
public function selectlist(Request $request): array
{
$this->authorize('view.selectlists');
$assets = Asset::select([
'assets.id',
'assets.name',
'assets.asset_tag',
'assets.model_id',
'assets.assigned_to',
'assets.assigned_type',
'assets.status_id',
])->with('model', 'status', 'assignedTo')
->NotArchived();
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
$assets = Company::scopeCompanyables($assets);
// Allow further narrowing to a specific company passed via data-company-id on the select.
// Superusers MUST bypass this filter — they manage across companies and need to see every
// asset on checkout dropdowns. Scoping superusers to the item's company breaks the umbrella-
// corp / service-provider workflow where one admin checks items out across sub-companies.
// See: https://github.com/snipe/snipe-it/issues/ (v8.6.3 regression report)
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& $request->filled('companyId')
&& ! auth()->user()->isSuperUser()) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
$assets->whereIn('assets.company_id', $companyIds);
}
}
if ($request->filled('excludeId')) {
$assets->where('assets.id', '!=', (int) $request->input('excludeId'));
}
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
$assets = $assets->RTD();
}
if ($request->filled('search')) {
$assets = $assets->AssignedSearch($request->input('search'));
}
$assets = $assets->paginate(50);
// Loop through and set some custom properties for the transformer to use.
// This lets us have more flexibility in special cases like assets, where
// they may not have a ->name value but we want to display something anyway
foreach ($assets as $asset) {
$asset->use_text = $asset->present()->fullName;
if (($asset->checkedOutToUser()) && ($asset->assigned)) {
$asset->use_text .= ' → '.$asset->assigned->display_name;
}
if ($asset->status->getStatuslabelType() == 'pending') {
$asset->use_text .= '('.$asset->status->getStatuslabelType().')';
}
$asset->use_image = ($asset->getImageUrl()) ? $asset->getImageUrl() : null;
}
return (new SelectlistTransformer)->transformSelectlist($assets);
}
/**
* Accepts a POST request to create a new asset
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param ImageUploadRequest $request
*
* @since [v4.0]
*/
public function store(StoreAssetRequest $request): JsonResponse
{
$asset = new Asset;
$asset->model()->associate(AssetModel::find((int) $request->input('model_id')));
$asset->fill($request->validated());
$asset->created_by = auth()->id();
/**
* this is here just legacy reasons. Api\AssetController
* used image_source once to allow encoded image uploads.
*/
if ($request->has('image_source')) {
$request->offsetSet('image', $request->offsetGet('image_source'));
}
$asset = $request->handleImages($asset);
// Update custom fields in the database.
$model = AssetModel::find($request->input('model_id'));
// Check that it's an object and not a collection
// (Sometimes people send arrays here and they shouldn't
if (($model) && ($model instanceof AssetModel) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field) {
// Set the field value based on what was sent in the request
$field_val = $request->input($field->db_column, null);
// If input value is null, use custom field's default value
if ($field_val == null) {
Log::debug('Field value for '.$field->db_column.' is null');
$field_val = $field->defaultValue($request->input('model_id'));
Log::debug('Use the default fieldset value of '.$field->defaultValue($request->input('model_id')));
}
// if the field is set to encrypted, make sure we encrypt the value
if ($field->field_encrypted == '1') {
Log::debug('This model field is encrypted in this fieldset.');
if (Gate::allows('assets.view.encrypted_custom_fields')) {
// If input value is null, use custom field's default value
if (($field_val == null) && ($request->has('model_id') != '')) {
$field_val = Crypt::encrypt($field->defaultValue($request->input('model_id')));
} else {
$field_val = Crypt::encrypt($request->input($field->db_column));
}
} else {
continue;
}
}
if ($field->element == 'checkbox') {
if (is_array($field_val)) {
$field_val = implode(',', $field_val);
}
}
$asset->{$field->db_column} = $field_val;
}
}
$target = $this->resolveCheckoutTargetForAssetMutation($request);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
}
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if ($requestedCheckout) {
// Keep create + optional checkout side effects atomic.
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
return true;
});
if ($stored) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
return response()->json(Helper::formatStandardApiResponse('success', $asset, trans('admin/hardware/message.create.success')));
// below is what we want the _eventual_ return to look like - in a more standardized format.
// return response()->json(Helper::formatStandardApiResponse('success', (new AssetsTransformer)->transformAsset($asset), trans('admin/hardware/message.create.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
}
/**
* Update a single asset. Route-model-binding on {asset} guarantees the
* caller is either operating on a real Asset or 404'd at routing.
* Response shape is the legacy `{status, messages, payload}` body every
* integration already knows.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v4.0]
*/
public function update(UpdateAssetRequest $request, Asset $asset): JsonResponse
{
$result = $this->applyAssetUpdate($asset, $request);
return response()->json($this->formatSingleAssetResponse($result));
}
/**
* Bulk update: `ids` in the request body names which assets to touch,
* every other field is the update payload applied to each of them.
* Per-row envelope response so callers can see which ids succeeded and
* which failed without looping the endpoint themselves.
*/
public function bulkUpdate(BulkUpdateAssetsRequest $request): JsonResponse
{
$ids = array_values(array_unique(array_map('intval', (array) $request->input('ids', []))));
$assets = Asset::whereIn('id', $ids)->get()->keyBy('id');
$results = [];
$success_count = 0;
$error_count = 0;
foreach ($ids as $id) {
if (! $assets->has($id)) {
$results[] = [
'id' => $id,
'status' => 'error',
'messages' => trans('admin/hardware/message.does_not_exist'),
'payload' => null,
];
$error_count++;
continue;
}
$asset = $assets->get($id);
// Per-row auth: a caller who can update one asset may not be able
// to update another (FMCS, tightened policy). Deny one row rather
// than 403ing the whole batch.
if (! Gate::allows('update', $asset)) {
$results[] = [
'id' => $id,
'status' => 'error',
'messages' => trans('general.unauthorized'),
'payload' => null,
];
$error_count++;
continue;
}
$result = $this->applyAssetUpdate($asset, $request);
$row = $this->buildRowResult($id, $result);
$results[] = $row;
$row['status'] === 'success' ? $success_count++ : $error_count++;
}
return response()->json($this->assembleBulkResponse($results, $success_count, $error_count));
}
/**
* Turn a single applyAssetUpdate() result into the per-row shape used by the
* bulk response. Reuses formatSingleAssetResponse() so the row's payload +
* error handling stays byte-identical to the legacy singular response.
*/
private function buildRowResult(int $id, array $result): array
{
$formatted = $this->formatSingleAssetResponse($result);
return [
'id' => $id,
'status' => $formatted['status'],
'messages' => $formatted['messages'],
'payload' => $formatted['payload'],
];
}
/**
* Compute the overall status + summary message for a batch and return the
* full bulk response envelope. Used by bulkUpdate() and by update() when
* the caller opts into the bulk shape via `?response=bulk`.
*/
private function assembleBulkResponse(array $results, int $success_count, int $error_count): array
{
$overall_status = match (true) {
$error_count === 0 => 'success',
$success_count === 0 => 'error',
default => 'partial',
};
$message = match ($overall_status) {
'success' => trans_choice('admin/hardware/message.bulk_update.success', $success_count, ['count' => $success_count]),
'partial' => trans('admin/hardware/message.bulk_update.partial', ['success' => $success_count, 'failed' => $error_count]),
'error' => trans('admin/hardware/message.bulk_update.error'),
};
return [
'status' => $overall_status,
'messages' => $message,
'results' => $results,
];
}
/**
* Format an applyAssetUpdate() result into the legacy single-asset API
* response body. Used by update() (default shape) and by buildRowResult()
* to shape each row of a bulk response.
*
* Kept as a flat asset payload (not through AssetsTransformer) so the
* response continues to match the shape existing integrations rely on.
*/
private function formatSingleAssetResponse(array $result): array
{
if (! empty($result['error_response_key'])) {
return Helper::formatStandardApiResponse('error', null, trans($result['error_response_key']));
}
if (! $result['success']) {
return Helper::formatStandardApiResponse('error', null, $result['errors']);
}
return Helper::formatStandardApiResponse('success', $result['asset'], trans($result['message']));
}
/**
* Apply a validated UpdateAssetRequest payload to a single Asset.
*
* Shared between update() and bulkUpdate(), so the transaction, checkout
* side-effects, and encrypted-custom-field gating live in one place.
* Returns the neutral result shape that formatSingleAssetResponse() /
* buildRowResult() turn into the legacy or bulk-envelope response body.
*
* @return array{
* success: bool,
* asset: Asset|null,
* message: string|null,
* errors: mixed,
* encrypted_field_warning: bool,
* error_response_key: string|null,
* }
*
* `error_response_key` is set for early-exit conditions that need a
* specific translation key rather than a validation-errors payload
* (e.g. an unresolvable checkout target). Callers translate.
*/
private function applyAssetUpdate(Asset $asset, UpdateAssetRequest $request): array
{
$validated = $request->validated();
$asset->fill($validated);
if ($request->has('model_id')) {
$asset->model()->associate(AssetModel::find($validated['model_id']));
}
if ($request->has('company_id')) {
$asset->company_id = Company::getIdForCurrentUser($validated['company_id']);
}
if ($request->has('rtd_location_id') && ! $request->has('location_id')) {
$asset->location_id = $validated['rtd_location_id'];
}
if ($request->input('last_audit_date')) {
$asset->last_audit_date = Carbon::parse($request->input('last_audit_date'))->startOfDay()->format('Y-m-d H:i:s');
}
// Legacy shim: an older release accepted image_source in place of image.
if ($request->has('image_source')) {
$request->offsetSet('image', $request->offsetGet('image_source'));
}
$asset = $request->handleImages($asset);
$model = $asset->model;
$problems_updating_encrypted_custom_fields = false;
if ($model && isset($model->fieldset)) {
foreach ($model->fieldset->fields as $field) {
if (! $request->has($field->db_column)) {
continue;
}
$field_val = $request->input($field->db_column, null);
if ($field->element === 'checkbox' && is_array($field_val)) {
$field_val = implode(',', $field_val);
}
if ($field->field_encrypted == '1') {
if (Gate::allows('assets.view.encrypted_custom_fields')) {
$field_val = Crypt::encrypt($field_val);
} else {
// Skip encrypted-field updates for callers without the
// permission, but flag it so the response can nudge them.
$problems_updating_encrypted_custom_fields = true;
continue;
}
}
$asset->{$field->db_column} = $field_val;
}
}
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
if ($requestedCheckout && ! $target) {
return [
'success' => false,
'asset' => null,
'message' => null,
'errors' => null,
'encrypted_field_warning' => false,
'error_response_key' => 'admin/hardware/message.does_not_exist',
];
}
if ($requestedCheckout && ! $asset->canCheckoutTo($target)) {
return [
'success' => false,
'asset' => null,
'message' => null,
'errors' => null,
'encrypted_field_warning' => false,
'error_response_key' => 'general.error_user_company',
];
}
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if ($requestedCheckout) {
// Preserve the asset name if the name wasn't in the payload.
$asset_name = $request->has('name') ? $request->input('name') : $asset->name;
$location = null;
if ($request->filled('assigned_user')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_asset')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_location')) {
$location = $target->id;
}
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
return false;
}
if ($request->filled('assigned_asset')) {
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
}
}
return true;
});
if (! $updated) {
return [
'success' => false,
'asset' => null,
'message' => null,
'errors' => $asset->getErrors(),
'encrypted_field_warning' => false,
'error_response_key' => null,
];
}
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
return [
'success' => true,
'asset' => $asset,
'message' => $problems_updating_encrypted_custom_fields
? 'admin/hardware/message.update.encrypted_warning'
: 'admin/hardware/message.update.success',
'errors' => null,
'encrypted_field_warning' => $problems_updating_encrypted_custom_fields,
'error_response_key' => null,
];
}
/**
* Resolve the assignee target from the request payload. Used by
* applyAssetUpdate() as well as store() and checkout().
*/
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
{
if ($request->filled('assigned_user')) {
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
}
if ($request->filled('assigned_asset')) {
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
}
if ($request->filled('assigned_location')) {
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
}
return null;
}
/**
* Company-mismatch check used by store() and checkout(). applyAssetUpdate()
* inlines the equivalent check because it needs to return the neutral
* result shape (not a JsonResponse) so the bulk envelope can wrap it.
*/
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if (! $asset->canCheckoutTo($target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
return null;
}
/**
* Delete a given asset (mark as deleted).
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param int $assetId
*
* @since [v4.0]
*/
public function destroy($id): JsonResponse
{
$this->authorize('delete', Asset::class);
if ($asset = Asset::find($id)) {
$this->authorize('delete', $asset);
if ($asset->assignedTo) {
$target = $asset->assignedTo;
$checkin_at = date('Y-m-d H:i:s');
$originalValues = $asset->getRawOriginal();
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), 'Checkin on delete', $checkin_at, $originalValues));
DB::table('assets')
->where('id', $asset->id)
->update(['assigned_to' => null]);
}
$asset->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.delete.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
}
/**
* Restore a soft-deleted asset.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param int $assetId
*
* @since [v5.1.18]
*/
public function restore(Request $request, $assetId = null): JsonResponse
{
if ($asset = Asset::withTrashed()->find($assetId)) {
$this->authorize('delete', $asset);
if ($asset->deleted_at == '') {
return response()->json(Helper::formatStandardApiResponse('error', trans('general.not_deleted', ['item_type' => trans('general.asset')])), 200);
}
if ($asset->restore()) {
return response()->json(Helper::formatStandardApiResponse('success', trans('admin/hardware/message.restore.success')), 200);
}
// Check validation to make sure we're not restoring an asset with the same asset tag (or unique attribute) as an existing asset
return response()->json(Helper::formatStandardApiResponse('error', trans('general.could_not_restore', ['item_type' => trans('general.asset'), 'error' => $asset->getErrors()->first()])), 200);
}
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
}
/**
* Checkout an asset by its tag.
*
* @author [N. Butler]
*
* @param string $tag
*
* @since [v6.0.5]
*/
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
{
// Use the same hardened checkout path as ID-based checkout.
if ($asset = Asset::where('asset_tag', $tag)->first()) {
return $this->checkout($request, $asset->id);
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'Asset not found'), 200);
}
/**
* Checkout an asset
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param int $assetId
*
* @since [v4.0]
*/
public function checkout(AssetCheckoutRequest $request, $asset_id): JsonResponse
{
$this->authorize('checkout', Asset::class);
$asset = Asset::findOrFail($asset_id);
if (! $asset->availableForCheckout()) {
return response()->json(Helper::formatStandardApiResponse('error', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.not_available')));
}
$this->authorize('checkout', $asset);
$error_payload = [];
$error_payload['asset'] = [
'id' => $asset->id,
'asset_tag' => $asset->asset_tag,
];
// This item is checked out to a location
if (request('checkout_to_type') == 'location') {
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
$asset->location_id = ($target) ? $target->id : '';
$error_payload['target_id'] = $request->input('assigned_location');
$error_payload['target_type'] = 'location';
} elseif (request('checkout_to_type') == 'asset') {
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Override with the asset's location_id if it has one
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_asset');
$error_payload['target_type'] = 'asset';
} elseif (request('checkout_to_type') == 'user') {
// Fetch the target and set the asset's new location_id
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_user');
$error_payload['target_type'] = 'user';
}
if ($request->filled('status_id')) {
$asset->status_id = $request->input('status_id');
}
// Preserve existing requestable state unless API caller explicitly includes the field.
if ($request->has('requestable')) {
$asset->requestable = $request->boolean('requestable');
}
if (! isset($target)) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
if ($mismatch = $this->checkoutCompanyMismatchResponse($asset, $target)) {
return $mismatch;
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
$expected_checkin = request('expected_checkin', null);
$note = request('note', null);
// Using `->has` preserves the asset name if the name parameter was not included in request.
$asset_name = request()->has('name') ? request('name') : $asset->name;
// Set the location ID to the RTD location id if there is one
// Wait, why are we doing this? This overrides the stuff we set further up, which makes no sense.
// TODO: Follow up here. WTF. Commented out for now.
// if ((isset($target->rtd_location_id)) && ($asset->rtd_location_id!='')) {
// $asset->location_id = $target->rtd_location_id;
// }
// Keep checkout mutation + checkout logging/event side effects atomic.
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
});
if ($wasCheckedOut) {
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.error')));
}
/**
* Checkin an asset
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param int $assetId
*
* @since [v4.0]
*/
public function checkin(Request $request, $asset_id): JsonResponse
{
$asset = Asset::with('model')->findOrFail($asset_id);
$this->authorize('checkin', $asset);
$target = $asset->assignedTo;
if (is_null($target)) {
return response()->json(Helper::formatStandardApiResponse('error', [
'asset_tag' => e($asset->asset_tag),
'model' => e($asset->model->name),
'model_number' => e($asset->model->model_number),
], trans('admin/hardware/message.checkin.already_checked_in')));
}
$asset->expected_checkin = null;
// $asset->last_checkout = null;
$asset->last_checkin = now();
$asset->assignedTo()->disassociate($asset);
$asset->accepted = null;
if ($request->input('clear_name') == '1') {
$asset->name = null;
} elseif ($request->has('name')) {
$asset->name = $request->input('name');
}
$this->migrateLegacyLocations($asset);
$asset->location_id = $asset->rtd_location_id;
if ($request->filled('location_id')) {
$asset->location_id = $request->input('location_id');
if ($request->input('update_default_location')) {
$asset->rtd_location_id = $request->input('location_id');
}
}
if ($request->filled('status_id')) {
$asset->status_id = $request->input('status_id');
}
$checkin_at = $request->filled('checkin_at') ? $request->input('checkin_at').' '.date('H:i:s') : date('Y-m-d H:i:s');
$originalValues = $asset->getRawOriginal();
if (($request->filled('checkin_at')) && ($request->input('checkin_at') != date('Y-m-d'))) {
$originalValues['action_date'] = $checkin_at;
}
$asset->licenseseats->each(function (LicenseSeat $seat) {
$seat->update(['assigned_to' => null]);
});
// Get all pending Acceptances for this asset and delete them
CheckoutAcceptance::pending()
->whereHasMorph(
'checkoutable',
[Asset::class],
function (Builder $query) use ($asset) {
$query->where('id', $asset->id);
}
)
->get()
->map(function ($acceptance) {
$acceptance->delete();
});
if ($asset->save()) {
// Update the location of any child assets
Asset::where('assigned_type', Asset::class)
->where('assigned_to', $asset->id)
->update(['location_id' => $asset->location_id]);
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
return response()->json(Helper::formatStandardApiResponse('success', [
'asset_tag' => e($asset->asset_tag),
'model' => e($asset->model->name),
'model_number' => e($asset->model->model_number),
], trans('admin/hardware/message.checkin.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkin.error')));
}
/**
* Checkin an asset by asset tag
*
* @author [A. Janes] [<ajanes@adagiohealth.org>]
*
* @since [v6.0]
*/
public function checkinByTag(Request $request, $tag = null): JsonResponse
{
$this->authorize('checkin', Asset::class);
if ($tag == null && null !== ($request->input('asset_tag'))) {
$tag = $request->input('asset_tag');
}
$asset = Asset::where('asset_tag', $tag)->first();
if ($asset) {
return $this->checkin($request, $asset->id);
}
return response()->json(Helper::formatStandardApiResponse('error', [
'asset' => e($tag),
], 'Asset with tag '.e($tag).' not found'));
}
/**
* Mark an asset as audited
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @param int $id
*
* @since [v4.0]
*/
public function audit(UploadFileRequest $request, ?Asset $asset = null): JsonResponse
{
$this->authorize('audit', Asset::class);
// On the legacy route the URL has no {asset} — resolve from the body
// via audit_key + audit_by_field (or the fallback asset_tag field).
// On the newer route the Asset is bound by RMB, so body-based lookup
// is skipped entirely.
$resolvedAsset = $asset ?: $this->resolveAuditAssetFromBody($request);
if (! $resolvedAsset) {
return response()->json(Helper::formatStandardApiResponse(
'error',
$this->auditFailPayload($request),
trans('admin/hardware/message.does_not_exist')
), 200);
}
$result = $this->applyAssetAudit($resolvedAsset, $request);
return response()->json($this->formatSingleAuditResponse($result));
}
/**
* Bulk audit: `ids` in the request body names which assets to audit,
* everything else on the request body is the shared audit payload
* (note, next_audit_date, location, uploaded image, etc.) applied to
* each. Per-row envelope response.
*/
public function bulkAudit(UploadFileRequest $request): JsonResponse
{
$this->authorize('audit', Asset::class);
$ids = array_values(array_unique(array_map('intval', (array) $request->input('ids', []))));
if ($ids === []) {
return response()->json([
'status' => 'error',
'messages' => ['ids' => [trans('validation.required', ['attribute' => 'ids'])]],
'results' => [],
]);
}
$assets = Asset::whereIn('id', $ids)->get()->keyBy('id');
$results = [];
$success_count = 0;
$error_count = 0;
foreach ($ids as $id) {
if (! $assets->has($id)) {
$results[] = [
'id' => $id,
'status' => 'error',
'messages' => trans('admin/hardware/message.does_not_exist'),
'payload' => null,
];
$error_count++;
continue;
}
$asset = $assets->get($id);
// Per-row FMCS/authorization gate. The class-level authorize()
// above is only a coarse "you have assets.audit" check; this
// catches FMCS mismatches and any policy tightening that lands
// later, surfacing them as row errors rather than a whole 403.
if (! Gate::allows('audit', $asset)) {
$results[] = [
'id' => $id,
'status' => 'error',
'messages' => trans('general.unauthorized'),
'payload' => null,
];
$error_count++;
continue;
}
$result = $this->applyAssetAudit($asset, $request);
$row = $this->buildAuditRowResult($id, $result);
$results[] = $row;
$row['status'] === 'success' ? $success_count++ : $error_count++;
}
return response()->json($this->assembleBulkResponse($results, $success_count, $error_count));
}
/**
* Apply an audit to a single Asset.
*
* Shared between audit() and bulkAudit(), so validation, the observer
* bypass, and the logAudit side-effect live in one place. Returns the
* neutral result shape that formatSingleAuditResponse() /
* buildAuditRowResult() turn into the legacy or bulk-envelope response
* body.
*
* @return array{
* success: bool,
* asset: Asset|null,
* payload: array,
* message: string|null,
* errors: mixed,
* }
*/
private function applyAssetAudit(Asset $asset, UploadFileRequest $request): array
{
$settings = Setting::getSettings();
$dt = null;
if (! is_null($settings->audit_interval)) {
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
}
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
$audit_key = $request->input('audit_key', null);
$originalValues = $asset->getRawOriginal();
$asset->next_audit_date = $dt;
if ($request->filled('next_audit_date')) {
$asset->next_audit_date = $request->input('next_audit_date');
}
// "update_location" flag actually writes the location instead of
// only recording it in the audit notes.
if ($request->input('update_location') == '1') {
$asset->location_id = $request->input('location_id');
}
$asset->last_audit_date = date('Y-m-d H:i:s');
if ($request->input('clear_name') == '1') {
$asset->name = null;
}
$payload = [
'id' => $asset->id,
'asset_tag' => e($asset->asset_tag),
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
'note' => $request->filled('note') ? e($request->input('note')) : null,
'status_label' => e($asset->status?->display_name),
'status_type' => $asset->status?->getStatuslabelType(),
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
];
if ($asset->model && $asset->model->fieldset) {
$payload['custom_fields'] = [];
foreach ($asset->model->fieldset->fields as $field) {
if ($field->display_audit != '1' || ! $request->has($field->db_column)) {
continue;
}
$value = $request->input($field->db_column);
$stored = is_array($value) ? implode(', ', $value) : $value;
if ($field->field_encrypted == '1') {
// Only writers with the encrypted-view permission can
// set encrypted fields; other callers get the payload
// echo but no persisted change.
if (Gate::allows('assets.view.encrypted_custom_fields')) {
$asset->{$field->db_column} = Crypt::encrypt($stored);
}
} else {
$asset->{$field->db_column} = $stored;
}
$payload['custom_fields'][$field->db_column] = $value;
}
}
// Merge the model-level base rules with the custom-field rules so
// uploaded audit data is checked before we bypass the observer below.
$asset->setRules($asset->getRules() + $asset->customFieldValidationRules());
if ($asset->isInvalid()) {
return [
'success' => false,
'asset' => $asset,
'payload' => $payload,
'message' => null,
'errors' => $asset->getErrors(),
];
}
// We save() further down but don't want an "update" log entry (the
// observer would emit one because de-normed fields like
// next_audit_date change here) in addition to the "audit" log entry
// we create below. unsetEventDispatcher() bypasses the observer;
// isValid() above already ran the Watson validation manually.
//
// @see \App\Observers\AssetObserver::updating()
// @see \App\Models\Asset::save()
$asset->unsetEventDispatcher();
if ($asset->isValid() && $asset->save()) {
// Persist an audit image if the caller uploaded one, matching
// the web audit form's behavior. Filename is stored on the
// action log so it renders in the audit history.
$file_name = null;
if ($request->hasFile('image')) {
$file_name = $request->handleFile('private_uploads/audits/', 'audit-'.$asset->id, $request->file('image'));
$payload['image'] = $file_name;
}
$asset->logAudit($request->input('note'), $request->input('location_id'), $file_name, $originalValues);
return [
'success' => true,
'asset' => $asset,
'payload' => $payload,
'message' => 'admin/hardware/message.audit.success',
'errors' => null,
];
}
return [
'success' => false,
'asset' => $asset,
'payload' => $payload,
'message' => null,
'errors' => $asset->getErrors(),
];
}
/**
* Body-based asset lookup for the legacy audit path. Mirrors the pre-
* refactor behavior: prefer audit_key + audit_by_field (serial or
* asset_tag), fall back to the legacy `asset_tag` field, otherwise
* return null so the caller can try the URL-bound id.
*/
private function resolveAuditAssetFromBody(Request $request): ?Asset
{
$settings = Setting::getSettings();
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
$audit_key = $request->input('audit_key', null);
if ($settings->unique_serial == '1' && $audit_by_field === 'serial' && $audit_key) {
return Asset::where('serial', '=', trim($audit_key))->first();
}
if ($audit_by_field === 'asset_tag' && $audit_key) {
return Asset::where('asset_tag', '=', trim($audit_key))->first();
}
if ($request->filled('asset_tag')) {
return Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
}
return null;
}
/**
* The "no matching asset" fail payload the legacy audit response emits.
*/
private function auditFailPayload(Request $request): array
{
return [
'audit_by_field' => e(Str::headline($request->input('audit_by_field', 'asset_tag'))),
'audit_key' => e($request->input('audit_key', null)),
];
}
/**
* Format an applyAssetAudit() result into the legacy single-asset audit
* response body. `payload` here is the audit-specific structure
* (asset_tag, audit_by_field, custom_fields, ...) built inside
* applyAssetAudit(), NOT the full Asset model.
*/
private function formatSingleAuditResponse(array $result): array
{
if (! $result['success']) {
return Helper::formatStandardApiResponse('error', $result['payload'], $result['errors']);
}
return Helper::formatStandardApiResponse('success', $result['payload'], trans($result['message']));
}
/**
* Turn an applyAssetAudit() result into a per-row bulk-envelope entry.
*/
private function buildAuditRowResult(int $id, array $result): array
{
$formatted = $this->formatSingleAuditResponse($result);
return [
'id' => $id,
'status' => $formatted['status'],
'messages' => $formatted['messages'],
'payload' => $formatted['payload'],
];
}
/**
* Returns JSON listing of all requestable assets
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v4.0]
*/
public function requestable(Request $request): JsonResponse|array
{
$this->authorize('viewRequestable', Asset::class);
$allowed_columns = [
'name',
'asset_tag',
'serial',
'model_number',
'image',
'purchase_cost',
'expected_checkin',
];
$all_custom_fields = CustomField::all(); // used as a 'cache' of custom fields throughout this page load
foreach ($all_custom_fields as $field) {
$allowed_columns[] = $field->db_column_name();
}
$assets = Asset::select('assets.*')
->with(
'location',
'status',
'assetlog',
'company',
'assignedTo',
'model.category',
'model.manufacturer',
'model.fieldset',
'supplier',
'requests'
);
if ($request->filled('search')) {
$assets->TextSearch($request->input('search'));
}
// Search custom fields by column name
foreach ($all_custom_fields as $field) {
if ($request->filled($field->db_column_name())) {
$assets->where($field->db_column_name(), '=', $request->input($field->db_column_name()));
}
}
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
$sort_override = str_replace('custom_fields.', '', $request->input('sort'));
// This handles all the pivot sorting (versus the assets.* fields
// in the allowed_columns array)
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'assets.created_at';
switch ($request->input('sort')) {
case 'model':
$assets->OrderModels($order);
break;
case 'model_number':
$assets->OrderModelNumber($order);
break;
case 'location':
$assets->OrderLocation($order);
break;
default:
$assets->orderBy($column_sort, $order);
break;
}
$assets->requestableAssets();
// Make sure the offset and limit are actually integers and do not exceed system limits
$offset = ($request->input('offset') > $assets->count()) ? $assets->count() : app('api_offset_value');
$limit = app('api_limit_value');
$total = $assets->count();
$assets = $assets->skip($offset)->take($limit)->get();
return (new AssetsTransformer)->transformRequestedAssets($assets, $total);
}
public function assignedAssets(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('view', Asset::class);
$this->authorize('view', $asset);
$query = Asset::where([
'assigned_to' => $asset->id,
'assigned_type' => Asset::class,
]);
$total = $query->count();
$assets = $query->applyOffsetAndLimit($total)->get();
return (new AssetsTransformer)->transformAssets($assets, $total);
}
public function assignedAccessories(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('view', $asset);
$accessory_checkouts = AccessoryCheckout::AssetsAssigned()
->where('assigned_to', $asset->id)
->with('adminuser')
->with('accessories');
$offset = ($request->input('offset') > $accessory_checkouts->count()) ? $accessory_checkouts->count() : app('api_offset_value');
$limit = app('api_limit_value');
$total = $accessory_checkouts->count();
$accessory_checkouts = $accessory_checkouts->skip($offset)->take($limit)->get();
return (new AssetsTransformer)->transformCheckedoutAccessories($accessory_checkouts, $total);
}
public function assignedComponents(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('view', $asset);
$asset->loadCount('components');
$allowed_columns = [
'created_at',
'assigned_qty',
'note',
];
$component_checkouts = ComponentAssignment::where('asset_id', $asset->id)->with('adminuser')->with('component');
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
switch ($sort_override) {
case 'created_by':
$component_checkouts = $component_checkouts->OrderByCreatedByName($order);
break;
case 'name':
$component_checkouts = $component_checkouts->OrderByComponentName($order);
break;
default:
$component_checkouts = $component_checkouts->orderBy($column_sort, $order);
break;
}
$offset = ($request->input('offset') > $component_checkouts->count()) ? $component_checkouts->count() : app('api_offset_value');
$total = $component_checkouts->count();
$limit = app('api_limit_value');
$component_checkouts = $component_checkouts->skip($offset)->take($limit)->get();
return (new AssetsTransformer)->transformCheckedoutComponents($component_checkouts, $total);
}
/**
* Generate asset labels by tag
*
* @author [Nebelkreis] [https://github.com/NebelKreis]
*
* @param Request $request Contains asset_tags array of asset tags to generate labels for
* @return JsonResponse Returns base64 encoded PDF on success, error message on failure
*/
public function getLabels(Request $request): JsonResponse
{
try {
$this->authorize('view', Asset::class);
// Validate that asset tags were provided in the request
if (! $request->filled('asset_tags')) {
return response()->json(Helper::formatStandardApiResponse('error', null,
trans('admin/hardware/message.no_assets_selected')), 400);
}
// Convert asset tags from request into collection and fetch matching assets
$asset_tags = collect($request->input('asset_tags'));
$assets = Asset::whereIn('asset_tag', $asset_tags)->get();
// Return error if no assets were found for the provided tags
if ($assets->isEmpty()) {
return response()->json(Helper::formatStandardApiResponse('error', null,
trans('admin/hardware/message.does_not_exist')), 404);
}
try {
$settings = Setting::getSettings();
// Check if logo file exists in storage and disable logo if not found
// This prevents errors when trying to include a non-existent logo in the PDF
$settings->label_logo = ($original_logo = $settings->label_logo) && ! Storage::disk('public')->exists('/'.$original_logo) ? null : $settings->label_logo;
$label = new Label;
if (! $label) {
throw new \Exception(trans('admin/labels/message.label_not_created'));
}
// Configure label with assets and settings
// bulkedit=false and count=0 are default values for label generation
$label = $label->with('assets', $assets)
->with('settings', $settings)
->with('bulkedit', false)
->with('count', 0);
// Generate PDF using callback function
// The callback captures the PDF content in $pdf_content variable
$pdf_content = '';
$label->render(function ($pdf) use (&$pdf_content) {
$pdf_content = $pdf->Output('', 'S');
return $pdf;
});
// Verify PDF was generated successfully
if (empty($pdf_content)) {
throw new \Exception(trans('admin/labels/message.use_new_label_engine_for_api'));
}
$encoded_content = base64_encode($pdf_content);
return response()->json(Helper::formatStandardApiResponse('success', [
'pdf' => $encoded_content,
], trans('admin/hardware/message.labels_generated')));
} catch (\Exception $e) {
return response()->json(Helper::formatStandardApiResponse('error', [
'error_message' => $e->getMessage(),
'error_line' => $e->getLine(),
'error_file' => $e->getFile(),
], trans('admin/hardware/message.error_generating_labels')), 500);
}
} catch (\Exception $e) {
return response()->json(Helper::formatStandardApiResponse('error', [
'error_message' => $e->getMessage(),
'error_line' => $e->getLine(),
'error_file' => $e->getFile(),
], $e->getMessage()), 500);
}
}
public function history(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('history', $asset);
$historyQuery = $asset->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);
}
}