Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2026-05-30 18:28:00 +01:00
4 changed files with 1842 additions and 0 deletions
+989
View File
@@ -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);
}
}
}
+59
View 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
+771
View File
@@ -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 711 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();
}
}