Merge remote-tracking branch 'origin/develop'
This commit is contained in:
@@ -0,0 +1,989 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\CheckoutableCheckedIn;
|
||||
use App\Mail\BulkDeleteReportMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
use function Laravel\Prompts\error;
|
||||
use function Laravel\Prompts\info;
|
||||
use function Laravel\Prompts\multisearch;
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\search;
|
||||
use function Laravel\Prompts\select;
|
||||
use function Laravel\Prompts\warning;
|
||||
|
||||
class BulkDelete extends Command
|
||||
{
|
||||
protected $signature = 'snipeit:checkin-delete-items';
|
||||
|
||||
protected $description = 'Interactively check in and/or delete items by company and type';
|
||||
|
||||
private const CHECKIN_NOTE = 'Checked in via bulk CLI operation';
|
||||
|
||||
private array $reportLines = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// Step 1: Dry run?
|
||||
$dryRun = confirm(
|
||||
label: 'Is this a dry run?',
|
||||
default: true,
|
||||
yes: 'Yes — preview only, no changes will be made',
|
||||
no: 'No — LIVE RUN, changes WILL be made',
|
||||
);
|
||||
|
||||
// Step 2: Who are you?
|
||||
$adminId = search(
|
||||
label: 'Who are you? Search by username, first or last name.',
|
||||
placeholder: 'Type to search users...',
|
||||
options: function (string $value): array {
|
||||
if (strlen($value) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return User::where('activated', 1)
|
||||
->whereNull('deleted_at')
|
||||
->onlySuperAdmins()
|
||||
->where(function ($query) use ($value) {
|
||||
$query->where('username', 'like', "%{$value}%")
|
||||
->orWhere('first_name', 'like', "%{$value}%")
|
||||
->orWhere('last_name', 'like', "%{$value}%")
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$value}%"]);
|
||||
})
|
||||
->get()
|
||||
->mapWithKeys(fn (User $u) => [$u->id => "{$u->first_name} {$u->last_name} ({$u->username})"])
|
||||
->toArray();
|
||||
},
|
||||
validate: fn (mixed $value) => ! $value ? 'A valid active user is required.' : null,
|
||||
);
|
||||
|
||||
/** @var User $admin */
|
||||
$admin = User::findOrFail((int) $adminId);
|
||||
|
||||
// Step 3: Which companies?
|
||||
if (! Company::exists()) {
|
||||
error('No companies found. Please create at least one company before using this command.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$selectedCompanyKeys = multisearch(
|
||||
label: 'Which companies would you like to check in and delete items for?',
|
||||
placeholder: 'Type to search companies...',
|
||||
options: function (string $value): array {
|
||||
$results = [];
|
||||
|
||||
if ($value === '' || str_contains('(no company / unassigned)', strtolower($value))) {
|
||||
$results['__null__'] = '(No Company / Unassigned)';
|
||||
}
|
||||
|
||||
$query = Company::orderBy('name');
|
||||
if ($value !== '') {
|
||||
$query->where('name', 'like', "%{$value}%");
|
||||
}
|
||||
|
||||
$query->get()->each(function (Company $c) use (&$results) {
|
||||
$results[$c->id] = "{$c->name} (ID: {$c->id})";
|
||||
});
|
||||
|
||||
return $results;
|
||||
},
|
||||
scroll: 10,
|
||||
required: 'Please select at least one company.',
|
||||
hint: 'If you\'re searching on several differently named companies, use the up-arrow to go back to the search box to search again. ',
|
||||
);
|
||||
|
||||
$includeNullCompany = in_array('__null__', $selectedCompanyKeys);
|
||||
$selectedCompanyIds = array_values(array_filter(
|
||||
$selectedCompanyKeys,
|
||||
fn ($k) => $k !== '__null__'
|
||||
));
|
||||
|
||||
$companyNamesById = Company::whereIn('id', $selectedCompanyIds)->pluck('name', 'id')->toArray();
|
||||
$selectedCompanyNames = array_map(
|
||||
fn ($id) => $id === '__null__' ? '(No Company)' : ($companyNamesById[$id] ?? "(ID: {$id})"),
|
||||
$selectedCompanyKeys
|
||||
);
|
||||
|
||||
// Step 4: Which item types?
|
||||
$rawTypeSelection = multiselect(
|
||||
label: 'What item types would you like to check in and delete?',
|
||||
options: [
|
||||
'all' => 'All Items (assets, licenses, accessories, components, consumables, users)',
|
||||
'assets' => 'Assets',
|
||||
'licenses' => 'Licenses',
|
||||
'accessories' => 'Accessories',
|
||||
'components' => 'Components',
|
||||
'consumables' => 'Consumables',
|
||||
'users' => 'Users',
|
||||
],
|
||||
required: 'Please select at least one item type.',
|
||||
hint: 'Select "All Items" to process every supported type.',
|
||||
);
|
||||
|
||||
$allSubTypes = ['assets', 'licenses', 'accessories', 'components', 'consumables', 'users'];
|
||||
$selectedTypes = in_array('all', $rawTypeSelection)
|
||||
? $allSubTypes
|
||||
: array_values(array_intersect($allSubTypes, $rawTypeSelection));
|
||||
|
||||
// Compute and display counts now so the user can see what will be affected
|
||||
$counts = $this->getCounts($selectedTypes, $selectedCompanyIds, $includeNullCompany);
|
||||
|
||||
$skipAdminUser = false;
|
||||
|
||||
$this->line('');
|
||||
$this->line(' Items that would be affected:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
|
||||
if (in_array('users', $selectedTypes)) {
|
||||
$userInScope = $this->buildUserQuery($selectedCompanyIds, $includeNullCompany)
|
||||
->where('users.id', $admin->id)
|
||||
->exists();
|
||||
|
||||
if ($userInScope) {
|
||||
$skipAdminUser = true;
|
||||
$counts['users'] = max(0, ($counts['users'] ?? 0) - 1);
|
||||
warning(" Your user ({$admin->username}) is within the selected scope and will be skipped during user deletion.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
// Step 5: Hard delete, soft delete, or no delete?
|
||||
$deleteType = select(
|
||||
label: 'How should items be deleted?',
|
||||
options: [
|
||||
'soft' => 'Soft delete — items moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
'none' => 'No delete — check in only, items remain in inventory',
|
||||
],
|
||||
default: 'soft',
|
||||
);
|
||||
|
||||
// Step 6: Send checkin notifications? (not applicable to users or consumables)
|
||||
$notifiableTypes = array_intersect($selectedTypes, ['assets', 'licenses', 'accessories', 'components']);
|
||||
$sendNotifications = false;
|
||||
|
||||
if (! empty($notifiableTypes)) {
|
||||
$sendNotifications = confirm(
|
||||
label: 'Should we send checkin notifications?',
|
||||
default: true,
|
||||
hint: 'Applies to: '.implode(', ', $notifiableTypes).'. Users and consumables are excluded.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 7: Clear related action_logs?
|
||||
$clearLogs = confirm(
|
||||
label: 'Should we clear related action logs?',
|
||||
default: false,
|
||||
hint: 'This removes all history for affected items, as if the data never existed.',
|
||||
);
|
||||
|
||||
// Step 8: Delete associated files?
|
||||
$deleteFiles = false;
|
||||
if ($deleteType !== 'none') {
|
||||
$deleteFiles = confirm(
|
||||
label: 'Should we also delete associated image and upload files?',
|
||||
default: $deleteType === 'hard',
|
||||
hint: 'Permanently removes images, avatars, signatures, EULAs, and action log uploads from disk.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 9: Delete the companies themselves?
|
||||
$deleteCompanyType = 'keep';
|
||||
if (! empty($selectedCompanyIds)) {
|
||||
$deleteCompanyType = select(
|
||||
label: 'Should the selected companies also be deleted?',
|
||||
options: [
|
||||
'keep' => 'Keep — do not delete the companies',
|
||||
'soft' => 'Soft delete — companies moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
],
|
||||
default: 'keep',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 10: Backup first?
|
||||
$doBackup = confirm(
|
||||
label: 'Should we run a backup before proceeding?',
|
||||
default: true,
|
||||
hint: 'Strongly recommended. Saved as backup-before-bulk-delete-cli-[datetime].zip',
|
||||
);
|
||||
|
||||
// Step 11: Summary + final confirmation
|
||||
$this->line('');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(' SUMMARY OF ACTIONS');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(" Admin user: {$admin->first_name} {$admin->last_name} ({$admin->username})");
|
||||
$this->line(' Companies: '.implode(', ', $selectedCompanyNames));
|
||||
$this->line(' Item types: '.implode(', ', $selectedTypes));
|
||||
$this->line(" Delete mode: {$deleteType}");
|
||||
$this->line(' Notifications: '.($sendNotifications ? 'Yes' : 'No'));
|
||||
$this->line(' Clear logs: '.($clearLogs ? 'Yes' : 'No'));
|
||||
$this->line(' Delete files: '.($deleteFiles ? 'Yes' : 'No'));
|
||||
$this->line(' Delete companies: '.($deleteCompanyType === 'keep' ? 'No' : ucfirst($deleteCompanyType).' delete'));
|
||||
$this->line(' Backup first: '.($doBackup ? 'Yes' : 'No'));
|
||||
$this->line(' Dry run: '.($dryRun ? 'Yes' : 'No'));
|
||||
$this->line('');
|
||||
$this->line(' Items to be processed:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
if ($skipAdminUser) {
|
||||
$this->line(' * Your user account will be skipped during user deletion.');
|
||||
}
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line('');
|
||||
|
||||
// Step 10.5: Email report?
|
||||
$sendEmailReport = false;
|
||||
if ($admin->email) {
|
||||
$sendEmailReport = confirm(
|
||||
label: "Send an email report to {$admin->email}?",
|
||||
default: false,
|
||||
hint: 'A summary of all '.($dryRun ? 'would-be ' : '').'actions will be emailed to you.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$confirmed = confirm(
|
||||
label: 'Are you sure you want to proceed? This cannot be undone.',
|
||||
default: false,
|
||||
);
|
||||
|
||||
if (! $confirmed) {
|
||||
info('Aborted. No changes were made.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Run backup if requested
|
||||
if ($doBackup && ! $dryRun) {
|
||||
$backupFilename = 'backup-before-bulk-delete-cli-'.now()->format('Y-m-d-H-i-s');
|
||||
info("Running backup ({$backupFilename}.zip)...");
|
||||
$result = $this->callSilently('snipeit:backup', ['--filename' => $backupFilename]);
|
||||
if ($result === 0) {
|
||||
info("Backup completed: {$backupFilename}.zip");
|
||||
} else {
|
||||
warning("Backup may have failed (exit code {$result}). Proceeding anyway.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 11: Execute with progress bar
|
||||
$totalItems = array_sum($counts);
|
||||
$bar = $this->output->createProgressBar($totalItems > 0 ? $totalItems : 1);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||
$bar->setMessage('Starting...');
|
||||
$bar->start();
|
||||
|
||||
foreach ($selectedTypes as $type) {
|
||||
match ($type) {
|
||||
'assets' => $this->processAssets($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'licenses' => $this->processLicenses($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'accessories' => $this->processAccessories($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'components' => $this->processComponents($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'consumables' => $this->processConsumables($selectedCompanyIds, $includeNullCompany, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'users' => $this->processUsers($selectedCompanyIds, $includeNullCompany, $admin, $skipAdminUser, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
};
|
||||
}
|
||||
|
||||
$bar->setMessage('Done.');
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
$this->line('');
|
||||
|
||||
// Delete companies if requested
|
||||
if ($deleteCompanyType !== 'keep' && ! empty($selectedCompanyIds)) {
|
||||
$companies = Company::whereIn('id', $selectedCompanyIds)->get();
|
||||
foreach ($companies as $company) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would {$deleteCompanyType}-delete company {$company->name}");
|
||||
$this->reportLines[] = "Would {$deleteCompanyType}-delete company {$company->name}";
|
||||
} else {
|
||||
if ($deleteCompanyType === 'soft') {
|
||||
$company->delete();
|
||||
} else {
|
||||
$company->forceDelete();
|
||||
}
|
||||
// Remove any remaining pivot associations (e.g. the admin user who was
|
||||
// skipped during user processing but is still a member of this company)
|
||||
DB::table('company_user')->where('company_id', $company->id)->delete();
|
||||
$this->reportLines[] = ucfirst($deleteCompanyType)."-deleted company {$company->name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
warning('Dry run complete — no changes were made.');
|
||||
} else {
|
||||
info('All actions completed successfully.');
|
||||
}
|
||||
|
||||
if ($sendEmailReport && $admin->email) {
|
||||
Mail::to($admin->email)->send(new BulkDeleteReportMail(
|
||||
admin: $admin,
|
||||
dryRun: $dryRun,
|
||||
companyNames: $selectedCompanyNames,
|
||||
selectedTypes: $selectedTypes,
|
||||
deleteType: $deleteType,
|
||||
reportLines: $this->reportLines,
|
||||
runAt: now(),
|
||||
));
|
||||
info("Report sent to {$admin->email}.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getCounts(array $types, array $companyIds, bool $includeNull): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
if (in_array('assets', $types)) {
|
||||
$counts['assets'] = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('licenses', $types)) {
|
||||
$counts['licenses'] = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('accessories', $types)) {
|
||||
$counts['accessories'] = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('components', $types)) {
|
||||
$counts['components'] = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('consumables', $types)) {
|
||||
$counts['consumables'] = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('users', $types)) {
|
||||
$counts['users'] = $this->buildUserQuery($companyIds, $includeNull)->count();
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function buildCompanyQuery(Builder $query, array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return $query->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function buildUserQuery(array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('activated', 1)
|
||||
->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processAssets(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$assets = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$bar->setMessage("Assets: {$asset->asset_tag}");
|
||||
|
||||
if ($asset->assignedTo) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in asset {$asset->asset_tag} from {$asset->assignedTo->name}");
|
||||
$this->reportLines[] = "Would check in asset {$asset->asset_tag} (assigned to {$asset->assignedTo->name})";
|
||||
} else {
|
||||
$target = $asset->assignedTo;
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($asset, $target, $admin, self::CHECKIN_NOTE, $checkinAt, $originalValues));
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
} else {
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
$asset->logCheckin($target, self::CHECKIN_NOTE, $checkinAt, $originalValues);
|
||||
}
|
||||
|
||||
$this->reportLines[] = "Checked in asset {$asset->asset_tag} from {$target->name}";
|
||||
$asset->licenseseats()->update(['assigned_to' => null]);
|
||||
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->whereNull('accepted_at')
|
||||
->whereNull('declined_at')
|
||||
->forceDelete();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $asset->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Delete checkout acceptance files, then hard-remove all acceptances
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->forceDelete();
|
||||
|
||||
// Hard-delete-only cleanup: maintenance records, accessory checkouts to this
|
||||
// asset, and any other assets that were assigned to this one
|
||||
$maintenanceImages = [];
|
||||
if ($deleteType === 'hard') {
|
||||
if ($deleteFiles) {
|
||||
$maintenanceImages = $asset->maintenances()
|
||||
->whereNotNull('image')
|
||||
->pluck('image')
|
||||
->toArray();
|
||||
}
|
||||
$asset->maintenances()->forceDelete();
|
||||
AccessoryCheckout::where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->delete();
|
||||
DB::table('assets')
|
||||
->where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $asset->delete(),
|
||||
'hard' => $asset->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$asset->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($asset->image) {
|
||||
$this->deleteStorageFile('public', app('assets_upload_path').$asset->image);
|
||||
}
|
||||
foreach ($maintenanceImages as $img) {
|
||||
$this->deleteStorageFile('public', app('maintenances_upload_path').$img);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete asset {$asset->asset_tag}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processLicenses(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$licenses = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
$bar->setMessage("Licenses: {$license->name}");
|
||||
|
||||
$seats = LicenseSeat::where('license_id', $license->id)
|
||||
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
|
||||
->get();
|
||||
|
||||
foreach ($seats as $seat) {
|
||||
$target = $seat->assigned_to ? $seat->user : $seat->asset;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$seat->assigned_to = null;
|
||||
$seat->asset_id = null;
|
||||
$seat->save();
|
||||
|
||||
$this->reportLines[] = "Checked in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($seat, $target, $admin, self::CHECKIN_NOTE));
|
||||
} else {
|
||||
$seat->logCheckin($target, self::CHECKIN_NOTE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $license->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($deleteType === 'soft') {
|
||||
$license->licenseseats()->delete();
|
||||
$license->delete();
|
||||
$this->reportLines[] = "Soft-deleted license {$license->name}";
|
||||
} elseif ($deleteType === 'hard') {
|
||||
$seatIds = $license->licenseseats()->pluck('id');
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->forceDelete();
|
||||
$license->licenseseats()->forceDelete();
|
||||
DB::table('kits_licenses')->where('license_id', $license->id)->delete();
|
||||
$license->forceDelete();
|
||||
$this->reportLines[] = "Hard-deleted license {$license->name}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$license->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete license {$license->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete license {$license->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processAccessories(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$accessories = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($accessories as $accessory) {
|
||||
$bar->setMessage("Accessories: {$accessory->name}");
|
||||
|
||||
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
|
||||
|
||||
foreach ($checkouts as $checkout) {
|
||||
$target = $checkout->assignedTo;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$checkout->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($accessory, $target, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$accessory->logCheckin($target, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $accessory->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$accessory->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_accessories')->where('accessory_id', $accessory->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $accessory->delete(),
|
||||
'hard' => $accessory->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($accessory->image) {
|
||||
$this->deleteStorageFile('public', app('accessories_upload_path').$accessory->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete accessory {$accessory->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processComponents(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$components = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($components as $component) {
|
||||
$bar->setMessage("Components: {$component->name}");
|
||||
|
||||
$assignments = DB::table('components_assets')
|
||||
->where('component_id', $component->id)
|
||||
->get();
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$asset = Asset::find($assignment->asset_id);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
DB::table('components_assets')->where('id', $assignment->id)->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($asset) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($component, $asset, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$component->logCheckin($asset, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $component->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$component->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $component->delete(),
|
||||
'hard' => $component->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted component {$component->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($component->image) {
|
||||
$this->deleteStorageFile('public', app('components_upload_path').$component->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete component {$component->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete component {$component->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processConsumables(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$consumables = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($consumables as $consumable) {
|
||||
$bar->setMessage("Consumables: {$consumable->name}");
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $consumable->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$consumable->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_consumables')->where('consumable_id', $consumable->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $consumable->delete(),
|
||||
'hard' => $consumable->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($consumable->image) {
|
||||
$this->deleteStorageFile('public', app('consumables_upload_path').$consumable->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete consumable {$consumable->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processUsers(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
User $admin,
|
||||
bool $skipAdminUser,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$users = $this->buildUserQuery($companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($skipAdminUser && $user->id === $admin->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bar->setMessage("Users: {$user->username}");
|
||||
|
||||
// If real companies were selected, check whether this user also belongs to
|
||||
// companies outside the selected scope. If so, only remove the selected-company
|
||||
// associations and skip full deletion to avoid orphaning them from their other companies.
|
||||
if (! empty($companyIds)) {
|
||||
$allUserCompanyIds = array_unique(array_filter(array_merge(
|
||||
$user->companies()->pluck('companies.id')->toArray(),
|
||||
$user->company_id ? [$user->company_id] : [],
|
||||
)));
|
||||
$outsideCompanyIds = array_values(array_diff($allUserCompanyIds, $companyIds));
|
||||
|
||||
if (! empty($outsideCompanyIds)) {
|
||||
$outsideNames = Company::whereIn('id', $outsideCompanyIds)->pluck('name')->implode(', ');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would partially disassociate user {$user->username} (also belongs to: {$outsideNames})");
|
||||
$this->reportLines[] = "Would partially disassociate user {$user->username} — also belongs to: {$outsideNames}";
|
||||
} else {
|
||||
$user->companies()->detach($companyIds);
|
||||
warning(" Skipped full deletion of {$user->username}: they also belong to {$outsideNames}. Removed selected company associations only.");
|
||||
$this->reportLines[] = "Partially disassociated user {$user->username} — also belongs to: {$outsideNames}. Full deletion skipped.";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect file paths and acceptance records before deleting pivot data
|
||||
$acceptancesToDelete = $deleteFiles
|
||||
? CheckoutAcceptance::where('assigned_to_id', $user->id)->get()
|
||||
: collect();
|
||||
|
||||
$actionLogPaths = $deleteFiles
|
||||
? Actionlog::where('item_type', User::class)
|
||||
->where('item_id', $user->id)
|
||||
->where('action_type', 'uploaded')
|
||||
->whereNotNull('filename')
|
||||
->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Clear pivot/assignment data that will orphan on deletion
|
||||
LicenseSeat::where('assigned_to', $user->id)->update(['assigned_to' => null]);
|
||||
AccessoryCheckout::where('assigned_to', $user->id)
|
||||
->where('assigned_type', User::class)
|
||||
->delete();
|
||||
DB::table('consumables_users')->where('assigned_to', $user->id)->delete();
|
||||
CheckoutAcceptance::where('assigned_to_id', $user->id)->forceDelete();
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('company_user')->where('user_id', $user->id)->delete();
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$user->userlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $user->delete(),
|
||||
'hard' => $user->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted user {$user->username}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($user->avatar) {
|
||||
$this->deleteStorageFile('public', app('users_upload_path').$user->avatar);
|
||||
}
|
||||
$acceptancesToDelete->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete user {$user->username}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete user {$user->username}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteStorageFile(string $disk, ?string $path): void
|
||||
{
|
||||
if (! $path) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$storage = $disk === 'public'
|
||||
? Storage::disk('public')
|
||||
: Storage::disk(config('filesystems.default'));
|
||||
if ($storage->exists($path)) {
|
||||
$storage->delete($path);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Could not delete file {$path}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAcceptanceFiles(CheckoutAcceptance $acceptance): void
|
||||
{
|
||||
if ($acceptance->signature_filename) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/signatures/'.$acceptance->signature_filename);
|
||||
}
|
||||
if ($acceptance->stored_eula_file) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/eula-pdfs/'.$acceptance->stored_eula_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BulkDeleteReportMail extends BaseMailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $admin,
|
||||
public readonly bool $dryRun,
|
||||
public readonly array $companyNames,
|
||||
public readonly array $selectedTypes,
|
||||
public readonly string $deleteType,
|
||||
public readonly array $reportLines,
|
||||
public readonly Carbon $runAt,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->dryRun
|
||||
? '[Dry Run] Bulk Check-in/Delete Report'
|
||||
: 'Bulk Check-in/Delete Report';
|
||||
|
||||
return new Envelope(
|
||||
from: new Address(config('mail.from.address'), config('mail.from.name')),
|
||||
subject: $subject,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'notifications.markdown.report-bulk-delete',
|
||||
with: [
|
||||
'admin' => $this->admin,
|
||||
'dryRun' => $this->dryRun,
|
||||
'companyNames' => $this->companyNames,
|
||||
'selectedTypes' => $this->selectedTypes,
|
||||
'deleteType' => $this->deleteType,
|
||||
'reportLines' => $this->reportLines,
|
||||
'runAt' => $this->runAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@component('mail::message')
|
||||
# {{ $dryRun ? '[Dry Run] ' : '' }}Bulk Check-in/Delete Report
|
||||
|
||||
**Run by:** {{ $admin->first_name }} {{ $admin->last_name }} ({{ $admin->username }})<br>
|
||||
**Date:** {{ $runAt->format('Y-m-d H:i:s') }}<br>
|
||||
**Mode:** {{ $dryRun ? 'Dry run (no changes made)' : 'Live run' }}<br>
|
||||
**Delete type:** {{ ucfirst($deleteType) }}<br>
|
||||
**Companies:** {{ implode(', ', $companyNames) }}<br>
|
||||
**Item types:** {{ implode(', ', $selectedTypes) }}
|
||||
|
||||
---
|
||||
|
||||
@if(count($reportLines) > 0)
|
||||
## Actions {{ $dryRun ? 'That Would Have Been ' : '' }}Taken
|
||||
|
||||
@foreach($reportLines as $line)
|
||||
- {{ $line }}
|
||||
@endforeach
|
||||
@else
|
||||
No actions were {{ $dryRun ? 'identified' : 'taken' }}.
|
||||
@endif
|
||||
|
||||
@endcomponent
|
||||
@@ -0,0 +1,771 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Events\CheckoutableCheckedIn;
|
||||
use App\Mail\BulkDeleteReportMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Testing\PendingCommand;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BulkDeleteTest extends TestCase
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt sequence helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds a PendingCommand with the full prompt sequence pre-answered.
|
||||
*
|
||||
* Search() falls back as two steps: ask() for the search term, then
|
||||
* choice() to select from matching results. Multiselect() and select()
|
||||
* each fall back as a single choice() call.
|
||||
*/
|
||||
private function runCommand(
|
||||
User $admin,
|
||||
array $companyIds,
|
||||
array $types,
|
||||
string $deleteType = 'soft',
|
||||
bool $dryRun = false,
|
||||
bool $sendNotifications = false,
|
||||
bool $clearLogs = false,
|
||||
bool $deleteFiles = false,
|
||||
bool $doBackup = false,
|
||||
bool $confirm = true,
|
||||
bool $sendEmailReport = false,
|
||||
string $deleteCompanyType = 'keep',
|
||||
): PendingCommand {
|
||||
$hasNotifiableTypes = ! empty(array_intersect($types, ['assets', 'licenses', 'accessories', 'components']));
|
||||
|
||||
$searchLabel = 'Who are you? Search by username, first or last name.';
|
||||
$companiesLabel = 'Which companies would you like to check in and delete items for?';
|
||||
$typesLabel = 'What item types would you like to check in and delete?';
|
||||
$deleteLabel = 'How should items be deleted?';
|
||||
|
||||
$cmd = $this->artisan('snipeit:checkin-delete-items')
|
||||
// Step 1: dry run confirm
|
||||
->expectsConfirmation('Is this a dry run?', $dryRun ? 'yes' : 'no')
|
||||
// Step 2: search() — ask() for the search term, then choice() to pick from results
|
||||
// Using expectsQuestion for both to avoid triggering choices validation
|
||||
->expectsQuestion($searchLabel, $admin->username)
|
||||
->expectsQuestion($searchLabel, (string) $admin->id)
|
||||
// Step 3: multisearch for companies — ask() for search term, then multiselectFallback()
|
||||
->expectsQuestion($companiesLabel, '')
|
||||
->expectsQuestion($companiesLabel, $companyIds)
|
||||
// Step 4: multiselect for item types
|
||||
->expectsQuestion($typesLabel, $types)
|
||||
// Step 5: select for delete mode
|
||||
->expectsQuestion($deleteLabel, $deleteType);
|
||||
|
||||
// Step 6: notification confirm only shown for checkiable types
|
||||
if ($hasNotifiableTypes) {
|
||||
$cmd->expectsConfirmation('Should we send checkin notifications?', $sendNotifications ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
// Steps 7–11 and final confirm
|
||||
$cmd->expectsConfirmation('Should we clear related action logs?', $clearLogs ? 'yes' : 'no');
|
||||
|
||||
// Step 8: file deletion prompt — only shown when deleteType !== 'none'
|
||||
if ($deleteType !== 'none') {
|
||||
$cmd->expectsConfirmation('Should we also delete associated image and upload files?', $deleteFiles ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
// Step 9: company deletion — only shown when real companies (not just __null__) were selected
|
||||
if (! empty($companyIds)) {
|
||||
$cmd->expectsQuestion('Should the selected companies also be deleted?', $deleteCompanyType);
|
||||
}
|
||||
|
||||
$cmd->expectsConfirmation('Should we run a backup before proceeding?', $doBackup ? 'yes' : 'no');
|
||||
|
||||
// Email report prompt — only shown when admin has an email address
|
||||
if ($admin->email) {
|
||||
$cmd->expectsConfirmation("Send an email report to {$admin->email}?", $sendEmailReport ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$cmd->expectsConfirmation('Are you sure you want to proceed? This cannot be undone.', $confirm ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_assigned_asset_is_checked_in_and_soft_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id]);
|
||||
$asset = Asset::factory()->for($company)->assignedToUser($user)->create();
|
||||
|
||||
$this->assertNotNull($asset->assigned_to);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($asset);
|
||||
$this->assertNull($asset->fresh()->assigned_to);
|
||||
}
|
||||
|
||||
public function test_unassigned_asset_is_soft_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($asset);
|
||||
}
|
||||
|
||||
public function test_asset_is_hard_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->assignedToUser()->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('assets', ['id' => $asset->id]);
|
||||
}
|
||||
|
||||
public function test_asset_checkin_event_fired_when_notifications_enabled(): void
|
||||
{
|
||||
Event::fake([CheckoutableCheckedIn::class]);
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->assignedToUser()->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], sendNotifications: true)
|
||||
->assertExitCode(0);
|
||||
|
||||
Event::assertDispatched(CheckoutableCheckedIn::class, fn ($e) => $e->checkoutable->is($asset));
|
||||
}
|
||||
|
||||
public function test_asset_checkin_event_not_fired_when_notifications_suppressed(): void
|
||||
{
|
||||
Event::fake([CheckoutableCheckedIn::class]);
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
Asset::factory()->for($company)->assignedToUser()->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], sendNotifications: false)
|
||||
->assertExitCode(0);
|
||||
|
||||
Event::assertNotDispatched(CheckoutableCheckedIn::class);
|
||||
}
|
||||
|
||||
public function test_asset_scoped_to_correct_company(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||
|
||||
$assetA = Asset::factory()->for($companyA)->create();
|
||||
$assetB = Asset::factory()->for($companyB)->create();
|
||||
|
||||
$this->runCommand($admin, [$companyA->id], ['assets'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($assetA);
|
||||
$this->assertNotSoftDeleted($assetB);
|
||||
}
|
||||
|
||||
public function test_asset_delete_clears_all_checkout_acceptances(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->create();
|
||||
|
||||
CheckoutAcceptance::factory()->create([
|
||||
'checkoutable_type' => Asset::class,
|
||||
'checkoutable_id' => $asset->id,
|
||||
]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('checkout_acceptances', [
|
||||
'checkoutable_type' => Asset::class,
|
||||
'checkoutable_id' => $asset->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_asset_checkin_clears_linked_license_seats(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id]);
|
||||
$asset = Asset::factory()->for($company)->assignedToUser($user)->create();
|
||||
$seat = LicenseSeat::factory()->create(['asset_id' => $asset->id, 'assigned_to' => $user->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertNull($seat->fresh()->assigned_to);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Licenses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_assigned_license_seat_is_checked_in_and_license_soft_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$license = License::factory()->for($company)->create();
|
||||
$seat = LicenseSeat::factory()->for($license)->assignedToUser()->create();
|
||||
|
||||
$this->assertNotNull($seat->assigned_to);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['licenses'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($license);
|
||||
$this->assertNull($seat->fresh()->assigned_to);
|
||||
$this->assertNull($seat->fresh()->asset_id);
|
||||
}
|
||||
|
||||
public function test_license_hard_delete_removes_seats_and_their_checkout_acceptances(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$license = License::factory()->for($company)->create();
|
||||
$seat = LicenseSeat::factory()->for($license)->assignedToUser()->create();
|
||||
|
||||
CheckoutAcceptance::factory()->create([
|
||||
'checkoutable_type' => LicenseSeat::class,
|
||||
'checkoutable_id' => $seat->id,
|
||||
]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['licenses'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('licenses', ['id' => $license->id]);
|
||||
$this->assertDatabaseMissing('license_seats', ['id' => $seat->id]);
|
||||
$this->assertDatabaseMissing('checkout_acceptances', [
|
||||
'checkoutable_type' => LicenseSeat::class,
|
||||
'checkoutable_id' => $seat->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_license_soft_delete_also_soft_deletes_seats(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$license = License::factory()->for($company)->create();
|
||||
$seat = LicenseSeat::factory()->for($license)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['licenses'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($license);
|
||||
$this->assertSoftDeleted($seat);
|
||||
}
|
||||
|
||||
public function test_license_scoped_to_correct_company(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||
|
||||
$licenseA = License::factory()->for($companyA)->create();
|
||||
$licenseB = License::factory()->for($companyB)->create();
|
||||
|
||||
$this->runCommand($admin, [$companyA->id], ['licenses'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($licenseA);
|
||||
$this->assertNotSoftDeleted($licenseB);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_checked_out_accessory_is_checked_in_and_soft_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$accessory = Accessory::factory()->for($company)->checkedOutToUser()->create();
|
||||
|
||||
$this->assertEquals(1, $accessory->checkouts->count());
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['accessories'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($accessory);
|
||||
$this->assertEquals(0, $accessory->fresh()->checkouts->count());
|
||||
}
|
||||
|
||||
public function test_accessory_scoped_to_correct_company(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||
|
||||
$accessoryA = Accessory::factory()->for($companyA)->checkedOutToUser()->create();
|
||||
$accessoryB = Accessory::factory()->for($companyB)->checkedOutToUser()->create();
|
||||
|
||||
$this->runCommand($admin, [$companyA->id], ['accessories'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($accessoryA);
|
||||
$this->assertNotSoftDeleted($accessoryB);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_checked_out_component_is_checked_in_and_soft_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$component = Component::factory()->for($company)->checkedOutToAsset()->create();
|
||||
|
||||
$this->assertDatabaseHas('components_assets', ['component_id' => $component->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['components'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($component);
|
||||
$this->assertDatabaseMissing('components_assets', ['component_id' => $component->id]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consumables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_consumable_is_soft_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$consumable = Consumable::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['consumables'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($consumable);
|
||||
}
|
||||
|
||||
public function test_consumable_is_hard_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$consumable = Consumable::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['consumables'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('consumables', ['id' => $consumable->id]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_user_is_soft_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($user);
|
||||
}
|
||||
|
||||
public function test_user_delete_nulls_license_seat_assignments(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
$seat = LicenseSeat::factory()->create(['assigned_to' => $user->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertNull($seat->fresh()->assigned_to);
|
||||
}
|
||||
|
||||
public function test_user_delete_removes_accessory_checkout_records(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
Accessory::factory()->for($company)->checkedOutToUser($user)->create();
|
||||
|
||||
$this->assertDatabaseHas('accessories_checkout', ['assigned_to' => $user->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('accessories_checkout', ['assigned_to' => $user->id]);
|
||||
}
|
||||
|
||||
public function test_user_delete_removes_consumable_assignments(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
Consumable::factory()->for($company)->checkedOutToUser($user)->create();
|
||||
|
||||
$this->assertDatabaseHas('consumables_users', ['assigned_to' => $user->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('consumables_users', ['assigned_to' => $user->id]);
|
||||
}
|
||||
|
||||
public function test_user_delete_removes_checkout_acceptances(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
|
||||
CheckoutAcceptance::factory()->create(['assigned_to_id' => $user->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('checkout_acceptances', ['assigned_to_id' => $user->id]);
|
||||
}
|
||||
|
||||
public function test_admin_user_is_skipped_during_user_deletion(): void
|
||||
{
|
||||
$company = Company::factory()->create();
|
||||
$admin = User::factory()->superuser()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseHas('users', ['id' => $admin->id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action log clearing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_clear_logs_removes_asset_action_logs(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->assignedToUser($user)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], clearLogs: true)
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('action_logs', [
|
||||
'item_type' => Asset::class,
|
||||
'item_id' => $asset->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Maintenance / related table cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_asset_hard_delete_removes_maintenance_records(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->create();
|
||||
$maintenance = Maintenance::factory()->create(['asset_id' => $asset->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('maintenances', ['id' => $maintenance->id]);
|
||||
}
|
||||
|
||||
public function test_asset_hard_delete_removes_accessory_checkouts_to_asset(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->create();
|
||||
$accessory = Accessory::factory()->for($company)->create();
|
||||
$accessory->checkouts()->create([
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $asset->id,
|
||||
'assigned_type' => Asset::class,
|
||||
'created_by' => $admin->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('accessories_checkout', [
|
||||
'assigned_to' => $asset->id,
|
||||
'assigned_type' => Asset::class,
|
||||
]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('accessories_checkout', [
|
||||
'assigned_to' => $asset->id,
|
||||
'assigned_type' => Asset::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_asset_hard_delete_nulls_child_asset_assignments(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$target = Asset::factory()->for($company)->create();
|
||||
$child = Asset::factory()->create(['assigned_to' => $target->id, 'assigned_type' => Asset::class]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertNull($child->fresh()->assigned_to);
|
||||
$this->assertNull($child->fresh()->assigned_type);
|
||||
}
|
||||
|
||||
public function test_license_hard_delete_removes_kit_entries(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$license = License::factory()->for($company)->create();
|
||||
DB::table('kits_licenses')->insert(['kit_id' => 1, 'license_id' => $license->id, 'quantity' => 1]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['licenses'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('kits_licenses', ['license_id' => $license->id]);
|
||||
}
|
||||
|
||||
public function test_accessory_hard_delete_removes_kit_entries(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$accessory = Accessory::factory()->for($company)->create();
|
||||
DB::table('kits_accessories')->insert(['kit_id' => 1, 'accessory_id' => $accessory->id, 'quantity' => 1]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['accessories'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('kits_accessories', ['accessory_id' => $accessory->id]);
|
||||
}
|
||||
|
||||
public function test_consumable_hard_delete_removes_kit_entries(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$consumable = Consumable::factory()->for($company)->create();
|
||||
DB::table('kits_consumables')->insert(['kit_id' => 1, 'consumable_id' => $consumable->id, 'quantity' => 1]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['consumables'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('kits_consumables', ['consumable_id' => $consumable->id]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dry run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_dry_run_does_not_delete_or_checkin_assets(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id]);
|
||||
$asset = Asset::factory()->for($company)->assignedToUser($user)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], dryRun: true)
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertNotSoftDeleted($asset);
|
||||
$this->assertNotNull($asset->fresh()->assigned_to);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Final confirm / abort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_declining_final_confirm_makes_no_changes(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$asset = Asset::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], confirm: false)
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertNotSoftDeleted($asset);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-company / checkin-only mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_multiple_companies_are_all_processed(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||
|
||||
$assetA = Asset::factory()->for($companyA)->create();
|
||||
$assetB = Asset::factory()->for($companyB)->create();
|
||||
$assetC = Asset::factory()->create(); // no company — should be untouched
|
||||
|
||||
$this->runCommand($admin, [$companyA->id, $companyB->id], ['assets'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($assetA);
|
||||
$this->assertSoftDeleted($assetB);
|
||||
$this->assertNotSoftDeleted($assetC);
|
||||
}
|
||||
|
||||
public function test_checkin_only_mode_checks_in_without_deleting(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id]);
|
||||
$asset = Asset::factory()->for($company)->assignedToUser($user)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteType: 'none')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertNotSoftDeleted($asset);
|
||||
$this->assertNull($asset->fresh()->assigned_to);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Company deletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_company_is_soft_deleted_when_requested(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
Asset::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteCompanyType: 'soft')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($company);
|
||||
}
|
||||
|
||||
public function test_company_is_hard_deleted_when_requested(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
Asset::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteCompanyType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('companies', ['id' => $company->id]);
|
||||
}
|
||||
|
||||
public function test_company_is_kept_when_not_requested(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
Asset::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], deleteCompanyType: 'keep')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseHas('companies', ['id' => $company->id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-company user handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_user_in_multiple_companies_is_partially_disassociated(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||
$user = User::factory()->create(['company_id' => $companyA->id, 'activated' => 1]);
|
||||
$user->companies()->syncWithoutDetaching([$companyA->id, $companyB->id]);
|
||||
|
||||
$this->runCommand($admin, [$companyA->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
// User should NOT have been deleted
|
||||
$this->assertDatabaseHas('users', ['id' => $user->id, 'deleted_at' => null]);
|
||||
// companyA pivot entry should be removed
|
||||
$this->assertDatabaseMissing('company_user', ['user_id' => $user->id, 'company_id' => $companyA->id]);
|
||||
// companyB pivot entry should remain
|
||||
$this->assertDatabaseHas('company_user', ['user_id' => $user->id, 'company_id' => $companyB->id]);
|
||||
}
|
||||
|
||||
public function test_user_in_only_selected_company_is_fully_deleted(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
$user->companies()->syncWithoutDetaching([$company->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSoftDeleted($user);
|
||||
}
|
||||
|
||||
public function test_user_hard_delete_removes_company_user_pivot_entries(): void
|
||||
{
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
$user = User::factory()->create(['company_id' => $company->id, 'activated' => 1]);
|
||||
$user->companies()->syncWithoutDetaching([$company->id]);
|
||||
|
||||
$this->assertDatabaseHas('company_user', ['user_id' => $user->id, 'company_id' => $company->id]);
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['users'], deleteType: 'hard')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('users', ['id' => $user->id]);
|
||||
$this->assertDatabaseMissing('company_user', ['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Email report
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public function test_email_report_is_sent_when_requested(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
Asset::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], sendEmailReport: true)
|
||||
->assertExitCode(0);
|
||||
|
||||
Mail::assertSent(BulkDeleteReportMail::class, fn ($mail) => $mail->hasTo($admin->email));
|
||||
}
|
||||
|
||||
public function test_email_report_is_not_sent_when_declined(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
$company = Company::factory()->create();
|
||||
Asset::factory()->for($company)->create();
|
||||
|
||||
$this->runCommand($admin, [$company->id], ['assets'], sendEmailReport: false)
|
||||
->assertExitCode(0);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user