diff --git a/app/Helpers/Helper.php b/app/Helpers/Helper.php
index c770210293..9f89d9db8f 100644
--- a/app/Helpers/Helper.php
+++ b/app/Helpers/Helper.php
@@ -14,6 +14,7 @@ use App\Models\License;
use App\Models\Location;
use App\Models\Setting;
use App\Models\Statuslabel;
+use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\RedirectResponse;
@@ -1700,6 +1701,8 @@ class Helper
return [];
}
+ $floater = (bool) Setting::getSettings()->null_company_is_floater;
+
foreach ($locations as $location) {
// in case of an update of a single location, use the newly requested company_id
if ($new_company_id) {
@@ -1745,20 +1748,40 @@ class Helper
$count = 0;
foreach ($items as $item) {
+ if (! $item) {
+ continue;
+ }
- if ($item && $item->company_id != $location_company) {
+ // Users belong to companies via the many-to-many pivot (company_user).
+ // canReceiveFromCompany() returns true only when the user's pivot
+ // contains the location's company, so !canReceiveFromCompany() is
+ // the correct mismatch signal.
+ if ($item instanceof User) {
+ $isMismatch = ! $item->canReceiveFromCompany((int) $location_company);
+ } elseif ($item->company_id == $location_company) {
+ $isMismatch = false;
+ } elseif (is_null($item->company_id) || is_null($location_company)) {
+ $isMismatch = ! $floater;
+ } else {
+ $isMismatch = true;
+ }
+
+ if ($isMismatch) {
+ if ($item instanceof User) {
+ $itemCompanyIds = $item->companies->pluck('id')->implode(', ');
+ $itemCompanyNames = $item->companies->pluck('name')->implode(', ');
+ } else {
+ $itemCompanyIds = $item->company_id ?? null;
+ $itemCompanyNames = $item->company->name ?? null;
+ }
$mismatched[] = [
class_basename(get_class($item)),
$item->id,
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
- $item->company_id ?? null,
- $item->company->name ?? null,
- // $item->defaultLoc->id ?? null,
- // $item->defaultLoc->name ?? null,
- // $item->defaultLoc->company->id ?? null,
- // $item->defaultLoc->company->name ?? null,
+ $itemCompanyIds,
+ $itemCompanyNames,
$item->location->name ?? null,
$item->location->company->name ?? null,
$location_company ?? null,
diff --git a/app/Http/Controllers/Accessories/AccessoryCheckoutController.php b/app/Http/Controllers/Accessories/AccessoryCheckoutController.php
index a00e072cb7..f1e3502f11 100644
--- a/app/Http/Controllers/Accessories/AccessoryCheckoutController.php
+++ b/app/Http/Controllers/Accessories/AccessoryCheckoutController.php
@@ -10,7 +10,6 @@ use App\Http\Traits\CheckInOutTrait;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
-use App\Models\Setting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
@@ -67,13 +66,18 @@ class AccessoryCheckoutController extends Controller
$target = $this->determineCheckoutTarget();
session()->put(['checkout_to_type' => $target]);
- if (
- Setting::getSettings()->full_multiple_companies_support == '1'
- && $accessory->company_id
- && $target instanceof User
- && ! $target->canReceiveFromCompany($accessory->company_id)
- ) {
- return redirect()->back()->with('error', trans('general.error_user_company'));
+ if (! $accessory->canCheckoutTo($target)) {
+ $targetType = match (class_basename($target)) {
+ 'User' => trans('general.user'),
+ 'Location' => trans('general.location'),
+ default => trans('general.asset'),
+ };
+
+ return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.accessory').' "'.$accessory->name.'"',
+ 'item_company' => $accessory->company?->name ?? trans('general.unassigned'),
+ 'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
+ ]));
}
$accessory->checkout_qty = $request->input('checkout_qty', 1);
diff --git a/app/Http/Controllers/Api/AssetsController.php b/app/Http/Controllers/Api/AssetsController.php
index 0581847bf0..b2b1a4042c 100644
--- a/app/Http/Controllers/Api/AssetsController.php
+++ b/app/Http/Controllers/Api/AssetsController.php
@@ -913,21 +913,7 @@ class AssetsController extends Controller
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
- if (Setting::getSettings()->full_multiple_companies_support != '1' || is_null($asset->company_id)) {
- return null;
- }
-
- // For users with multiple companies, check all their associated companies,
- // not just the primary company_id column.
- if ($target instanceof User) {
- if (! $target->canReceiveFromCompany((int) $asset->company_id)) {
- return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
- }
-
- return null;
- }
-
- if (! is_null($target->company_id) && (int) $asset->company_id !== (int) $target->company_id) {
+ if (! $asset->canCheckoutTo($target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
diff --git a/app/Http/Controllers/Api/LocationsController.php b/app/Http/Controllers/Api/LocationsController.php
index 64f706287d..8353e00aec 100644
--- a/app/Http/Controllers/Api/LocationsController.php
+++ b/app/Http/Controllers/Api/LocationsController.php
@@ -211,12 +211,19 @@ class LocationsController extends Controller
$location->fill($request->all());
$location = $request->handleImages($location);
- // Only scope location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
- // check if parent is set and has a different company
- if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
- return response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent'));
+ }
+
+ // Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
+ if (Setting::getSettings()->full_multiple_companies_support) {
+ $parent = $location->parent_id ? Location::find($location->parent_id) : null;
+ if ($parent && $parent->company_id != $location->company_id) {
+ return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
+ 'parent' => $parent->name,
+ 'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
+ 'location_company' => $location->company?->name ?? trans('general.unassigned'),
+ ])));
}
}
@@ -303,22 +310,36 @@ class LocationsController extends Controller
$location = $request->handleImages($location);
if ($request->filled('company_id')) {
- // Only scope location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if there are related objects with different company
- if (Helper::test_locations_fmcs(false, $id, $location->company_id)) {
- return response()->json(Helper::formatStandardApiResponse('error', null, 'error scoped locations'));
- }
- // check if parent is set and has a different company
- if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
- return response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent'));
+ if ($mismatched = Helper::test_locations_fmcs(false, $id, $location->company_id)) {
+ $first = $mismatched[0];
+
+ return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_scoped_items', [
+ 'item_type' => trans('general.'.strtolower($first[0])),
+ 'item_name' => $first[2],
+ 'item_company' => $first[5] ?? trans('general.unassigned'),
+ ])));
}
} else {
$location->company_id = $request->input('company_id');
}
}
+ // Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
+ // Runs outside the company_id gate so a parent_id-only update is also validated.
+ if (Setting::getSettings()->full_multiple_companies_support) {
+ $parent = $location->parent_id ? Location::find($location->parent_id) : null;
+ if ($parent && $parent->company_id != $location->company_id) {
+ return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
+ 'parent' => $parent->name,
+ 'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
+ 'location_company' => $location->company?->name ?? trans('general.unassigned'),
+ ])));
+ }
+ }
+
if ($location->isValid()) {
$location->save();
diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php
index 5512247dbb..49c3960055 100644
--- a/app/Http/Controllers/Api/UsersController.php
+++ b/app/Http/Controllers/Api/UsersController.php
@@ -404,7 +404,7 @@ class UsersController extends Controller
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
if (! empty($companyIds)) {
- $users->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
+ $users = Company::scopeUsersByCompanyIds($users, $companyIds);
}
}
diff --git a/app/Http/Controllers/Assets/AssetCheckoutController.php b/app/Http/Controllers/Assets/AssetCheckoutController.php
index 9c0221984a..0c8e0d7f2e 100644
--- a/app/Http/Controllers/Assets/AssetCheckoutController.php
+++ b/app/Http/Controllers/Assets/AssetCheckoutController.php
@@ -9,7 +9,6 @@ use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
-use App\Models\Setting;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -119,18 +118,18 @@ class AssetCheckoutController extends Controller
// Add any custom fields that should be included in the checkout
$asset->customFieldsForCheckinCheckout('display_checkout');
- $settings = Setting::getSettings();
+ if (! $asset->canCheckoutTo($target)) {
+ $targetType = match (class_basename($target)) {
+ 'User' => trans('general.user'),
+ 'Location' => trans('general.location'),
+ default => trans('general.asset'),
+ };
- // Locations have no company, so we only enforce FMCS when both sides have a company_id.
- // For users with multiple companies, check all their associated companies via the pivot.
- if ($settings->full_multiple_companies_support && ! is_null($asset->company_id)) {
- $mismatch = $target instanceof User
- ? ! $target->canReceiveFromCompany((int) $asset->company_id)
- : (! is_null($target->company_id) && (int) $target->company_id !== (int) $asset->company_id);
-
- if ($mismatch) {
- return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_user_company'));
- }
+ return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.asset').' "'.$asset->display_name.'"',
+ 'item_company' => $asset->company?->name ?? trans('general.unassigned'),
+ 'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
+ ]));
}
session()->put([
diff --git a/app/Http/Controllers/Assets/BulkAssetsController.php b/app/Http/Controllers/Assets/BulkAssetsController.php
index 7bca83d2ec..33649e8feb 100644
--- a/app/Http/Controllers/Assets/BulkAssetsController.php
+++ b/app/Http/Controllers/Assets/BulkAssetsController.php
@@ -689,17 +689,17 @@ class BulkAssetsController extends Controller
}
// Prevent checking out assets across companies if FMCS enabled.
- // For users with multiple companies, check all their associated companies via the pivot.
if (Setting::getSettings()->full_multiple_companies_support) {
$company_ids = $assets->pluck('company_id')->filter()->unique();
if ($company_ids->isNotEmpty()) {
- $assetCompanyId = (int) $company_ids->first();
-
- $mismatch = $company_ids->count() > 1
- || ($target instanceof User
- ? ! $target->canReceiveFromCompany($assetCompanyId)
- : (! is_null($target->company_id) && (int) $target->company_id !== $assetCompanyId));
+ if ($company_ids->count() > 1) {
+ // Selected assets span multiple companies; bulk checkout can't satisfy all of them.
+ $mismatch = true;
+ } else {
+ // All assets share the same company; let the model enforce the checkout rules.
+ $mismatch = ! $assets->first()->canCheckoutTo($target);
+ }
if ($mismatch) {
$request->session()->flashInput(['selected_assets' => $asset_ids]);
diff --git a/app/Http/Controllers/Components/ComponentCheckoutController.php b/app/Http/Controllers/Components/ComponentCheckoutController.php
index 949e9a4f1a..90c0bc309b 100644
--- a/app/Http/Controllers/Components/ComponentCheckoutController.php
+++ b/app/Http/Controllers/Components/ComponentCheckoutController.php
@@ -7,7 +7,6 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\Asset;
use App\Models\Component;
-use App\Models\Setting;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@@ -104,8 +103,12 @@ class ComponentCheckoutController extends Controller
// Check if the asset exists
$asset = Asset::find($request->input('asset_id'));
- if ((Setting::getSettings()->full_multiple_companies_support) && $component->company_id !== $asset->company_id) {
- return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_user_company'));
+ if (! $component->canCheckoutTo($asset)) {
+ return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.component').' "'.$component->name.'"',
+ 'item_company' => $component->company?->name ?? trans('general.unassigned'),
+ 'target' => trans('general.asset').' "'.$asset->display_name.'"',
+ ]));
}
$component->checkout_qty = $request->input('assigned_qty');
diff --git a/app/Http/Controllers/Consumables/ConsumableCheckoutController.php b/app/Http/Controllers/Consumables/ConsumableCheckoutController.php
index fddd821c17..018655fe7c 100644
--- a/app/Http/Controllers/Consumables/ConsumableCheckoutController.php
+++ b/app/Http/Controllers/Consumables/ConsumableCheckoutController.php
@@ -7,7 +7,6 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
-use App\Models\Setting;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
@@ -97,12 +96,12 @@ class ConsumableCheckoutController extends Controller
return redirect()->route('consumables.checkout.show', $consumable)->with('error', trans('admin/consumables/message.checkout.user_does_not_exist'))->withInput();
}
- if (
- Setting::getSettings()->full_multiple_companies_support == '1'
- && $consumable->company_id
- && ! $user->canReceiveFromCompany($consumable->company_id)
- ) {
- return redirect()->back()->with('error', trans('general.error_user_company'));
+ if (! $consumable->canCheckoutTo($user)) {
+ return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.consumable').' "'.$consumable->name.'"',
+ 'item_company' => $consumable->company?->name ?? trans('general.unassigned'),
+ 'target' => trans('general.user').' "'.$user->username.'"',
+ ]));
}
// Update the consumable data
diff --git a/app/Http/Controllers/Licenses/LicenseCheckoutController.php b/app/Http/Controllers/Licenses/LicenseCheckoutController.php
index f30acbf2a7..54bcef42f8 100644
--- a/app/Http/Controllers/Licenses/LicenseCheckoutController.php
+++ b/app/Http/Controllers/Licenses/LicenseCheckoutController.php
@@ -99,13 +99,21 @@ class LicenseCheckoutController extends Controller
if (Setting::getSettings()->full_multiple_companies_support == '1') {
if ($request->filled('asset_id')) {
$fmcsTarget = Asset::find($request->input('asset_id'));
- if ($fmcsTarget && $license->company_id && $license->company_id !== $fmcsTarget->company_id) {
- return redirect()->route('licenses.index')->with('error', trans('general.error_user_company'));
+ if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
+ return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.license').' "'.$license->name.'"',
+ 'item_company' => $license->company?->name ?? trans('general.unassigned'),
+ 'target' => trans('general.asset').' "'.$fmcsTarget->display_name.'"',
+ ]));
}
} elseif ($request->filled('assigned_to')) {
$fmcsTarget = User::find($request->input('assigned_to'));
- if ($fmcsTarget && $license->company_id && ! $fmcsTarget->canReceiveFromCompany($license->company_id)) {
- return redirect()->route('licenses.index')->with('error', trans('general.error_user_company'));
+ if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
+ return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.license').' "'.$license->name.'"',
+ 'item_company' => $license->company?->name ?? trans('general.unassigned'),
+ 'target' => trans('general.user').' "'.$fmcsTarget->username.'"',
+ ]));
}
}
}
diff --git a/app/Http/Controllers/LocationsController.php b/app/Http/Controllers/LocationsController.php
index cad42a7c5b..1180a738bd 100755
--- a/app/Http/Controllers/LocationsController.php
+++ b/app/Http/Controllers/LocationsController.php
@@ -89,19 +89,24 @@ class LocationsController extends Controller
$location->fax = request('fax');
$location->tag_color = $request->input('tag_color');
$location->notes = $request->input('notes');
- $location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
-
- // Only scope the location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
- // check if parent is set and has a different company
- if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
- return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent');
- }
} else {
$location->company_id = $request->input('company_id');
}
+ // Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
+ if (Setting::getSettings()->full_multiple_companies_support) {
+ $parent = $location->parent_id ? Location::find($location->parent_id) : null;
+ if ($parent && $parent->company_id != $location->company_id) {
+ return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
+ 'parent' => $parent->name,
+ 'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
+ 'location_company' => $location->company?->name ?? trans('general.unassigned'),
+ ]));
+ }
+ }
+
if ($request->has('use_cloned_image')) {
$cloned_model_img = Location::select('image')->find($request->input('clone_image_from_id'));
if ($cloned_model_img) {
@@ -171,21 +176,34 @@ class LocationsController extends Controller
$location->tag_color = $request->input('tag_color');
$location->notes = $request->input('notes');
- // Only scope the location if the setting is enabled
if (Setting::getSettings()->scope_locations_fmcs) {
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
// check if there are related objects with different company
- if (Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
- return redirect()->back()->withInput()->withInput()->with('error', 'error scoped locations');
- }
- // check if parent is set and has a different company
- if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
- return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent');
+ if ($mismatched = Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
+ $first = $mismatched[0];
+
+ return redirect()->back()->withInput()->with('error', trans('general.error_location_scoped_items', [
+ 'item_type' => trans('general.'.strtolower($first[0])),
+ 'item_name' => $first[2],
+ 'item_company' => $first[5] ?? trans('general.unassigned'),
+ ]));
}
} else {
$location->company_id = $request->input('company_id');
}
+ // Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
+ if (Setting::getSettings()->full_multiple_companies_support) {
+ $parent = $location->parent_id ? Location::find($location->parent_id) : null;
+ if ($parent && $parent->company_id != $location->company_id) {
+ return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
+ 'parent' => $parent->name,
+ 'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
+ 'location_company' => $location->company?->name ?? trans('general.unassigned'),
+ ]));
+ }
+ }
+
$location = $request->handleImages($location);
if ($location->save()) {
diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php
index a3d653eaa1..5420152a41 100644
--- a/app/Http/Controllers/SettingsController.php
+++ b/app/Http/Controllers/SettingsController.php
@@ -93,10 +93,12 @@ class SettingsController extends Controller
$old_locations_fmcs = $setting->scope_locations_fmcs;
$setting->full_multiple_companies_support = $request->input('full_multiple_companies_support', '0');
$setting->scope_locations_fmcs = $request->input('scope_locations_fmcs', '0');
+ $setting->null_company_is_floater = $request->input('null_company_is_floater', '0');
- // Backward compatibility for locations makes no sense without FullMultipleCompanySupport
+ // These options make no sense without FullMultipleCompanySupport
if (! $setting->full_multiple_companies_support) {
$setting->scope_locations_fmcs = '0';
+ $setting->null_company_is_floater = '0';
}
// check for inconsistencies when activating scoped locations
diff --git a/app/Importer/AssetImporter.php b/app/Importer/AssetImporter.php
index e56ebde78a..bd8cce65a7 100644
--- a/app/Importer/AssetImporter.php
+++ b/app/Importer/AssetImporter.php
@@ -214,16 +214,25 @@ class AssetImporter extends ItemImporter
// -- the class that needs to use it (command importer or GUI importer inside the project).
if (isset($target) && ($target !== false)) {
$asset = $asset->fresh();
- $targetType = get_class($target);
- $alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
- // Skip duplicate checkout noise when update mode keeps the same assignment target.
- if (! $alreadyCheckedOutToTarget) {
- if (! is_null($asset->assigned_to)) {
- event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
+ if (! $asset->canCheckoutTo($target)) {
+ $this->log(trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.asset').' "'.$asset->display_name.'"',
+ 'item_company' => $asset->company?->name ?? trans('general.unassigned'),
+ 'target' => ($target->name ?? $target->username ?? $target->id),
+ ]));
+ } else {
+ $targetType = get_class($target);
+ $alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
+
+ // Skip duplicate checkout noise when update mode keeps the same assignment target.
+ if (! $alreadyCheckedOutToTarget) {
+ if (! is_null($asset->assigned_to)) {
+ event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
+ }
+
+ $asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
-
- $asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
}
diff --git a/app/Importer/ComponentImporter.php b/app/Importer/ComponentImporter.php
index 5066c8eb2b..0145997745 100644
--- a/app/Importer/ComponentImporter.php
+++ b/app/Importer/ComponentImporter.php
@@ -59,13 +59,21 @@ class ComponentImporter extends ItemImporter
// If we have an asset tag, checkout to that asset.
if (isset($this->item['asset_tag']) && ($asset = Asset::where('asset_tag', $this->item['asset_tag'])->first())) {
- $component->assets()->attach($component->id, [
- 'component_id' => $component->id,
- 'created_by' => auth()->id(),
- 'created_at' => date('Y-m-d H:i:s'),
- 'assigned_qty' => 1, // Only assign the first one to the asset
- 'asset_id' => $asset->id,
- ]);
+ if (! $component->canCheckoutTo($asset)) {
+ $this->log(trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.component').' "'.$component->name.'"',
+ 'item_company' => $component->company?->name ?? trans('general.unassigned'),
+ 'target' => trans('general.asset').' "'.$asset->display_name.'"',
+ ]));
+ } else {
+ $component->assets()->attach($component->id, [
+ 'component_id' => $component->id,
+ 'created_by' => auth()->id(),
+ 'created_at' => date('Y-m-d H:i:s'),
+ 'assigned_qty' => 1, // Only assign the first one to the asset
+ 'asset_id' => $asset->id,
+ ]);
+ }
}
return;
diff --git a/app/Importer/ItemImporter.php b/app/Importer/ItemImporter.php
index 5cfa8973ca..dc19cf076f 100644
--- a/app/Importer/ItemImporter.php
+++ b/app/Importer/ItemImporter.php
@@ -82,6 +82,7 @@ class ItemImporter extends Importer
$this->item['qty'] = $this->findCsvMatch($row, 'quantity');
$this->item['requestable'] = $this->findCsvMatch($row, 'requestable');
$this->item['created_by'] = auth()->id();
+ $this->item['asset_tag'] = $this->findCsvMatch($row, 'asset_tag');
$this->item['serial'] = $this->findCsvMatch($row, 'serial');
$this->item['item_no'] = trim($this->findCsvMatch($row, 'item_no'));
diff --git a/app/Importer/LicenseImporter.php b/app/Importer/LicenseImporter.php
index 101adc3b41..d637d9084e 100644
--- a/app/Importer/LicenseImporter.php
+++ b/app/Importer/LicenseImporter.php
@@ -106,16 +106,32 @@ class LicenseImporter extends ItemImporter
}
if ($checkout_target) {
- $targetLicense->assigned_to = $checkout_target->id;
- $targetLicense->created_by = auth()->id();
- if ($asset) {
- $targetLicense->asset_id = $asset->id;
+ if (! $license->canCheckoutTo($checkout_target)) {
+ $this->log(trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.license').' "'.$license->name.'"',
+ 'item_company' => $license->company?->name ?? trans('general.unassigned'),
+ 'target' => ($checkout_target->name ?? $checkout_target->username ?? $checkout_target->id),
+ ]));
+ } else {
+ $targetLicense->assigned_to = $checkout_target->id;
+ $targetLicense->created_by = auth()->id();
+ if ($asset) {
+ $targetLicense->asset_id = $asset->id;
+ }
+ $targetLicense->save();
}
- $targetLicense->save();
} elseif ($asset) {
- $targetLicense->created_by = auth()->id();
- $targetLicense->asset_id = $asset->id;
- $targetLicense->save();
+ if (! $license->canCheckoutTo($asset)) {
+ $this->log(trans('general.error_checkout_company_mismatch', [
+ 'item' => trans('general.license').' "'.$license->name.'"',
+ 'item_company' => $license->company?->name ?? trans('general.unassigned'),
+ 'target' => trans('general.asset').' "'.$asset->display_name.'"',
+ ]));
+ } else {
+ $targetLicense->created_by = auth()->id();
+ $targetLicense->asset_id = $asset->id;
+ $targetLicense->save();
+ }
}
}
diff --git a/app/Models/Company.php b/app/Models/Company.php
index 9c7a48878b..c32dd3d222 100644
--- a/app/Models/Company.php
+++ b/app/Models/Company.php
@@ -398,10 +398,17 @@ final class Company extends SnipeModel
return $query->whereIn('companies.id', $companyIds);
}
+ $floater = Setting::getSettings()->null_company_is_floater;
+
// Users are scoped by pivot membership (company_user), not by company_id column,
// since a user may belong to multiple companies and company_id alone is insufficient.
if ($query->getModel()->getTable() == 'users') {
if (empty($companyIds)) {
+ // Floater: actor has no company and is unrestricted — see everyone.
+ if ($floater) {
+ return $query;
+ }
+
// No pivot memberships: mirror old null-company behavior — show only users
// who are also not in any company via the pivot.
return $query->whereNotIn('users.id', function ($sub) {
@@ -409,6 +416,15 @@ final class Company extends SnipeModel
});
}
+ // Floater: also include users with no company associations (they float). They all float down here, Georgie.).
+ if ($floater) {
+ return $query->where(function ($q) use ($companyIds) {
+ $q->whereIn('users.id', function ($sub) use ($companyIds) {
+ $sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
+ })->orWhereDoesntHave('companies');
+ });
+ }
+
return $query->whereIn('users.id', function ($sub) use ($companyIds) {
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
});
@@ -419,6 +435,11 @@ final class Company extends SnipeModel
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
if (empty($companyIds)) {
+ // Floater: actor has no company and is unrestricted — see everything.
+ if ($floater) {
+ return $query;
+ }
+
return $query->whereNull($table.$column);
}
@@ -432,10 +453,36 @@ final class Company extends SnipeModel
});
}
+ // Floater: null-company items are visible to users from any company.
+ if ($floater) {
+ return $query->where(function ($q) use ($table, $column, $companyIds) {
+ $q->whereIn($table.$column, $companyIds)
+ ->orWhereNull($table.$column);
+ });
+ }
+
return $query->whereIn($table.$column, $companyIds);
}
}
+ /**
+ * Scope a users query to those belonging to the given company IDs, respecting floater mode.
+ *
+ * Extracted from controller-level inline logic so the same rule is enforced consistently
+ * everywhere users are filtered by a specific set of company IDs (e.g. select2 dropdowns).
+ */
+ public static function scopeUsersByCompanyIds($query, array $companyIds): mixed
+ {
+ if (Setting::getSettings()->null_company_is_floater) {
+ return $query->where(function ($q) use ($companyIds) {
+ $q->whereHas('companies', fn ($q2) => $q2->whereIn('companies.id', $companyIds))
+ ->orWhereDoesntHave('companies');
+ });
+ }
+
+ return $query->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
+ }
+
/**
* I legit do not know what this method does, but we can't remove it (yet).
*
diff --git a/app/Models/Traits/CompanyableTrait.php b/app/Models/Traits/CompanyableTrait.php
index 4d891d05d3..2c00fc5698 100644
--- a/app/Models/Traits/CompanyableTrait.php
+++ b/app/Models/Traits/CompanyableTrait.php
@@ -3,6 +3,9 @@
namespace App\Models\Traits;
use App\Models\CompanyableScope;
+use App\Models\Setting;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Model;
trait CompanyableTrait
{
@@ -18,4 +21,41 @@ trait CompanyableTrait
{
static::addGlobalScope(new CompanyableScope);
}
+
+ /**
+ * Whether this item may be checked out to the given target under FMCS rules.
+ *
+ * Returns true when:
+ * - FMCS is disabled, OR
+ * - this item has no company (uncompanied items are unrestricted), OR
+ * - target is a User whose company pivot includes this item's company, OR
+ * - target has no company and null_company_is_floater is enabled, OR
+ * - target's company_id exactly matches this item's company_id.
+ */
+ public function canCheckoutTo(Model $target): bool
+ {
+ $settings = Setting::getSettings();
+
+ if (! $settings->full_multiple_companies_support) {
+ return true;
+ }
+
+ if (! $this->company_id) {
+ if (is_null($target->company_id)) {
+ return true;
+ }
+
+ return (bool) $settings->null_company_is_floater;
+ }
+
+ if ($target instanceof User) {
+ return $target->canReceiveFromCompany((int) $this->company_id);
+ }
+
+ if (is_null($target->company_id)) {
+ return (bool) $settings->null_company_is_floater;
+ }
+
+ return (int) $target->company_id === (int) $this->company_id;
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 61a4714369..e43f45cc23 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -634,22 +634,22 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
*/
public function canReceiveFromCompany(int $companyId): bool
{
+ // Items with no company association are unrestricted — anyone can receive them.
+ if (! $companyId) {
+ return true;
+ }
+
// Query the pivot directly to avoid the Company model's FMCS global scope,
// which would restrict results to the current actor's visible companies.
$userCompanyIds = DB::table('company_user')
->where('user_id', $this->id)
->pluck('company_id');
- if ($userCompanyIds->contains($companyId)) {
- return true;
- }
-
- // User has no company associations — don't enforce.
if ($userCompanyIds->isEmpty()) {
- return true;
+ return (bool) Setting::getSettings()->null_company_is_floater;
}
- return false;
+ return $userCompanyIds->contains($companyId);
}
/**
diff --git a/app/Providers/ValidationServiceProvider.php b/app/Providers/ValidationServiceProvider.php
index 50d8082af9..26befdc8fa 100644
--- a/app/Providers/ValidationServiceProvider.php
+++ b/app/Providers/ValidationServiceProvider.php
@@ -349,6 +349,20 @@ class ValidationServiceProvider extends ServiceProvider
return in_array($value, $options);
});
+ Validator::replacer('fmcs_location', function ($message, $attribute, $rule, $parameters, $validator) {
+ $locationId = $validator->getData()[$attribute] ?? null;
+ $location = $locationId ? Location::find($locationId) : null;
+
+ return str_replace(
+ [':location', ':location_company'],
+ [
+ $location?->name ?? '?',
+ $location?->company?->name ?? trans('general.unassigned'),
+ ],
+ $message
+ );
+ });
+
// Validates that the company of the validated object matches the company of the location in case of scoped locations
Validator::extend('fmcs_location', function ($attribute, $value, $parameters, $validator) {
$settings = Setting::getSettings();
diff --git a/database/migrations/2026_06_10_000001_add_null_company_is_floater_to_settings.php b/database/migrations/2026_06_10_000001_add_null_company_is_floater_to_settings.php
new file mode 100644
index 0000000000..a106020236
--- /dev/null
+++ b/database/migrations/2026_06_10_000001_add_null_company_is_floater_to_settings.php
@@ -0,0 +1,22 @@
+boolean('null_company_is_floater')->default(false)->after('scope_locations_fmcs');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('settings', function (Blueprint $table) {
+ $table->dropColumn('null_company_is_floater');
+ });
+ }
+};
diff --git a/resources/lang/en-US/admin/settings/general.php b/resources/lang/en-US/admin/settings/general.php
index d0db5f1c25..54e76cca54 100644
--- a/resources/lang/en-US/admin/settings/general.php
+++ b/resources/lang/en-US/admin/settings/general.php
@@ -180,6 +180,8 @@ return [
'scope_locations_fmcs_check_button' => 'Check Compatibility',
'scope_locations_fmcs_test_needed' => 'Please Check Compatibility to enable this',
'scope_locations_fmcs_support_disabled_text' => 'This option is disabled because you have conflicting locations set for :count or more items.',
+ 'null_company_is_floater_text' => 'Treat items and users without company associations as floaters',
+ 'null_company_is_floater_help_text' => 'When disabled, items can only be checked out to targets without a company association if the item also has no company - null is treated as its own pseudo-company. When enabled, items from any company can be checked out to targets with no company assignment.',
'show_in_model_list' => 'Show in Model Dropdowns',
'optional' => 'optional',
'per_page' => 'Results Per Page',
diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php
index 38714e6af2..134698629d 100644
--- a/resources/lang/en-US/general.php
+++ b/resources/lang/en-US/general.php
@@ -587,6 +587,9 @@ return [
'error_user_company' => 'Checkout target company and asset company do not match',
'error_user_company_multiple' => 'One or more of the checkout target company and asset company do not match',
'error_user_company_accept_view' => 'An Asset assigned to you belongs to a different company so you can\'t accept nor deny it, please check with your manager',
+ 'error_checkout_company_mismatch' => ':item cannot be checked out to :target — :item belongs to :item_company',
+ 'error_location_scoped_items' => 'This location cannot be saved. :item_type ":item_name" (Company: :item_company) does not match this location\'s company.',
+ 'error_location_parent_company' => 'Parent location ":parent" belongs to :parent_company, but this location belongs to :location_company.',
'error_assets_already_checked_out' => 'One or more of the assets are already checked out',
'assigned_assets_removed' => 'The following were removed from the selected assets because they are already checked out',
'unassigned_assets_removed' => 'The following were removed from the selected assets because they are not currently checked out',
diff --git a/resources/lang/en-US/validation.php b/resources/lang/en-US/validation.php
index eccd1abb08..7ae4cb26d3 100644
--- a/resources/lang/en-US/validation.php
+++ b/resources/lang/en-US/validation.php
@@ -174,7 +174,7 @@ return [
'ulid' => 'The :attribute field must be a valid ULID.',
'uuid' => 'The :attribute field must be a valid UUID.',
'valid_css_color' => 'The :attribute field must be a valid CSS color (hex, rgb, rgba, hsl, or hsla).',
- 'fmcs_location' => 'Full multiple company support and location scoping is enabled in the Admin Settings, and the selected location and selected company are not compatible.',
+ 'fmcs_location' => 'Location ":location" belongs to :location_company, which does not match the selected company.',
'is_unique_across_company_and_location' => 'The :attribute must be unique within the selected company and location.',
/*
diff --git a/resources/views/settings/general.blade.php b/resources/views/settings/general.blade.php
index cd84969794..d807b521be 100644
--- a/resources/views/settings/general.blade.php
+++ b/resources/views/settings/general.blade.php
@@ -61,6 +61,21 @@
+
+
+
+