diff --git a/app/Http/Controllers/Assets/AssetsController.php b/app/Http/Controllers/Assets/AssetsController.php index 6e6e8766e1..1c71ed83ae 100755 --- a/app/Http/Controllers/Assets/AssetsController.php +++ b/app/Http/Controllers/Assets/AssetsController.php @@ -444,6 +444,11 @@ class AssetsController extends Controller $asset->expected_checkin = $request->input('expected_checkin', null); $asset->requestable = $request->input('requestable', 0); $asset->rtd_location_id = $request->input('rtd_location_id', null); + // Current location is editable from the asset edit form as of + // the location-dropdown addition. Blank clears it (the mutator + // normalizes '' / 0 to NULL) so the next checkout can re-derive + // location_id from the target. See general.location_edit_help. + $asset->location_id = $request->input('location_id', null); $asset->byod = $request->input('byod', 0); $status = Statuslabel::find($request->input('status_id')); diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 7998362412..14b76c713d 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -108,6 +108,40 @@ class Asset extends Depreciable 'deleted_at' => 'datetime', ]; + /** + * location_id and company_id should store NULL when there's no + * assignment, never 0. Old data and previous bugs occasionally + * left `0` behind (empty select2 → '' → integer-cast → 0), which + * then breaks `exists:` validation and FMCS queries that treat + * NULL and 0 as different. Mutators normalize on write; matching + * accessors normalize on read so legacy rows already storing 0 + * present as null at the model boundary until they're re-saved. + * Mirrors the parent_id pattern on Company / SnipeModel. + */ + public function setLocationIdAttribute($value): void + { + $this->attributes['location_id'] = ($value === '' || $value === null || (int) $value === 0) + ? null + : (int) $value; + } + + public function getLocationIdAttribute($value): ?int + { + return ($value === null || (int) $value === 0) ? null : (int) $value; + } + + public function setCompanyIdAttribute($value): void + { + $this->attributes['company_id'] = ($value === '' || $value === null || (int) $value === 0) + ? null + : (int) $value; + } + + public function getCompanyIdAttribute($value): ?int + { + return ($value === null || (int) $value === 0) ? null : (int) $value; + } + protected $rules = [ 'model_id' => ['required', 'integer', 'exists:models,id,deleted_at,NULL', 'not_array'], 'status_id' => ['required', 'integer', 'exists:status_labels,id'], diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php index f9e63d659e..a71d89872a 100644 --- a/resources/lang/en-US/general.php +++ b/resources/lang/en-US/general.php @@ -644,6 +644,7 @@ return [ 'copied' => 'Copied!', 'status_compatibility' => 'If assets are already assigned, they cannot be changed to a non-deployable status type and this value change will be skipped.', 'rtd_location_help' => 'This is the location of the asset when it is not checked out', + 'location_edit_help' => 'If left blank, the location will be updated to match the location of whoever (or wherever) this asset is checked out to.', 'item_not_found' => ':item_type ID :id does not exist or has been deleted', 'item_target_not_found_hard' => ':item_type ID :id does not exist or has been hard-deleted. Would you like to force a checkin?', 'force_checkin' => 'Force Checkin', diff --git a/resources/views/hardware/edit.blade.php b/resources/views/hardware/edit.blade.php index 75d06483c4..b889580c26 100755 --- a/resources/views/hardware/edit.blade.php +++ b/resources/views/hardware/edit.blade.php @@ -118,6 +118,17 @@ input_div_class="col-md-7 col-sm-12" /> + {{-- Current location. When blank, the observer / checkout flow + updates location_id to match the checkout target's location + (user's location, target-location, or parent asset's location). + See the note under general.location_edit_help. --}} + + {{-- Default (ready-to-deploy) location --}} company_id === null`. + * + * The mutator normalizes on write (Asset::setLocationIdAttribute / + * setCompanyIdAttribute) and the accessor normalizes on read for + * legacy rows already storing 0. + */ +class AssetZeroIdNormalizationTest extends TestCase +{ + public function test_setting_location_id_to_zero_normalizes_to_null(): void + { + $asset = Asset::factory()->create(); + + $asset->location_id = 0; + $this->assertNull($asset->location_id); + + $asset->location_id = '0'; + $this->assertNull($asset->location_id); + + $asset->location_id = ''; + $this->assertNull($asset->location_id); + } + + public function test_setting_company_id_to_zero_normalizes_to_null(): void + { + $asset = Asset::factory()->create(); + + $asset->company_id = 0; + $this->assertNull($asset->company_id); + + $asset->company_id = '0'; + $this->assertNull($asset->company_id); + + $asset->company_id = ''; + $this->assertNull($asset->company_id); + } + + public function test_legacy_zero_stored_in_db_reads_as_null(): void + { + // Simulate a row from the pre-mutator era where 0 slipped in. + // DB::update bypasses model events so we can write the invalid + // value directly. + $asset = Asset::factory()->create(); + DB::table('assets')->where('id', $asset->id)->update([ + 'location_id' => 0, + 'company_id' => 0, + ]); + + $reloaded = Asset::find($asset->id); + + $this->assertNull($reloaded->location_id); + $this->assertNull($reloaded->company_id); + } + + public function test_real_ids_still_persist_and_read_normally(): void + { + // Non-regression: normalization must not touch valid ids. + $company = Company::factory()->create(); + $location = Location::factory()->create(); + $asset = Asset::factory()->create(); + + $asset->company_id = $company->id; + $asset->location_id = $location->id; + $asset->save(); + + $reloaded = Asset::find($asset->id); + + $this->assertSame($company->id, $reloaded->company_id); + $this->assertSame($location->id, $reloaded->location_id); + } +}