mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 11:15:42 +00:00
1143 lines
47 KiB
PHP
1143 lines
47 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Assets;
|
|
|
|
use App\Events\CheckoutableCheckedIn;
|
|
use App\Events\CheckoutablesCheckedOutInBulk;
|
|
use App\Helpers\Helper;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\AssetCheckoutRequest;
|
|
use App\Http\Requests\UploadFileRequest;
|
|
use App\Http\Traits\CheckInOutTrait;
|
|
use App\Http\Traits\MigratesLegacyAssetLocations;
|
|
use App\Models\Asset;
|
|
use App\Models\AssetModel;
|
|
use App\Models\CheckoutAcceptance;
|
|
use App\Models\Company;
|
|
use App\Models\CustomField;
|
|
use App\Models\LicenseSeat;
|
|
use App\Models\Location;
|
|
use App\Models\Setting;
|
|
use App\Models\Statuslabel;
|
|
use App\Models\User;
|
|
use App\View\Label;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Context;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class BulkAssetsController extends Controller
|
|
{
|
|
use CheckInOutTrait;
|
|
use MigratesLegacyAssetLocations;
|
|
|
|
/**
|
|
* Display the bulk edit page.
|
|
*
|
|
* This method is super weird because it's kinda of like a controller within a controller.
|
|
* It's main function is to determine what the bulk action in, and then return a view with
|
|
* the information that view needs, be it bulk delete, bulk edit, restore, etc.
|
|
*
|
|
* This is something that made sense at the time, but sort of doesn't make sense now. A JS front-end to determine form
|
|
* action would make a lot more sense here and make things a lot more clear.
|
|
*
|
|
* @author [A. Gianotto] [<snipe@snipe.net>]
|
|
*
|
|
* @internal param int $assetId
|
|
*
|
|
* @since [v2.0]
|
|
*/
|
|
public function edit(Request $request): View|RedirectResponse
|
|
{
|
|
$this->authorize('view', Asset::class);
|
|
|
|
/**
|
|
* No asset IDs were passed
|
|
*/
|
|
if (! $request->filled('ids')) {
|
|
return redirect()->back()->with('error', trans('admin/hardware/message.update.no_assets_selected'));
|
|
}
|
|
|
|
$asset_ids = $request->input('ids');
|
|
|
|
if ($request->input('bulk_actions') === 'checkout') {
|
|
$status_check = $this->hasUndeployableStatus($asset_ids);
|
|
if ($status_check && $status_check['status'] === true) {
|
|
|
|
$asset_tags = implode(', ', array_column($status_check['tags'], 'asset_tag'));
|
|
$asset_ids = $status_check['asset_ids'];
|
|
|
|
session()->flash('warning', trans('admin/hardware/message.undeployable', ['asset_tags' => $asset_tags]));
|
|
}
|
|
|
|
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
|
|
|
return redirect()->route('hardware.bulkcheckout.show');
|
|
}
|
|
|
|
if ($request->input('bulk_actions') === 'checkin') {
|
|
if ($referer = Helper::sameOriginUrl($request->headers->get('referer'))) {
|
|
redirect()->setIntendedUrl($referer);
|
|
}
|
|
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
|
|
|
return redirect()->route('hardware.bulkcheckin.show');
|
|
}
|
|
|
|
if ($request->input('bulk_actions') === 'audit') {
|
|
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
|
|
|
return redirect()->route('hardware.bulk-audit.show');
|
|
}
|
|
|
|
if ($request->input('bulk_actions') === 'maintenance') {
|
|
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
|
|
|
return redirect()->route('maintenances.create');
|
|
}
|
|
|
|
// Stash where to redirect after update/destroy. Referer is user-
|
|
// controllable, so run it through the same-origin gate so a
|
|
// hostile referrer can't turn the later redirect($bulk_back_url)
|
|
// into an open-redirect. If the referrer fails validation the
|
|
// reader falls back to route('hardware.index') via the same
|
|
// helper coalescing pattern.
|
|
if ($safeReferer = Helper::sameOriginUrl(request()->headers->get('referer'))) {
|
|
session(['bulk_back_url' => $safeReferer]);
|
|
}
|
|
|
|
$allowed_columns = [
|
|
'id',
|
|
'name',
|
|
'asset_tag',
|
|
'serial',
|
|
'model_number',
|
|
'last_checkout',
|
|
'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',
|
|
];
|
|
|
|
/**
|
|
* Make sure the column is allowed, and if it's a custom field, make sure we strip the custom_fields. prefix
|
|
*/
|
|
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
|
$sort_override = str_replace('custom_fields.', '', $request->input('sort'));
|
|
|
|
// This handles all of the pivot sorting below (versus the assets.* fields in the allowed_columns array)
|
|
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'assets.id';
|
|
|
|
$query = Asset::with('assignedTo', 'location', 'model')
|
|
->whereIn('assets.id', $asset_ids)
|
|
->withTrashed();
|
|
|
|
switch ($sort_override) {
|
|
case 'model':
|
|
$query->OrderModels($order);
|
|
break;
|
|
case 'model_number':
|
|
$query->OrderModelNumber($order);
|
|
break;
|
|
case 'category':
|
|
$query->OrderCategory($order);
|
|
break;
|
|
case 'manufacturer':
|
|
$query->OrderManufacturer($order);
|
|
break;
|
|
case 'company':
|
|
$query->OrderCompany($order);
|
|
break;
|
|
case 'location':
|
|
$query->OrderLocation($order);
|
|
break;
|
|
case 'rtd_location':
|
|
$query->OrderRtdLocation($order);
|
|
break;
|
|
case 'status_label':
|
|
$query->OrderStatus($order);
|
|
break;
|
|
case 'supplier':
|
|
$query->OrderSupplier($order);
|
|
break;
|
|
case 'assigned_to':
|
|
$query->OrderAssigned($order);
|
|
break;
|
|
default:
|
|
$query->orderBy($column_sort, $order);
|
|
break;
|
|
}
|
|
$assets = $query->get();
|
|
|
|
if ($assets->isEmpty()) {
|
|
Log::debug('No assets were found for the provided IDs', ['ids' => $asset_ids]);
|
|
|
|
return redirect()->back()->with('error', trans('admin/hardware/message.update.assets_do_not_exist_or_are_invalid'));
|
|
}
|
|
|
|
$models = $assets->unique('model_id');
|
|
$modelNames = [];
|
|
|
|
foreach ($models as $model) {
|
|
$modelNames[] = $model->model?->name;
|
|
}
|
|
|
|
if ($request->filled('bulk_actions')) {
|
|
|
|
switch ($request->input('bulk_actions')) {
|
|
case 'labels':
|
|
$this->authorize('view', Asset::class);
|
|
|
|
return (new Label)
|
|
->with('assets', $assets)
|
|
->with('settings', Setting::getSettings())
|
|
->with('bulkedit', true)
|
|
->with('count', 0);
|
|
|
|
case 'delete':
|
|
$this->authorize('delete', Asset::class);
|
|
$assets->each(function ($assets) {
|
|
$this->authorize('delete', $assets);
|
|
});
|
|
|
|
return view('hardware/bulk-delete')->with('assets', $assets);
|
|
|
|
case 'restore':
|
|
$this->authorize('update', Asset::class);
|
|
$assets = Asset::withTrashed()->find($asset_ids);
|
|
$assets->each(function ($asset) {
|
|
$this->authorize('delete', $asset);
|
|
});
|
|
|
|
return view('hardware/bulk-restore')->with('assets', $assets);
|
|
|
|
case 'edit':
|
|
$this->authorize('update', Asset::class);
|
|
|
|
return view('hardware/bulk')
|
|
->with('assets', $asset_ids)
|
|
->with('statuslabel_list', Helper::statusLabelList())
|
|
->with('models', $models->pluck(['model']))
|
|
->with('modelNames', $modelNames);
|
|
}
|
|
}
|
|
|
|
return redirect()->back()->with('error', 'No action selected');
|
|
}
|
|
|
|
/**
|
|
* Save bulk edits
|
|
*
|
|
* @author [A. Gianotto] [<snipe@snipe.net>]
|
|
*
|
|
* @internal param array $assets
|
|
*
|
|
* @since [v2.0]
|
|
*/
|
|
public function update(Request $request): RedirectResponse
|
|
{
|
|
$this->authorize('update', Asset::class);
|
|
$has_errors = 0;
|
|
$error_array = [];
|
|
|
|
// Pull the stashed-and-vetted back URL from edit(). If the
|
|
// session slot is empty (direct POST, session lost, etc.) fall
|
|
// back to the plain index rather than url()->previous(), which
|
|
// is Referer-derived and would need its own sanitize step.
|
|
$bulk_back_url = Helper::sameOriginUrl($request->session()->pull('bulk_back_url')) ?? route('hardware.index');
|
|
|
|
$custom_field_columns = CustomField::all()->pluck('db_column')->toArray();
|
|
|
|
// find custom field input attributes that start with 'null_'
|
|
$null_custom_fields_inputs = array_filter($request->all(), function ($key) {
|
|
// filter out all keys that start with 'null_'
|
|
return strpos($key, 'null_') === 0;
|
|
}, ARRAY_FILTER_USE_KEY);
|
|
// remove 'null' from the keys
|
|
$custom_fields_to_null = [];
|
|
foreach ($null_custom_fields_inputs as $key => $value) {
|
|
$custom_fields_to_null[str_replace('null', '', $key)] = $value;
|
|
}
|
|
|
|
if (! $request->filled('ids') || count($request->input('ids')) == 0) {
|
|
return redirect($bulk_back_url)->with('error', trans('admin/hardware/message.update.no_assets_selected'));
|
|
}
|
|
|
|
$assets = Asset::whereIn('id', $request->input('ids'))->get();
|
|
|
|
/**
|
|
* If ANY of these are filled, prepare to update the values on the assets.
|
|
*
|
|
* Additional checks will be needed for some of them to make sure the values
|
|
* make sense (for example, changing the status ID to something incompatible with
|
|
* its checkout status.
|
|
*/
|
|
if (($request->filled('name'))
|
|
|| ($request->filled('purchase_date'))
|
|
|| ($request->filled('expected_checkin'))
|
|
|| ($request->filled('purchase_cost'))
|
|
|| ($request->filled('supplier_id'))
|
|
|| ($request->filled('order_number'))
|
|
|| ($request->filled('warranty_months'))
|
|
|| ($request->filled('rtd_location_id'))
|
|
|| ($request->filled('requestable'))
|
|
|| ($request->filled('company_id'))
|
|
|| ($request->filled('status_id'))
|
|
|| ($request->filled('model_id'))
|
|
|| ($request->filled('notes'))
|
|
|| ($request->filled('next_audit_date'))
|
|
|| ($request->filled('asset_eol_date'))
|
|
|| ($request->filled('null_name'))
|
|
|| ($request->filled('null_purchase_date'))
|
|
|| ($request->filled('null_expected_checkin_date'))
|
|
|| ($request->filled('null_next_audit_date'))
|
|
|| ($request->filled('null_asset_eol_date'))
|
|
|| ($request->filled('null_notes'))
|
|
|| ($request->anyFilled($custom_field_columns))
|
|
|| ($request->anyFilled(array_keys($null_custom_fields_inputs)))
|
|
|
|
) {
|
|
// Let's loop through those assets and build an update array
|
|
foreach ($assets as $asset) {
|
|
|
|
$this->update_array = [];
|
|
|
|
/**
|
|
* Leave out model_id and status here because we do math on that later. We have to do some
|
|
* extra validation and checks on those two.
|
|
*
|
|
* It's tempting to make these match the request check above, but some of these values require
|
|
* extra work to make sure the data makes sense.
|
|
*/
|
|
$this->conditionallyAddItem('name')
|
|
->conditionallyAddItem('purchase_date')
|
|
->conditionallyAddItem('expected_checkin')
|
|
->conditionallyAddItem('order_number')
|
|
->conditionallyAddItem('requestable')
|
|
->conditionallyAddItem('supplier_id')
|
|
->conditionallyAddItem('warranty_months')
|
|
->conditionallyAddItem('next_audit_date')
|
|
->conditionallyAddItem('asset_eol_date')
|
|
->conditionallyAddItem('notes');
|
|
foreach ($custom_field_columns as $key => $custom_field_column) {
|
|
$this->conditionallyAddItem($custom_field_column);
|
|
}
|
|
foreach ($custom_fields_to_null as $key => $custom_field_to_null) {
|
|
$this->conditionallyAddItem($key);
|
|
}
|
|
|
|
if (! ($asset->eol_explicit)) {
|
|
if ($request->filled('model_id')) {
|
|
$model = AssetModel::find($request->input('model_id'));
|
|
if ($model->eol > 0) {
|
|
if ($request->filled('purchase_date')) {
|
|
$this->update_array['asset_eol_date'] = Carbon::parse($request->input('purchase_date'))->addMonths($model->eol)->format('Y-m-d');
|
|
} else {
|
|
$this->update_array['asset_eol_date'] = Carbon::parse($asset->purchase_date)->addMonths($model->eol)->format('Y-m-d');
|
|
}
|
|
} else {
|
|
$this->update_array['asset_eol_date'] = null;
|
|
}
|
|
} elseif (($request->filled('purchase_date')) && ($asset->model->eol > 0)) {
|
|
$this->update_array['asset_eol_date'] = Carbon::parse($request->input('purchase_date'))->addMonths($asset->model->eol)->format('Y-m-d');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Blank out fields that were requested to be blanked out via checkbox
|
|
*/
|
|
if ($request->input('null_name') == '1') {
|
|
|
|
$this->update_array['name'] = null;
|
|
}
|
|
|
|
if ($request->input('null_purchase_date') == '1') {
|
|
$this->update_array['purchase_date'] = null;
|
|
if (! ($asset->eol_explicit)) {
|
|
$this->update_array['asset_eol_date'] = null;
|
|
}
|
|
}
|
|
|
|
if ($request->input('null_expected_checkin_date') == '1') {
|
|
$this->update_array['expected_checkin'] = null;
|
|
}
|
|
|
|
if ($request->input('null_next_audit_date') == '1') {
|
|
$this->update_array['next_audit_date'] = null;
|
|
}
|
|
|
|
if ($request->input('null_asset_eol_date') == '1') {
|
|
$this->update_array['asset_eol_date'] = null;
|
|
|
|
// If they are nulling the EOL date to allow it to calculate, set eol explicit to 0
|
|
if ($request->input('calc_eol') == '1') {
|
|
$this->update_array['eol_explicit'] = 0;
|
|
}
|
|
}
|
|
|
|
if ($request->input('null_notes') == '1') {
|
|
$this->update_array['notes'] = null;
|
|
}
|
|
|
|
if ($request->filled('purchase_cost')) {
|
|
$this->update_array['purchase_cost'] = $request->input('purchase_cost');
|
|
}
|
|
|
|
if ($request->filled('company_id')) {
|
|
$this->update_array['company_id'] = Company::getIdForCurrentUser($request->input('company_id'));
|
|
if ($request->input('company_id') == 'clear') {
|
|
$this->update_array['company_id'] = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* We're trying to change the model ID - we need to do some extra checks here to make sure
|
|
* the custom field values work for the custom fieldset rules around this asset. Uniqueness
|
|
* and requiredness across the fieldset is particularly important, since those are
|
|
* fieldset-specific attributes.
|
|
*/
|
|
if ($request->filled('model_id')) {
|
|
$this->update_array['model_id'] = AssetModel::find($request->input('model_id'))->id;
|
|
}
|
|
|
|
/**
|
|
* We're trying to change the status ID - we need to do some extra checks here to
|
|
* make sure the status label type is one that makes sense for the state of the asset,
|
|
* for example, we shouldn't be able to make an asset archived if it's currently assigned
|
|
* to someone/something.
|
|
*/
|
|
if ($request->filled('status_id')) {
|
|
try {
|
|
$updated_status = Statuslabel::findOrFail($request->input('status_id'));
|
|
} catch (ModelNotFoundException $e) {
|
|
return redirect($bulk_back_url)->with('error', trans('admin/statuslabels/message.does_not_exist'));
|
|
}
|
|
|
|
// We cannot assign a non-deployable status type if the asset is already assigned.
|
|
// This could probably be added to a form request.
|
|
// If the asset isn't assigned, we don't care what the status is.
|
|
// Otherwise we need to make sure the status type is still a deployable one.
|
|
|
|
$unassigned = $asset->assigned_to == '';
|
|
$deployable = $updated_status->deployable == '1' && $asset->status?->deployable == '1';
|
|
$pending = $updated_status->pending === 1;
|
|
|
|
if ($unassigned || $deployable || $pending) {
|
|
$this->update_array['status_id'] = $updated_status->id;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* We're changing the location ID - figure out which location we should apply
|
|
* this change to:
|
|
*
|
|
* 0 - RTD location only
|
|
* 1 - location ID and RTD location ID
|
|
* 2 - location ID only
|
|
*
|
|
* Note: this is kinda dumb and we should just use human-readable values IMHO. - snipe
|
|
*/
|
|
if ($request->filled('rtd_location_id')) {
|
|
|
|
if (($request->filled('update_real_loc')) && (($request->input('update_real_loc')) == '0')) {
|
|
$this->update_array['rtd_location_id'] = $request->input('rtd_location_id');
|
|
}
|
|
|
|
if (($request->filled('update_real_loc')) && (($request->input('update_real_loc')) == '1')) {
|
|
$this->update_array['location_id'] = $request->input('rtd_location_id');
|
|
$this->update_array['rtd_location_id'] = $request->input('rtd_location_id');
|
|
}
|
|
|
|
if (($request->filled('update_real_loc')) && (($request->input('update_real_loc')) == '2')) {
|
|
$this->update_array['location_id'] = $request->input('rtd_location_id');
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* ------------------------------------------------------------------------------
|
|
* ANYTHING that happens past this foreach
|
|
* WILL NOT BE logged in the edit log_meta data
|
|
* ------------------------------------------------------------------------------
|
|
*/
|
|
$changed = [];
|
|
|
|
foreach ($this->update_array as $key => $value) {
|
|
|
|
if ($this->update_array[$key] != $asset->{$key}) {
|
|
$changed[$key]['old'] = $asset->{$key};
|
|
$changed[$key]['new'] = $this->update_array[$key];
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Start all the custom fields shenanigans
|
|
*/
|
|
|
|
// Does the model have a fieldset?
|
|
if ($asset->model?->fieldset) {
|
|
foreach ($asset->model->fieldset->fields as $field) {
|
|
|
|
// null custom fields
|
|
if ($custom_fields_to_null) {
|
|
foreach ($custom_fields_to_null as $key => $custom_field_to_null) {
|
|
if ($field->db_column == $key) {
|
|
$this->update_array[$field->db_column] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ((array_key_exists($field->db_column, $this->update_array)) && ($field->field_encrypted == '1')) {
|
|
if (Gate::allows('admin')) {
|
|
$decrypted_old = Helper::gracefulDecrypt($field, $asset->{$field->db_column});
|
|
|
|
/*
|
|
* Check if the decrypted existing value is different from one we just submitted
|
|
* and if not, pull it out of the object since it shouldn't really be updating at all.
|
|
* If we don't do this, it will try to re-encrypt it, and the same value encrypted two
|
|
* different times will have different values, so it will *look* like it was updated
|
|
* but it wasn't.
|
|
*/
|
|
if ($decrypted_old != $this->update_array[$field->db_column]) {
|
|
$asset->{$field->db_column} = Crypt::encrypt($this->update_array[$field->db_column]);
|
|
} else {
|
|
/*
|
|
* Remove the encrypted custom field from the update_array, since nothing changed
|
|
*/
|
|
unset($this->update_array[$field->db_column]);
|
|
unset($asset->{$field->db_column});
|
|
}
|
|
|
|
/*
|
|
* These custom fields aren't encrypted, just carry on as usual
|
|
*/
|
|
}
|
|
} else {
|
|
|
|
if ((array_key_exists($field->db_column, $this->update_array)) && ($asset->{$field->db_column} != $this->update_array[$field->db_column])) {
|
|
|
|
// Check if this is an array, and if so, flatten it
|
|
if (is_array($this->update_array[$field->db_column])) {
|
|
$asset->{$field->db_column} = implode(', ', $this->update_array[$field->db_column]);
|
|
} else {
|
|
$asset->{$field->db_column} = $this->update_array[$field->db_column];
|
|
}
|
|
}
|
|
}
|
|
|
|
} // endforeach
|
|
}
|
|
|
|
// Check if it passes validation, and then try to save
|
|
if (! $asset->update($this->update_array)) {
|
|
|
|
// Build the error array
|
|
foreach ($asset->getErrors()->toArray() as $key => $message) {
|
|
for ($x = 0; $x < count($message); $x++) {
|
|
$error_array[$key][] = trans('general.asset').' '.$asset->id.': '.$message[$x];
|
|
$has_errors++;
|
|
}
|
|
}
|
|
|
|
} // end if saved
|
|
|
|
} // end asset foreach
|
|
|
|
if ($has_errors > 0) {
|
|
session()->put('bulkedit_ids', $request->input('ids'));
|
|
session()->put('bulk_asset_errors', $error_array);
|
|
|
|
return redirect()
|
|
->route('hardware.index')
|
|
->with('bulk_asset_errors', $error_array)
|
|
->withInput();
|
|
}
|
|
|
|
return redirect($bulk_back_url)->with('success', trans('admin/hardware/message.update.success'));
|
|
}
|
|
|
|
// no values given, nothing to update
|
|
return redirect($bulk_back_url)->with('warning', trans('admin/hardware/message.update.nothing_updated'));
|
|
}
|
|
|
|
/**
|
|
* Array to store update data per item
|
|
*
|
|
* @var array
|
|
*/
|
|
private $update_array;
|
|
|
|
/**
|
|
* Adds parameter to update array for an item if it exists in request
|
|
*
|
|
* @param string $field field name
|
|
*/
|
|
protected function conditionallyAddItem($field): BulkAssetsController
|
|
{
|
|
if (request()->filled($field)) {
|
|
$this->update_array[$field] = request()->input($field);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Save bulk deleted.
|
|
*
|
|
* @author [A. Gianotto] [<snipe@snipe.net>]
|
|
*
|
|
* @internal param array $assets
|
|
*
|
|
* @since [v2.0]
|
|
*/
|
|
public function destroy(Request $request): RedirectResponse
|
|
{
|
|
$this->authorize('delete', Asset::class);
|
|
|
|
// Same pattern as update(): pull the vetted URL from edit()'s
|
|
// stash, fall through to the plain index if it's missing or was
|
|
// somehow poisoned upstream of the sanitize step.
|
|
$bulk_back_url = Helper::sameOriginUrl($request->session()->pull('bulk_back_url')) ?? route('hardware.index');
|
|
$assetIds = $request->input('ids');
|
|
|
|
if (empty($assetIds)) {
|
|
return redirect($bulk_back_url)->with('error', trans('admin/hardware/message.delete.nothing_updated'));
|
|
}
|
|
|
|
$assignedAssets = Asset::whereIn('id', $assetIds)->whereNotNull('assigned_to')->get();
|
|
if ($assignedAssets->isNotEmpty()) {
|
|
|
|
// if assets are checked out, return a list of asset tags that would need to be checked in first.
|
|
$assetTags = $assignedAssets->pluck('asset_tag')->implode(', ');
|
|
|
|
return redirect($bulk_back_url)->with('error', trans_choice('admin/hardware/message.delete.assigned_to_error', $assignedAssets->count(), ['asset_tag' => $assetTags]));
|
|
}
|
|
|
|
foreach (Asset::wherein('id', $assetIds)->get() as $asset) {
|
|
$asset->delete();
|
|
}
|
|
|
|
return redirect($bulk_back_url)->with('success', trans('admin/hardware/message.delete.success'));
|
|
// no values given, nothing to update
|
|
|
|
}
|
|
|
|
/**
|
|
* Show Bulk Checkout Page
|
|
*/
|
|
public function showCheckout(): View
|
|
{
|
|
$this->authorize('checkout', Asset::class);
|
|
|
|
$alreadyAssigned = collect();
|
|
|
|
if (old('selected_assets') && is_array(old('selected_assets'))) {
|
|
$assets = Asset::findMany(old('selected_assets'));
|
|
|
|
[$assignable, $alreadyAssigned] = $assets->partition(function (Asset $asset) {
|
|
return ! $asset->assigned_to;
|
|
});
|
|
|
|
session()->flashInput(['selected_assets' => $assignable->pluck('id')->values()->toArray()]);
|
|
}
|
|
|
|
$do_not_change = ['' => trans('general.do_not_change')];
|
|
$status_label_list = $do_not_change + Helper::deployableStatusLabelList();
|
|
|
|
return view('hardware/bulk-checkout', [
|
|
'statusLabel_list' => $status_label_list,
|
|
'removed_assets' => $alreadyAssigned,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Process Multiple Checkout Request
|
|
*/
|
|
public function storeCheckout(AssetCheckoutRequest $request): RedirectResponse|ModelNotFoundException
|
|
{
|
|
Context::add('action', 'bulk_asset_checkout');
|
|
|
|
$this->authorize('checkout', Asset::class);
|
|
|
|
try {
|
|
$admin = auth()->user();
|
|
|
|
$target = $this->determineCheckoutTarget();
|
|
// Store the STRING kind ('user' / 'asset' / 'location') the
|
|
// operator picked, not the resolved model — the checkout-selector
|
|
// partial compares against string literals and an Eloquent
|
|
// instance in the slot silently fails every match. Unlike the
|
|
// per-item Asset / Accessory controllers, this method has no
|
|
// downstream session put that would overwrite a bad early value,
|
|
// so this is the sole source of truth.
|
|
session()->put(['checkout_to_type' => request('checkout_to_type')]);
|
|
|
|
if (! is_array($request->input('selected_assets'))) {
|
|
return redirect()->route('hardware.bulkcheckout.show')->withInput()->with('error', trans('admin/hardware/message.checkout.no_assets_selected'));
|
|
}
|
|
|
|
$asset_ids = array_filter($request->input('selected_assets'));
|
|
|
|
$assets = Asset::findOrFail($asset_ids);
|
|
|
|
// Prevent checking out assets that are already checked out
|
|
if ($assets->pluck('assigned_to')->unique()->filter()->isNotEmpty()) {
|
|
// re-add the asset ids so the assets select is re-populated
|
|
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
|
|
|
return redirect(route('hardware.bulkcheckout.show'))
|
|
->with('error', trans('general.error_assets_already_checked_out'));
|
|
}
|
|
|
|
// Prevent checking out assets across companies if FMCS enabled.
|
|
if (Setting::getSettings()->full_multiple_companies_support) {
|
|
$company_ids = $assets->pluck('company_id')->filter()->unique();
|
|
|
|
if ($company_ids->isNotEmpty()) {
|
|
if ($company_ids->count() > 1) {
|
|
// Selected assets span multiple companies; bulk checkout can't satisfy all of them.
|
|
$mismatch = true;
|
|
} else {
|
|
// All assets share the same company; let the model enforce the checkout rules.
|
|
$mismatch = ! $assets->first()->canCheckoutTo($target);
|
|
}
|
|
|
|
if ($mismatch) {
|
|
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
|
|
|
return redirect(route('hardware.bulkcheckout.show'))
|
|
->with('error', trans('general.error_user_company_multiple'));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (request('checkout_to_type') == 'asset') {
|
|
foreach ($asset_ids as $asset_id) {
|
|
if ($target->id == $asset_id) {
|
|
return redirect()->back()->with('error', 'You cannot check an asset out to itself.');
|
|
}
|
|
}
|
|
}
|
|
$checkout_at = date('Y-m-d H:i:s');
|
|
if (($request->filled('checkout_at')) && ($request->input('checkout_at') != date('Y-m-d'))) {
|
|
$checkout_at = $request->input('checkout_at');
|
|
}
|
|
|
|
$expected_checkin = '';
|
|
|
|
if ($request->filled('expected_checkin')) {
|
|
$expected_checkin = $request->input('expected_checkin');
|
|
}
|
|
|
|
$errors = [];
|
|
DB::transaction(function () use ($target, $admin, $checkout_at, $expected_checkin, &$errors, $assets, $request) { // NOTE: $errors is passsed by reference!
|
|
foreach ($assets as $asset) {
|
|
$this->authorize('checkout', $asset);
|
|
|
|
// See if there is a status label passed
|
|
if ($request->filled('status_id')) {
|
|
$asset->status_id = $request->input('status_id');
|
|
}
|
|
|
|
// The bulk-checkout form now carries a plain `requestable`
|
|
// checkbox (matching the per-item hardware/checkout form
|
|
// so the localStorage-backed remembered-default JS is
|
|
// shared between the two). Always set the flag from the
|
|
// request, so the operator's explicit choice sticks.
|
|
$asset->requestable = $request->boolean('requestable');
|
|
|
|
$checkout_success = $asset->checkOut($target, $admin, $checkout_at, $expected_checkin, e($request->input('note')), $asset->name, null);
|
|
|
|
// TODO - I think this logic is duplicated in the checkOut method?
|
|
if ($target->location_id != '') {
|
|
$asset->location_id = $target->location_id;
|
|
// TODO - I don't know why this is being saved without events
|
|
$asset::withoutEvents(function () use ($asset) {
|
|
$asset->save();
|
|
});
|
|
}
|
|
|
|
if (! $checkout_success) {
|
|
$errors = array_merge_recursive($errors, $asset->getErrors()->toArray());
|
|
}
|
|
}
|
|
});
|
|
|
|
if (! $errors) {
|
|
CheckoutablesCheckedOutInBulk::dispatch(
|
|
$assets,
|
|
$target,
|
|
$admin,
|
|
$checkout_at,
|
|
$expected_checkin,
|
|
e($request->get('note')),
|
|
);
|
|
|
|
// Honor the redirect_option select from the form. Choosing
|
|
// 'bulk_checkout' bounces the operator right back to the
|
|
// bulk-checkout screen so they can keep scanning without
|
|
// navigating away — the workflow that Quick Scan Checkin
|
|
// uses for the checkin side. Any other value (default is
|
|
// 'index') falls back to the asset listing.
|
|
$redirect = $request->input('redirect_option') === 'bulk_checkout'
|
|
? route('hardware.bulkcheckout.show')
|
|
: route('hardware.index');
|
|
|
|
return redirect()->to($redirect)->with('success', trans_choice('admin/hardware/message.multi-checkout.success', $asset_ids));
|
|
}
|
|
|
|
// Redirect to the asset management page with error
|
|
return redirect()->route('hardware.bulkcheckout.show')->withInput()->with('error', trans_choice('admin/hardware/message.multi-checkout.error', $asset_ids))->withErrors($errors);
|
|
} catch (ModelNotFoundException $e) {
|
|
return redirect()->route('hardware.bulkcheckout.show')->withInput()->with('error', trans_choice('admin/hardware/message.multi-checkout.error', $request->input('selected_assets')));
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Show Bulk Checkin Page
|
|
*/
|
|
public function showCheckin(): View
|
|
{
|
|
$this->authorize('checkin', Asset::class);
|
|
|
|
$notAssigned = collect();
|
|
|
|
if (old('selected_assets') && is_array(old('selected_assets'))) {
|
|
$assets = Asset::withTrashed()->findMany(old('selected_assets'));
|
|
|
|
[$assigned, $notAssigned] = $assets->partition(function (Asset $asset) {
|
|
return $asset->assigned_to;
|
|
});
|
|
|
|
session()->flashInput(['selected_assets' => $assigned->pluck('id')->values()->toArray()]);
|
|
}
|
|
|
|
$do_not_change = ['' => trans('general.do_not_change')];
|
|
$status_label_list = $do_not_change + Helper::statusLabelList();
|
|
|
|
return view('hardware/bulk-checkin', [
|
|
'statusLabel_list' => $status_label_list,
|
|
'removed_assets' => $notAssigned,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Process Multiple Checkin Request
|
|
*/
|
|
public function storeCheckin(Request $request): RedirectResponse
|
|
{
|
|
$this->authorize('checkin', Asset::class);
|
|
|
|
if (! is_array($request->input('selected_assets'))) {
|
|
return redirect()->route('hardware.bulkcheckin.show')->withInput()->with('error', trans('admin/hardware/message.multi-checkin.no_assets_selected'));
|
|
}
|
|
|
|
$asset_ids = array_filter($request->input('selected_assets'));
|
|
|
|
$assets = Asset::withTrashed()->findOrFail($asset_ids);
|
|
|
|
// Resolve via the scoped Location query so non-existent IDs and IDs the actor
|
|
// cannot see under FMCS are rejected before we touch any asset.
|
|
$submittedLocation = null;
|
|
if ($request->filled('location_id')) {
|
|
$submittedLocation = Location::find($request->input('location_id'));
|
|
|
|
if (! $submittedLocation) {
|
|
return redirect()->route('hardware.bulkcheckin.show')->withInput()
|
|
->with('error', trans('admin/hardware/message.create.target_not_found.location'));
|
|
}
|
|
}
|
|
|
|
$checkin_at = date('Y-m-d H:i:s');
|
|
if ($request->filled('checkin_at') && $request->input('checkin_at') != date('Y-m-d')) {
|
|
$checkin_at = $request->input('checkin_at');
|
|
}
|
|
|
|
$errors = [];
|
|
$admin = auth()->user();
|
|
|
|
DB::transaction(function () use ($assets, $admin, $checkin_at, $request, $submittedLocation, &$errors) {
|
|
foreach ($assets as $asset) {
|
|
$this->authorize('checkin', $asset);
|
|
|
|
if (is_null($asset->assignedTo)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $asset->assignedTo;
|
|
$originalValues = $asset->getRawOriginal();
|
|
|
|
$asset->expected_checkin = null;
|
|
$asset->assignedTo()->disassociate($asset);
|
|
$asset->accepted = null;
|
|
|
|
if ($request->filled('status_id')) {
|
|
$asset->status_id = $request->input('status_id');
|
|
}
|
|
|
|
$this->migrateLegacyLocations($asset);
|
|
|
|
$asset->location_id = $asset->rtd_location_id;
|
|
|
|
if ($request->has('location_id')) {
|
|
if ($submittedLocation) {
|
|
$asset->location_id = $submittedLocation->id;
|
|
if ($request->input('update_default_location') == 0) {
|
|
$asset->rtd_location_id = $submittedLocation->id;
|
|
}
|
|
} else {
|
|
$asset->location_id = null;
|
|
}
|
|
}
|
|
|
|
$asset->last_checkin = $checkin_at;
|
|
|
|
if ($request->boolean('checkin_licenses')) {
|
|
$asset->licenseseats->each(function (LicenseSeat $seat) {
|
|
$seat->update(['assigned_to' => null]);
|
|
});
|
|
}
|
|
|
|
CheckoutAcceptance::pending()->whereHasMorph('checkoutable', [Asset::class], function (Builder $query) use ($asset) {
|
|
$query->where('id', $asset->id);
|
|
})->get()->each->delete();
|
|
|
|
if ($asset->save()) {
|
|
if ($request->boolean('checkin_child_assets')) {
|
|
Asset::where('assigned_type', Asset::class)
|
|
->where('assigned_to', $asset->id)
|
|
->update(['location_id' => $asset->location_id]);
|
|
}
|
|
|
|
event(new CheckoutableCheckedIn($asset, $target, $admin, $request->input('note'), $checkin_at, $originalValues));
|
|
} else {
|
|
$errors = array_merge_recursive($errors, $asset->getErrors()->toArray());
|
|
}
|
|
}
|
|
});
|
|
|
|
if (! $errors) {
|
|
return redirect()->intended(route('hardware.index'))->with('success', trans_choice('admin/hardware/message.multi-checkin.success', count($asset_ids)));
|
|
}
|
|
|
|
return redirect()->route('hardware.bulkcheckin.show')->withInput()
|
|
->with('error', trans_choice('admin/hardware/message.multi-checkin.error', count($asset_ids)))
|
|
->withErrors($errors);
|
|
}
|
|
|
|
/**
|
|
* Show the bulk-audit form: single shared note, optional location
|
|
* override, optional next_audit_date override. Applied uniformly
|
|
* to every selected asset when the form is submitted.
|
|
*
|
|
* The full walk-per-asset customization the single-audit form
|
|
* offers (custom fields, per-asset image, etc.) is intentionally
|
|
* out of scope here; the whole point of bulk audit is to
|
|
* touch-and-go a stack of items at once. Users needing to record
|
|
* per-item details should still use the single audit form.
|
|
*/
|
|
public function showAudit(): View
|
|
{
|
|
$this->authorize('audit', Asset::class);
|
|
|
|
// Only prefill next_audit_date when the install has an
|
|
// audit_interval configured. Without it, `(int) null` falls
|
|
// out to 0 months and the field prefills as today, which
|
|
// reads as "audit this again immediately" and isn't useful.
|
|
$settings = Setting::getSettings();
|
|
$next_audit_date = $settings->audit_interval
|
|
? Carbon::now()->addMonths((int) $settings->audit_interval)->toDateString()
|
|
: null;
|
|
|
|
return view('hardware/bulk-audit', [
|
|
'next_audit_date' => $next_audit_date,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Apply the audit to every selected asset. Same skip-observer
|
|
* pattern the single-asset audit uses (AssetsController::auditStore)
|
|
* so the assets table update doesn't fire an extra Actionlog
|
|
* 'update' entry alongside the 'audit' entry logAudit() writes.
|
|
*/
|
|
public function storeAudit(UploadFileRequest $request): RedirectResponse
|
|
{
|
|
$this->authorize('audit', Asset::class);
|
|
|
|
if (! is_array($request->input('selected_assets'))) {
|
|
return redirect()->route('hardware.bulk-audit.show')->withInput()
|
|
->with('error', trans('admin/hardware/message.multi-audit.no_assets_selected'));
|
|
}
|
|
|
|
$asset_ids = array_filter($request->input('selected_assets'));
|
|
$assets = Asset::whereIn('id', $asset_ids)->get();
|
|
|
|
// Resolve the submitted location through the FMCS-scoped query
|
|
// so a location the actor can't see (or a fabricated id) fails
|
|
// fast, before we touch any asset. Matches the pattern the
|
|
// storeCheckin method uses one method up.
|
|
$submittedLocation = null;
|
|
if ($request->filled('location_id')) {
|
|
$submittedLocation = Location::find($request->input('location_id'));
|
|
if (! $submittedLocation) {
|
|
return redirect()->route('hardware.bulk-audit.show')->withInput()
|
|
->with('error', trans('admin/hardware/message.create.target_not_found.location'));
|
|
}
|
|
}
|
|
|
|
$errors = [];
|
|
$succeeded = 0;
|
|
|
|
DB::transaction(function () use ($assets, $request, $submittedLocation, &$errors, &$succeeded) {
|
|
foreach ($assets as $asset) {
|
|
$this->authorize('audit', $asset);
|
|
|
|
$originalValues = $asset->getRawOriginal();
|
|
|
|
if ($request->filled('next_audit_date')) {
|
|
$asset->next_audit_date = $request->input('next_audit_date');
|
|
}
|
|
$asset->last_audit_date = date('Y-m-d H:i:s');
|
|
|
|
if ($submittedLocation) {
|
|
$asset->location_id = $submittedLocation->id;
|
|
}
|
|
|
|
// Skip observer + fire model-level validation manually,
|
|
// matching AssetsController::auditStore's rationale.
|
|
$asset->unsetEventDispatcher();
|
|
|
|
if ($asset->isValid() && $asset->save()) {
|
|
// A single uploaded image is applied to every
|
|
// audit row, matching the API's bulkAudit shape.
|
|
// We hand handleFile the current asset's id so
|
|
// the stored filename is scoped per-row (avoids
|
|
// filename collisions when multiple audits fire
|
|
// in the same second).
|
|
$file_name = null;
|
|
if ($request->hasFile('image')) {
|
|
$file_name = $request->handleFile('private_uploads/audits/', 'audit-'.$asset->id, $request->file('image'));
|
|
}
|
|
|
|
$asset->logAudit(
|
|
$request->input('note'),
|
|
$submittedLocation?->id,
|
|
$file_name,
|
|
$originalValues,
|
|
);
|
|
$succeeded++;
|
|
} else {
|
|
$errors[$asset->id] = $asset->getErrors()->toArray();
|
|
}
|
|
}
|
|
});
|
|
|
|
if (! $errors) {
|
|
return redirect()->route('hardware.index')
|
|
->with('success', trans_choice('admin/hardware/message.multi-audit.success', $succeeded, ['count' => $succeeded]));
|
|
}
|
|
|
|
return redirect()->route('hardware.bulk-audit.show')->withInput()
|
|
->with('error', trans_choice('admin/hardware/message.multi-audit.partial_error', count($errors), ['success' => $succeeded, 'failed' => count($errors)]))
|
|
->withErrors($errors);
|
|
}
|
|
|
|
public function restore(Request $request): RedirectResponse
|
|
{
|
|
// Restore is a delete-level action across the codebase. The bulk
|
|
// POST handler used to gate on authorize('update', Asset::class),
|
|
// letting an assets.edit user undo an admin's soft-delete.
|
|
$this->authorize('delete', Asset::class);
|
|
$assetIds = $request->input('ids');
|
|
|
|
if (empty($assetIds)) {
|
|
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.restore.nothing_updated'));
|
|
}
|
|
|
|
foreach ($assetIds as $assetId) {
|
|
// Skip invalid or forged IDs. Prior code called ->restore() on
|
|
// null and 500'd on the first bad id in the payload.
|
|
if ($asset = Asset::withTrashed()->find($assetId)) {
|
|
$asset->restore();
|
|
}
|
|
}
|
|
|
|
return redirect()->route('hardware.index')->with('success', trans('admin/hardware/message.restore.success'));
|
|
}
|
|
|
|
public function hasUndeployableStatus(array $asset_ids)
|
|
{
|
|
$undeployable = Asset::whereIn('id', $asset_ids)
|
|
->undeployable()
|
|
->get();
|
|
|
|
$undeployableTags = $undeployable->map(function ($asset) {
|
|
return [
|
|
'id' => $asset->id,
|
|
'asset_tag' => $asset->asset_tag,
|
|
];
|
|
})->toArray();
|
|
|
|
$undeployableIds = array_column($undeployableTags, 'id');
|
|
$filtered_ids = array_diff($asset_ids, $undeployableIds);
|
|
|
|
if ($undeployable->isNotEmpty()) {
|
|
return ['status' => true, 'tags' => $undeployableTags, 'asset_ids' => $filtered_ids];
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function bulkEditForm(): View|RedirectResponse
|
|
{
|
|
$this->authorize('update', Asset::class);
|
|
|
|
$asset_ids = session()->pull('bulkedit_ids', []);
|
|
|
|
if (empty($asset_ids)) {
|
|
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.update.no_assets_selected'));
|
|
}
|
|
|
|
$assets = Asset::with('model')->withTrashed()->whereIn('id', $asset_ids)->get();
|
|
|
|
if ($assets->isEmpty()) {
|
|
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.update.assets_do_not_exist_or_are_invalid'));
|
|
}
|
|
|
|
$models = $assets->unique('model_id');
|
|
$modelNames = [];
|
|
foreach ($models as $model) {
|
|
$modelNames[] = $model->model->name;
|
|
}
|
|
|
|
return view('hardware/bulk')
|
|
->with('assets', $asset_ids)
|
|
->with('statuslabel_list', Helper::statusLabelList())
|
|
->with('models', $models->pluck(['model']))
|
|
->with('modelNames', $modelNames);
|
|
}
|
|
}
|