Merge pull request #19167 from grokability/fmcs-scope-check-updates-for-multiple-companies
FMCS/Console: Fixed #19166 scope check updates for multiple companies, adds floater
This commit is contained in:
+30
-7
@@ -14,6 +14,7 @@ use App\Models\License;
|
|||||||
use App\Models\Location;
|
use App\Models\Location;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Statuslabel;
|
use App\Models\Statuslabel;
|
||||||
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Encryption\DecryptException;
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -1700,6 +1701,8 @@ class Helper
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$floater = (bool) Setting::getSettings()->null_company_is_floater;
|
||||||
|
|
||||||
foreach ($locations as $location) {
|
foreach ($locations as $location) {
|
||||||
// in case of an update of a single location, use the newly requested company_id
|
// in case of an update of a single location, use the newly requested company_id
|
||||||
if ($new_company_id) {
|
if ($new_company_id) {
|
||||||
@@ -1745,20 +1748,40 @@ class Helper
|
|||||||
|
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($items as $item) {
|
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[] = [
|
$mismatched[] = [
|
||||||
class_basename(get_class($item)),
|
class_basename(get_class($item)),
|
||||||
$item->id,
|
$item->id,
|
||||||
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
|
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
|
||||||
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
|
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
|
||||||
$item->company_id ?? null,
|
$itemCompanyIds,
|
||||||
$item->company->name ?? null,
|
$itemCompanyNames,
|
||||||
// $item->defaultLoc->id ?? null,
|
|
||||||
// $item->defaultLoc->name ?? null,
|
|
||||||
// $item->defaultLoc->company->id ?? null,
|
|
||||||
// $item->defaultLoc->company->name ?? null,
|
|
||||||
$item->location->name ?? null,
|
$item->location->name ?? null,
|
||||||
$item->location->company->name ?? null,
|
$item->location->company->name ?? null,
|
||||||
$location_company ?? null,
|
$location_company ?? null,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use App\Http\Traits\CheckInOutTrait;
|
|||||||
use App\Models\Accessory;
|
use App\Models\Accessory;
|
||||||
use App\Models\AccessoryCheckout;
|
use App\Models\AccessoryCheckout;
|
||||||
use App\Models\CheckoutAcceptance;
|
use App\Models\CheckoutAcceptance;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
@@ -67,13 +66,18 @@ class AccessoryCheckoutController extends Controller
|
|||||||
$target = $this->determineCheckoutTarget();
|
$target = $this->determineCheckoutTarget();
|
||||||
session()->put(['checkout_to_type' => $target]);
|
session()->put(['checkout_to_type' => $target]);
|
||||||
|
|
||||||
if (
|
if (! $accessory->canCheckoutTo($target)) {
|
||||||
Setting::getSettings()->full_multiple_companies_support == '1'
|
$targetType = match (class_basename($target)) {
|
||||||
&& $accessory->company_id
|
'User' => trans('general.user'),
|
||||||
&& $target instanceof User
|
'Location' => trans('general.location'),
|
||||||
&& ! $target->canReceiveFromCompany($accessory->company_id)
|
default => trans('general.asset'),
|
||||||
) {
|
};
|
||||||
return redirect()->back()->with('error', trans('general.error_user_company'));
|
|
||||||
|
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);
|
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||||
|
|||||||
@@ -913,21 +913,7 @@ class AssetsController extends Controller
|
|||||||
|
|
||||||
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
|
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
|
||||||
{
|
{
|
||||||
if (Setting::getSettings()->full_multiple_companies_support != '1' || is_null($asset->company_id)) {
|
if (! $asset->canCheckoutTo($target)) {
|
||||||
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) {
|
|
||||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,12 +211,19 @@ class LocationsController extends Controller
|
|||||||
$location->fill($request->all());
|
$location->fill($request->all());
|
||||||
$location = $request->handleImages($location);
|
$location = $request->handleImages($location);
|
||||||
|
|
||||||
// Only scope location if the setting is enabled
|
|
||||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
$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);
|
$location = $request->handleImages($location);
|
||||||
|
|
||||||
if ($request->filled('company_id')) {
|
if ($request->filled('company_id')) {
|
||||||
// Only scope location if the setting is enabled
|
|
||||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||||
// check if there are related objects with different company
|
// check if there are related objects with different company
|
||||||
if (Helper::test_locations_fmcs(false, $id, $location->company_id)) {
|
if ($mismatched = Helper::test_locations_fmcs(false, $id, $location->company_id)) {
|
||||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'error scoped locations'));
|
$first = $mismatched[0];
|
||||||
}
|
|
||||||
// check if parent is set and has a different company
|
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_scoped_items', [
|
||||||
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
|
'item_type' => trans('general.'.strtolower($first[0])),
|
||||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent'));
|
'item_name' => $first[2],
|
||||||
|
'item_company' => $first[5] ?? trans('general.unassigned'),
|
||||||
|
])));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$location->company_id = $request->input('company_id');
|
$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()) {
|
if ($location->isValid()) {
|
||||||
|
|
||||||
$location->save();
|
$location->save();
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ class UsersController extends Controller
|
|||||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
||||||
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
|
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
|
||||||
if (! empty($companyIds)) {
|
if (! empty($companyIds)) {
|
||||||
$users->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
|
$users = Company::scopeUsersByCompanyIds($users, $companyIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use App\Http\Requests\AssetCheckoutRequest;
|
|||||||
use App\Http\Traits\CheckInOutTrait;
|
use App\Http\Traits\CheckInOutTrait;
|
||||||
use App\Models\Asset;
|
use App\Models\Asset;
|
||||||
use App\Models\CheckoutAcceptance;
|
use App\Models\CheckoutAcceptance;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
@@ -119,18 +118,18 @@ class AssetCheckoutController extends Controller
|
|||||||
// Add any custom fields that should be included in the checkout
|
// Add any custom fields that should be included in the checkout
|
||||||
$asset->customFieldsForCheckinCheckout('display_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.
|
return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_checkout_company_mismatch', [
|
||||||
// For users with multiple companies, check all their associated companies via the pivot.
|
'item' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||||
if ($settings->full_multiple_companies_support && ! is_null($asset->company_id)) {
|
'item_company' => $asset->company?->name ?? trans('general.unassigned'),
|
||||||
$mismatch = $target instanceof User
|
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
|
||||||
? ! $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'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->put([
|
session()->put([
|
||||||
|
|||||||
@@ -689,17 +689,17 @@ class BulkAssetsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prevent checking out assets across companies if FMCS enabled.
|
// 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) {
|
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||||
$company_ids = $assets->pluck('company_id')->filter()->unique();
|
$company_ids = $assets->pluck('company_id')->filter()->unique();
|
||||||
|
|
||||||
if ($company_ids->isNotEmpty()) {
|
if ($company_ids->isNotEmpty()) {
|
||||||
$assetCompanyId = (int) $company_ids->first();
|
if ($company_ids->count() > 1) {
|
||||||
|
// Selected assets span multiple companies; bulk checkout can't satisfy all of them.
|
||||||
$mismatch = $company_ids->count() > 1
|
$mismatch = true;
|
||||||
|| ($target instanceof User
|
} else {
|
||||||
? ! $target->canReceiveFromCompany($assetCompanyId)
|
// All assets share the same company; let the model enforce the checkout rules.
|
||||||
: (! is_null($target->company_id) && (int) $target->company_id !== $assetCompanyId));
|
$mismatch = ! $assets->first()->canCheckoutTo($target);
|
||||||
|
}
|
||||||
|
|
||||||
if ($mismatch) {
|
if ($mismatch) {
|
||||||
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use App\Helpers\Helper;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Asset;
|
use App\Models\Asset;
|
||||||
use App\Models\Component;
|
use App\Models\Component;
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -104,8 +103,12 @@ class ComponentCheckoutController extends Controller
|
|||||||
// Check if the asset exists
|
// Check if the asset exists
|
||||||
$asset = Asset::find($request->input('asset_id'));
|
$asset = Asset::find($request->input('asset_id'));
|
||||||
|
|
||||||
if ((Setting::getSettings()->full_multiple_companies_support) && $component->company_id !== $asset->company_id) {
|
if (! $component->canCheckoutTo($asset)) {
|
||||||
return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_user_company'));
|
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');
|
$component->checkout_qty = $request->input('assigned_qty');
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use App\Helpers\Helper;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\CheckoutAcceptance;
|
use App\Models\CheckoutAcceptance;
|
||||||
use App\Models\Consumable;
|
use App\Models\Consumable;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Contracts\View\View;
|
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();
|
return redirect()->route('consumables.checkout.show', $consumable)->with('error', trans('admin/consumables/message.checkout.user_does_not_exist'))->withInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (! $consumable->canCheckoutTo($user)) {
|
||||||
Setting::getSettings()->full_multiple_companies_support == '1'
|
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
|
||||||
&& $consumable->company_id
|
'item' => trans('general.consumable').' "'.$consumable->name.'"',
|
||||||
&& ! $user->canReceiveFromCompany($consumable->company_id)
|
'item_company' => $consumable->company?->name ?? trans('general.unassigned'),
|
||||||
) {
|
'target' => trans('general.user').' "'.$user->username.'"',
|
||||||
return redirect()->back()->with('error', trans('general.error_user_company'));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the consumable data
|
// Update the consumable data
|
||||||
|
|||||||
@@ -99,13 +99,21 @@ class LicenseCheckoutController extends Controller
|
|||||||
if (Setting::getSettings()->full_multiple_companies_support == '1') {
|
if (Setting::getSettings()->full_multiple_companies_support == '1') {
|
||||||
if ($request->filled('asset_id')) {
|
if ($request->filled('asset_id')) {
|
||||||
$fmcsTarget = Asset::find($request->input('asset_id'));
|
$fmcsTarget = Asset::find($request->input('asset_id'));
|
||||||
if ($fmcsTarget && $license->company_id && $license->company_id !== $fmcsTarget->company_id) {
|
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
|
||||||
return redirect()->route('licenses.index')->with('error', trans('general.error_user_company'));
|
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')) {
|
} elseif ($request->filled('assigned_to')) {
|
||||||
$fmcsTarget = User::find($request->input('assigned_to'));
|
$fmcsTarget = User::find($request->input('assigned_to'));
|
||||||
if ($fmcsTarget && $license->company_id && ! $fmcsTarget->canReceiveFromCompany($license->company_id)) {
|
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
|
||||||
return redirect()->route('licenses.index')->with('error', trans('general.error_user_company'));
|
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.'"',
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,19 +89,24 @@ class LocationsController extends Controller
|
|||||||
$location->fax = request('fax');
|
$location->fax = request('fax');
|
||||||
$location->tag_color = $request->input('tag_color');
|
$location->tag_color = $request->input('tag_color');
|
||||||
$location->notes = $request->input('notes');
|
$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) {
|
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
$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 {
|
} else {
|
||||||
$location->company_id = $request->input('company_id');
|
$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')) {
|
if ($request->has('use_cloned_image')) {
|
||||||
$cloned_model_img = Location::select('image')->find($request->input('clone_image_from_id'));
|
$cloned_model_img = Location::select('image')->find($request->input('clone_image_from_id'));
|
||||||
if ($cloned_model_img) {
|
if ($cloned_model_img) {
|
||||||
@@ -171,21 +176,34 @@ class LocationsController extends Controller
|
|||||||
$location->tag_color = $request->input('tag_color');
|
$location->tag_color = $request->input('tag_color');
|
||||||
$location->notes = $request->input('notes');
|
$location->notes = $request->input('notes');
|
||||||
|
|
||||||
// Only scope the location if the setting is enabled
|
|
||||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||||
// check if there are related objects with different company
|
// check if there are related objects with different company
|
||||||
if (Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
|
if ($mismatched = Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
|
||||||
return redirect()->back()->withInput()->withInput()->with('error', 'error scoped locations');
|
$first = $mismatched[0];
|
||||||
}
|
|
||||||
// check if parent is set and has a different company
|
return redirect()->back()->withInput()->with('error', trans('general.error_location_scoped_items', [
|
||||||
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
|
'item_type' => trans('general.'.strtolower($first[0])),
|
||||||
return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent');
|
'item_name' => $first[2],
|
||||||
|
'item_company' => $first[5] ?? trans('general.unassigned'),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$location->company_id = $request->input('company_id');
|
$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);
|
$location = $request->handleImages($location);
|
||||||
|
|
||||||
if ($location->save()) {
|
if ($location->save()) {
|
||||||
|
|||||||
@@ -93,10 +93,12 @@ class SettingsController extends Controller
|
|||||||
$old_locations_fmcs = $setting->scope_locations_fmcs;
|
$old_locations_fmcs = $setting->scope_locations_fmcs;
|
||||||
$setting->full_multiple_companies_support = $request->input('full_multiple_companies_support', '0');
|
$setting->full_multiple_companies_support = $request->input('full_multiple_companies_support', '0');
|
||||||
$setting->scope_locations_fmcs = $request->input('scope_locations_fmcs', '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) {
|
if (! $setting->full_multiple_companies_support) {
|
||||||
$setting->scope_locations_fmcs = '0';
|
$setting->scope_locations_fmcs = '0';
|
||||||
|
$setting->null_company_is_floater = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// check for inconsistencies when activating scoped locations
|
// check for inconsistencies when activating scoped locations
|
||||||
|
|||||||
@@ -214,16 +214,25 @@ class AssetImporter extends ItemImporter
|
|||||||
// -- the class that needs to use it (command importer or GUI importer inside the project).
|
// -- the class that needs to use it (command importer or GUI importer inside the project).
|
||||||
if (isset($target) && ($target !== false)) {
|
if (isset($target) && ($target !== false)) {
|
||||||
$asset = $asset->fresh();
|
$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 (! $asset->canCheckoutTo($target)) {
|
||||||
if (! $alreadyCheckedOutToTarget) {
|
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||||
if (! is_null($asset->assigned_to)) {
|
'item' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||||
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
|
'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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,13 +59,21 @@ class ComponentImporter extends ItemImporter
|
|||||||
|
|
||||||
// If we have an asset tag, checkout to that asset.
|
// 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())) {
|
if (isset($this->item['asset_tag']) && ($asset = Asset::where('asset_tag', $this->item['asset_tag'])->first())) {
|
||||||
$component->assets()->attach($component->id, [
|
if (! $component->canCheckoutTo($asset)) {
|
||||||
'component_id' => $component->id,
|
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||||
'created_by' => auth()->id(),
|
'item' => trans('general.component').' "'.$component->name.'"',
|
||||||
'created_at' => date('Y-m-d H:i:s'),
|
'item_company' => $component->company?->name ?? trans('general.unassigned'),
|
||||||
'assigned_qty' => 1, // Only assign the first one to the asset
|
'target' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||||
'asset_id' => $asset->id,
|
]));
|
||||||
]);
|
} 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;
|
return;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class ItemImporter extends Importer
|
|||||||
$this->item['qty'] = $this->findCsvMatch($row, 'quantity');
|
$this->item['qty'] = $this->findCsvMatch($row, 'quantity');
|
||||||
$this->item['requestable'] = $this->findCsvMatch($row, 'requestable');
|
$this->item['requestable'] = $this->findCsvMatch($row, 'requestable');
|
||||||
$this->item['created_by'] = auth()->id();
|
$this->item['created_by'] = auth()->id();
|
||||||
|
$this->item['asset_tag'] = $this->findCsvMatch($row, 'asset_tag');
|
||||||
$this->item['serial'] = $this->findCsvMatch($row, 'serial');
|
$this->item['serial'] = $this->findCsvMatch($row, 'serial');
|
||||||
$this->item['item_no'] = trim($this->findCsvMatch($row, 'item_no'));
|
$this->item['item_no'] = trim($this->findCsvMatch($row, 'item_no'));
|
||||||
|
|
||||||
|
|||||||
@@ -106,16 +106,32 @@ class LicenseImporter extends ItemImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($checkout_target) {
|
if ($checkout_target) {
|
||||||
$targetLicense->assigned_to = $checkout_target->id;
|
if (! $license->canCheckoutTo($checkout_target)) {
|
||||||
$targetLicense->created_by = auth()->id();
|
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||||
if ($asset) {
|
'item' => trans('general.license').' "'.$license->name.'"',
|
||||||
$targetLicense->asset_id = $asset->id;
|
'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) {
|
} elseif ($asset) {
|
||||||
$targetLicense->created_by = auth()->id();
|
if (! $license->canCheckoutTo($asset)) {
|
||||||
$targetLicense->asset_id = $asset->id;
|
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||||
$targetLicense->save();
|
'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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -398,10 +398,17 @@ final class Company extends SnipeModel
|
|||||||
return $query->whereIn('companies.id', $companyIds);
|
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,
|
// 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.
|
// since a user may belong to multiple companies and company_id alone is insufficient.
|
||||||
if ($query->getModel()->getTable() == 'users') {
|
if ($query->getModel()->getTable() == 'users') {
|
||||||
if (empty($companyIds)) {
|
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
|
// No pivot memberships: mirror old null-company behavior — show only users
|
||||||
// who are also not in any company via the pivot.
|
// who are also not in any company via the pivot.
|
||||||
return $query->whereNotIn('users.id', function ($sub) {
|
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) {
|
return $query->whereIn('users.id', function ($sub) use ($companyIds) {
|
||||||
$sub->select('user_id')->from('company_user')->whereIn('company_id', $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().'.';
|
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
|
||||||
|
|
||||||
if (empty($companyIds)) {
|
if (empty($companyIds)) {
|
||||||
|
// Floater: actor has no company and is unrestricted — see everything.
|
||||||
|
if ($floater) {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
return $query->whereNull($table.$column);
|
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);
|
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).
|
* I legit do not know what this method does, but we can't remove it (yet).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Models\Traits;
|
namespace App\Models\Traits;
|
||||||
|
|
||||||
use App\Models\CompanyableScope;
|
use App\Models\CompanyableScope;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
trait CompanyableTrait
|
trait CompanyableTrait
|
||||||
{
|
{
|
||||||
@@ -18,4 +21,41 @@ trait CompanyableTrait
|
|||||||
{
|
{
|
||||||
static::addGlobalScope(new CompanyableScope);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -634,22 +634,22 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
|||||||
*/
|
*/
|
||||||
public function canReceiveFromCompany(int $companyId): bool
|
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,
|
// Query the pivot directly to avoid the Company model's FMCS global scope,
|
||||||
// which would restrict results to the current actor's visible companies.
|
// which would restrict results to the current actor's visible companies.
|
||||||
$userCompanyIds = DB::table('company_user')
|
$userCompanyIds = DB::table('company_user')
|
||||||
->where('user_id', $this->id)
|
->where('user_id', $this->id)
|
||||||
->pluck('company_id');
|
->pluck('company_id');
|
||||||
|
|
||||||
if ($userCompanyIds->contains($companyId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User has no company associations — don't enforce.
|
|
||||||
if ($userCompanyIds->isEmpty()) {
|
if ($userCompanyIds->isEmpty()) {
|
||||||
return true;
|
return (bool) Setting::getSettings()->null_company_is_floater;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return $userCompanyIds->contains($companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -349,6 +349,20 @@ class ValidationServiceProvider extends ServiceProvider
|
|||||||
return in_array($value, $options);
|
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
|
// 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) {
|
Validator::extend('fmcs_location', function ($attribute, $value, $parameters, $validator) {
|
||||||
$settings = Setting::getSettings();
|
$settings = Setting::getSettings();
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('settings', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -180,6 +180,8 @@ return [
|
|||||||
'scope_locations_fmcs_check_button' => 'Check Compatibility',
|
'scope_locations_fmcs_check_button' => 'Check Compatibility',
|
||||||
'scope_locations_fmcs_test_needed' => 'Please Check Compatibility to enable this',
|
'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.',
|
'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',
|
'show_in_model_list' => 'Show in Model Dropdowns',
|
||||||
'optional' => 'optional',
|
'optional' => 'optional',
|
||||||
'per_page' => 'Results Per Page',
|
'per_page' => 'Results Per Page',
|
||||||
|
|||||||
@@ -587,6 +587,9 @@ return [
|
|||||||
'error_user_company' => 'Checkout target company and asset company do not match',
|
'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_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_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',
|
'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',
|
'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',
|
'unassigned_assets_removed' => 'The following were removed from the selected assets because they are not currently checked out',
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ return [
|
|||||||
'ulid' => 'The :attribute field must be a valid ULID.',
|
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||||
'uuid' => 'The :attribute field must be a valid UUID.',
|
'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).',
|
'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.',
|
'is_unique_across_company_and_location' => 'The :attribute must be unique within the selected company and location.',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -61,6 +61,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- /.form-group -->
|
<!-- /.form-group -->
|
||||||
|
|
||||||
|
<!-- Null Company Is Floater -->
|
||||||
|
<div class="form-group {{ $errors->has('null_company_is_floater') ? 'error' : '' }}">
|
||||||
|
<div class="col-md-8 col-md-offset-3">
|
||||||
|
<label class="form-control">
|
||||||
|
<input type="checkbox" name="null_company_is_floater" value="1" @checked(old('null_company_is_floater', $setting->null_company_is_floater)) aria-label="null_company_is_floater" @disabled(! $setting->full_multiple_companies_support) />
|
||||||
|
{{ trans('admin/settings/general.null_company_is_floater_text') }}
|
||||||
|
</label>
|
||||||
|
{!! $errors->first('null_company_is_floater', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
|
||||||
|
<p class="help-block">
|
||||||
|
{{ trans('admin/settings/general.null_company_is_floater_help_text') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /.form-group -->
|
||||||
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class CheckoutAccessoryTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_checkout_to_user_succeeds_when_accessory_has_no_company_with_fmcs_enabled()
|
public function test_checkout_to_user_is_blocked_when_accessory_has_no_company_with_fmcs_enabled_without_floater()
|
||||||
{
|
{
|
||||||
$accessory = Accessory::factory()->create(['qty' => 5, 'company_id' => null]);
|
$accessory = Accessory::factory()->create(['qty' => 5, 'company_id' => null]);
|
||||||
[$companyA] = Company::factory()->count(1)->create();
|
[$companyA] = Company::factory()->count(1)->create();
|
||||||
@@ -71,6 +71,33 @@ class CheckoutAccessoryTest extends TestCase
|
|||||||
$user->companies()->sync([$companyA->id]);
|
$user->companies()->sync([$companyA->id]);
|
||||||
|
|
||||||
$this->settings->enableMultipleFullCompanySupport();
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
$this->settings->disableFloaterMode();
|
||||||
|
|
||||||
|
$actor = User::factory()->superuser()->create();
|
||||||
|
|
||||||
|
$this->actingAs($actor)
|
||||||
|
->post(route('accessories.checkout.store', $accessory), [
|
||||||
|
'checkout_to_type' => 'user',
|
||||||
|
'assigned_user' => $user->id,
|
||||||
|
'checkout_qty' => 1,
|
||||||
|
'redirect_option' => 'index',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('accessories_checkout', [
|
||||||
|
'accessory_id' => $accessory->id,
|
||||||
|
'assigned_to' => $user->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_checkout_to_user_succeeds_when_accessory_has_no_company_with_floater_enabled()
|
||||||
|
{
|
||||||
|
$accessory = Accessory::factory()->create(['qty' => 5, 'company_id' => null]);
|
||||||
|
[$companyA] = Company::factory()->count(1)->create();
|
||||||
|
$user = User::factory()->for($companyA)->create();
|
||||||
|
$user->companies()->sync([$companyA->id]);
|
||||||
|
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
$actor = User::factory()->superuser()->create();
|
$actor = User::factory()->superuser()->create();
|
||||||
|
|
||||||
@@ -114,13 +141,63 @@ class CheckoutAccessoryTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_checkout_to_null_company_user_blocked_in_strict_mode()
|
||||||
|
{
|
||||||
|
[$companyA] = Company::factory()->count(1)->create();
|
||||||
|
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
|
||||||
|
$nullCompanyUser = User::factory()->create(['company_id' => null]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$actor = User::factory()->superuser()->create();
|
||||||
|
|
||||||
|
$this->actingAs($actor)
|
||||||
|
->post(route('accessories.checkout.store', $accessory), [
|
||||||
|
'checkout_to_type' => 'user',
|
||||||
|
'assigned_user' => $nullCompanyUser->id,
|
||||||
|
'checkout_qty' => 1,
|
||||||
|
'redirect_option' => 'index',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('accessories_checkout', [
|
||||||
|
'accessory_id' => $accessory->id,
|
||||||
|
'assigned_to' => $nullCompanyUser->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_checkout_to_null_company_user_succeeds_in_floater_mode()
|
||||||
|
{
|
||||||
|
[$companyA] = Company::factory()->count(1)->create();
|
||||||
|
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
|
||||||
|
$nullCompanyUser = User::factory()->create(['company_id' => null]);
|
||||||
|
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$actor = User::factory()->superuser()->create();
|
||||||
|
|
||||||
|
$this->actingAs($actor)
|
||||||
|
->post(route('accessories.checkout.store', $accessory), [
|
||||||
|
'checkout_to_type' => 'user',
|
||||||
|
'assigned_user' => $nullCompanyUser->id,
|
||||||
|
'checkout_qty' => 1,
|
||||||
|
'redirect_option' => 'index',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('accessories_checkout', [
|
||||||
|
'accessory_id' => $accessory->id,
|
||||||
|
'assigned_to' => $nullCompanyUser->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_checkout_to_location_does_not_throw_when_fmcs_enabled()
|
public function test_checkout_to_location_does_not_throw_when_fmcs_enabled()
|
||||||
{
|
{
|
||||||
[$companyA] = Company::factory()->count(1)->create();
|
[$companyA] = Company::factory()->count(1)->create();
|
||||||
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
|
$accessory = Accessory::factory()->for($companyA)->create(['qty' => 5]);
|
||||||
$location = Location::factory()->create();
|
$location = Location::factory()->create();
|
||||||
|
|
||||||
$this->settings->enableMultipleFullCompanySupport();
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
$actor = User::factory()->superuser()->create();
|
$actor = User::factory()->superuser()->create();
|
||||||
|
|
||||||
|
|||||||
@@ -335,8 +335,8 @@ class AssetCheckoutTest extends TestCase
|
|||||||
|
|
||||||
public function test_asset_can_be_checked_out_to_user_with_no_company_when_fmcs_enabled()
|
public function test_asset_can_be_checked_out_to_user_with_no_company_when_fmcs_enabled()
|
||||||
{
|
{
|
||||||
// Users with no company associations should not be blocked — they're unrestricted.
|
// In floater mode, users with no company associations can receive items from any company.
|
||||||
$this->settings->enableMultipleFullCompanySupport();
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
$company = Company::factory()->create();
|
$company = Company::factory()->create();
|
||||||
// Actor is in the same company as the asset.
|
// Actor is in the same company as the asset.
|
||||||
@@ -408,4 +408,46 @@ class AssetCheckoutTest extends TestCase
|
|||||||
|
|
||||||
$this->assertTrue((bool) $asset->fresh()->requestable);
|
$this->assertTrue((bool) $asset->fresh()->requestable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_null_company_asset_cannot_be_checked_out_to_companied_user_when_fmcs_enabled_without_floater()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
$this->settings->disableFloaterMode();
|
||||||
|
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$actor = User::factory()->superuser()->create();
|
||||||
|
$nullCompanyAsset = Asset::factory()->create(['company_id' => null]);
|
||||||
|
$companiedUser = User::factory()->for($company)->create();
|
||||||
|
|
||||||
|
$this->actingAsForApi($actor)
|
||||||
|
->postJson(route('api.asset.checkout', $nullCompanyAsset), [
|
||||||
|
'checkout_to_type' => 'user',
|
||||||
|
'assigned_user' => $companiedUser->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('error')
|
||||||
|
->assertMessagesAre(trans('general.error_user_company'));
|
||||||
|
|
||||||
|
$this->assertNull($nullCompanyAsset->fresh()->assigned_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_null_company_asset_can_be_checked_out_to_companied_user_when_floater_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$actor = User::factory()->superuser()->create();
|
||||||
|
$nullCompanyAsset = Asset::factory()->create(['company_id' => null]);
|
||||||
|
$companiedUser = User::factory()->for($company)->create();
|
||||||
|
|
||||||
|
$this->actingAsForApi($actor)
|
||||||
|
->postJson(route('api.asset.checkout', $nullCompanyAsset), [
|
||||||
|
'checkout_to_type' => 'user',
|
||||||
|
'assigned_user' => $companiedUser->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('success');
|
||||||
|
|
||||||
|
$this->assertEquals($companiedUser->id, $nullCompanyAsset->fresh()->assigned_to);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Console;
|
||||||
|
|
||||||
|
use App\Helpers\Helper;
|
||||||
|
use App\Models\Asset;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\User;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TestLocationsFmcsTest extends TestCase
|
||||||
|
{
|
||||||
|
private function mismatchedIds(array $mismatched): array
|
||||||
|
{
|
||||||
|
return array_column($mismatched, 1); // column 1 is the item ID
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assetAt(Location $location, array $attrs = []): Asset
|
||||||
|
{
|
||||||
|
// Pin both location_id and rtd_location_id to prevent AssetFactory from
|
||||||
|
// generating a random rtd_location with no company that would cause false mismatches.
|
||||||
|
return Asset::factory()->create(array_merge([
|
||||||
|
'location_id' => $location->id,
|
||||||
|
'rtd_location_id' => $location->id,
|
||||||
|
], $attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_item_at_location_with_same_company_is_not_flagged()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->for($company)->create();
|
||||||
|
$asset = $this->assetAt($location, ['company_id' => $company->id]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertNotContains($asset->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_item_at_location_with_different_company_is_flagged()
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||||
|
$location = Location::factory()->for($companyA)->create();
|
||||||
|
$asset = $this->assetAt($location, ['company_id' => $companyB->id]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertContains($asset->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_null_company_item_at_company_location_is_flagged_in_strict_mode()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->for($company)->create();
|
||||||
|
$asset = $this->assetAt($location, ['company_id' => null]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertContains($asset->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_null_company_item_at_company_location_is_not_flagged_in_floater_mode()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->for($company)->create();
|
||||||
|
$asset = $this->assetAt($location, ['company_id' => null]);
|
||||||
|
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertNotContains($asset->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_company_item_at_null_company_location_is_flagged_in_strict_mode()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->create(['company_id' => null]);
|
||||||
|
$asset = $this->assetAt($location, ['company_id' => $company->id]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertContains($asset->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_company_item_at_null_company_location_is_not_flagged_in_floater_mode()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->create(['company_id' => null]);
|
||||||
|
$asset = $this->assetAt($location, ['company_id' => $company->id]);
|
||||||
|
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertNotContains($asset->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_null_company_item_at_null_company_location_is_never_flagged()
|
||||||
|
{
|
||||||
|
$location = Location::factory()->create(['company_id' => null]);
|
||||||
|
$asset = $this->assetAt($location, ['company_id' => null]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertNotContains($asset->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_at_location_with_matching_company_is_not_flagged()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->for($company)->create();
|
||||||
|
$user = User::factory()->create(['location_id' => $location->id]);
|
||||||
|
$user->companies()->sync([$company->id]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertNotContains($user->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_at_location_with_different_company_is_flagged()
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||||
|
$location = Location::factory()->for($companyA)->create();
|
||||||
|
$user = User::factory()->create(['location_id' => $location->id]);
|
||||||
|
$user->companies()->sync([$companyB->id]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertContains($user->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_null_company_user_at_company_location_is_flagged_in_strict_mode()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->for($company)->create();
|
||||||
|
$user = User::factory()->create(['company_id' => null, 'location_id' => $location->id]);
|
||||||
|
$user->companies()->sync([]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertContains($user->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_null_company_user_at_company_location_is_not_flagged_in_floater_mode()
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$location = Location::factory()->for($company)->create();
|
||||||
|
$user = User::factory()->create(['company_id' => null, 'location_id' => $location->id]);
|
||||||
|
$user->companies()->sync([]);
|
||||||
|
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true);
|
||||||
|
|
||||||
|
$this->assertNotContains($user->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_location_id_option_scopes_check_to_single_location()
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||||
|
$locationA = Location::factory()->for($companyA)->create();
|
||||||
|
$locationB = Location::factory()->for($companyB)->create();
|
||||||
|
$assetA = $this->assetAt($locationA, ['company_id' => $companyB->id]); // mismatch at A
|
||||||
|
$assetB = $this->assetAt($locationB, ['company_id' => $companyA->id]); // mismatch at B
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$result = Helper::test_locations_fmcs(true, $locationA->id);
|
||||||
|
|
||||||
|
$this->assertContains($assetA->id, $this->mismatchedIds($result));
|
||||||
|
$this->assertNotContains($assetB->id, $this->mismatchedIds($result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Importing\Api;
|
|||||||
|
|
||||||
use App\Models\Actionlog as ActionLog;
|
use App\Models\Actionlog as ActionLog;
|
||||||
use App\Models\Asset;
|
use App\Models\Asset;
|
||||||
|
use App\Models\Company;
|
||||||
use App\Models\CustomField;
|
use App\Models\CustomField;
|
||||||
use App\Models\Import;
|
use App\Models\Import;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -641,4 +642,88 @@ class ImportAssetsTest extends ImportDataTestCase implements TestsPermissionsReq
|
|||||||
|
|
||||||
$this->assertNotEquals($encryptedMacAddress, $macAddress);
|
$this->assertNotEquals($encryptedMacAddress, $macAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_asset_checkout_is_blocked_when_fmcs_companies_differ(): void
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||||
|
$user = User::factory()->for($companyB)->create();
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $companyA->name,
|
||||||
|
'assigneeUsername' => $user->username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->asset()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newAsset = Asset::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertNull($newAsset->assigned_to, 'Asset should not be checked out when item and user companies differ under FMCS');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_asset_checkout_is_allowed_when_fmcs_companies_match(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$user = User::factory()->for($company)->create();
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'assigneeUsername' => $user->username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->asset()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newAsset = Asset::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertEquals($user->id, $newAsset->assigned_to, 'Asset should be checked out when companies match under FMCS');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_asset_checkout_is_blocked_when_floater_disabled_and_user_has_no_company(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$user = User::factory()->create(['company_id' => null]);
|
||||||
|
$this->settings->enableMultipleFullCompanySupport()->disableFloaterMode();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'assigneeUsername' => $user->username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->asset()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newAsset = Asset::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertNull($newAsset->assigned_to, 'Asset should not be checked out to a no-company user when floater mode is off');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_asset_checkout_is_allowed_when_floater_enabled_and_user_has_no_company(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$user = User::factory()->create(['company_id' => null]);
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'assigneeUsername' => $user->username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->asset()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newAsset = Asset::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertEquals($user->id, $newAsset->assigned_to, 'Asset should be checked out to a no-company user when floater mode is on');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace Tests\Feature\Importing\Api;
|
namespace Tests\Feature\Importing\Api;
|
||||||
|
|
||||||
use App\Models\Actionlog as ActionLog;
|
use App\Models\Actionlog as ActionLog;
|
||||||
|
use App\Models\Asset;
|
||||||
|
use App\Models\Company;
|
||||||
use App\Models\Component;
|
use App\Models\Component;
|
||||||
use App\Models\Import;
|
use App\Models\Import;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -348,4 +350,88 @@ class ImportComponentsTest extends ImportDataTestCase implements TestsPermission
|
|||||||
$this->assertNull($newComponent->image);
|
$this->assertNull($newComponent->image);
|
||||||
$this->assertNull($newComponent->notes);
|
$this->assertNull($newComponent->notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_component_checkout_to_asset_is_blocked_when_fmcs_companies_differ(): void
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||||
|
$asset = Asset::factory()->for($companyB)->create();
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $companyA->name,
|
||||||
|
'assetTag' => $asset->asset_tag,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->component()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newComponent = Component::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertEquals(0, $newComponent->assets()->count(), 'Component should not be checked out when item and asset companies differ under FMCS');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_component_checkout_to_asset_is_allowed_when_fmcs_companies_match(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$asset = Asset::factory()->for($company)->create();
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'assetTag' => $asset->asset_tag,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->component()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newComponent = Component::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertEquals(1, $newComponent->assets()->count(), 'Component should be checked out when companies match under FMCS');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_component_checkout_to_asset_is_blocked_when_floater_disabled_and_asset_has_no_company(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$asset = Asset::factory()->create(['company_id' => null]);
|
||||||
|
$this->settings->enableMultipleFullCompanySupport()->disableFloaterMode();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'assetTag' => $asset->asset_tag,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->component()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newComponent = Component::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertEquals(0, $newComponent->assets()->count(), 'Component should not be checked out to a no-company asset when floater mode is off');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_component_checkout_to_asset_is_allowed_when_floater_enabled_and_asset_has_no_company(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$asset = Asset::factory()->create(['company_id' => null]);
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'assetTag' => $asset->asset_tag,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->component()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$newComponent = Component::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$this->assertEquals(1, $newComponent->assets()->count(), 'Component should be checked out to a no-company asset when floater mode is on');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace Tests\Feature\Importing\Api;
|
namespace Tests\Feature\Importing\Api;
|
||||||
|
|
||||||
use App\Models\Actionlog as ActivityLog;
|
use App\Models\Actionlog as ActivityLog;
|
||||||
|
use App\Models\Company;
|
||||||
use App\Models\Import;
|
use App\Models\Import;
|
||||||
use App\Models\License;
|
use App\Models\License;
|
||||||
|
use App\Models\LicenseSeat;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\WithFaker;
|
use Illuminate\Foundation\Testing\WithFaker;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -399,4 +401,96 @@ class ImportLicenseTest extends ImportDataTestCase implements TestsPermissionsRe
|
|||||||
$this->assertNull($newLicense->deprecate);
|
$this->assertNull($newLicense->deprecate);
|
||||||
$this->assertNull($newLicense->min_amt);
|
$this->assertNull($newLicense->min_amt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_license_checkout_is_blocked_when_fmcs_companies_differ(): void
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||||
|
$user = User::factory()->for($companyB)->create();
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $companyA->name,
|
||||||
|
'checkoutUsername' => $user->username,
|
||||||
|
'seats' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->license()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$license = License::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$checkedOutSeat = LicenseSeat::where('license_id', $license->id)->whereNotNull('assigned_to')->first();
|
||||||
|
$this->assertNull($checkedOutSeat, 'License seat should not be checked out when item and user companies differ under FMCS');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_license_checkout_is_allowed_when_fmcs_companies_match(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$user = User::factory()->for($company)->create();
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'checkoutUsername' => $user->username,
|
||||||
|
'seats' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->license()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$license = License::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$checkedOutSeat = LicenseSeat::where('license_id', $license->id)->where('assigned_to', $user->id)->first();
|
||||||
|
$this->assertNotNull($checkedOutSeat, 'License seat should be checked out when companies match under FMCS');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_license_checkout_is_blocked_when_floater_disabled_and_user_has_no_company(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$user = User::factory()->create(['company_id' => null]);
|
||||||
|
$this->settings->enableMultipleFullCompanySupport()->disableFloaterMode();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'checkoutUsername' => $user->username,
|
||||||
|
'seats' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->license()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$license = License::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$checkedOutSeat = LicenseSeat::where('license_id', $license->id)->whereNotNull('assigned_to')->first();
|
||||||
|
$this->assertNull($checkedOutSeat, 'License seat should not be checked out to a no-company user when floater mode is off');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function import_license_checkout_is_allowed_when_floater_enabled_and_user_has_no_company(): void
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$user = User::factory()->create(['company_id' => null]);
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$importFileBuilder = ImportFileBuilder::new([
|
||||||
|
'companyName' => $company->name,
|
||||||
|
'checkoutUsername' => $user->username,
|
||||||
|
'seats' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$import = Import::factory()->license()->create(['file_path' => $importFileBuilder->saveToImportsDirectory()]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||||
|
$this->importFileResponse(['import' => $import->id])->assertOk();
|
||||||
|
|
||||||
|
$license = License::where('serial', $importFileBuilder->firstRow()['serialNumber'])->sole();
|
||||||
|
$checkedOutSeat = LicenseSeat::where('license_id', $license->id)->where('assigned_to', $user->id)->first();
|
||||||
|
$this->assertNotNull($checkedOutSeat, 'License seat should be checked out to a no-company user when floater mode is on');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Locations\Api;
|
||||||
|
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\User;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that a location cannot be given a parent that belongs to a different
|
||||||
|
* company when FMCS is enabled, and that the check is bypassed when FMCS is off.
|
||||||
|
*
|
||||||
|
* The API update case also covers the scenario where only parent_id changes
|
||||||
|
* (company_id not included in the request), to ensure the check runs regardless.
|
||||||
|
*/
|
||||||
|
class LocationParentCompanyTest extends TestCase
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// store (create)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_cannot_create_location_with_cross_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||||
|
->postJson(route('api.locations.store'), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('error');
|
||||||
|
|
||||||
|
$this->assertFalse(Location::where('name', 'Location B')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_location_with_same_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||||
|
->postJson(route('api.locations.store'), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $acme->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('success');
|
||||||
|
|
||||||
|
$this->assertTrue(Location::where('name', 'Location B')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_location_with_cross_company_parent_when_fmcs_disabled()
|
||||||
|
{
|
||||||
|
$this->settings->disableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||||
|
->postJson(route('api.locations.store'), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('success');
|
||||||
|
|
||||||
|
$this->assertTrue(Location::where('name', 'Location B')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// update
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_cannot_update_location_to_cross_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
$location = Location::factory()->create(['name' => 'Location B', 'company_id' => $globex->id]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||||
|
->patchJson(route('api.locations.update', $location), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('error');
|
||||||
|
|
||||||
|
$this->assertNull($location->fresh()->parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cannot_update_location_parent_id_only_to_cross_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
// Ensures the check fires even when company_id is not included in the request.
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
$location = Location::factory()->create(['name' => 'Location B', 'company_id' => $globex->id]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||||
|
->patchJson(route('api.locations.update', $location), [
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('error');
|
||||||
|
|
||||||
|
$this->assertNull($location->fresh()->parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_update_location_to_same_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
$location = Location::factory()->create(['name' => 'Location B', 'company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||||
|
->patchJson(route('api.locations.update', $location), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $acme->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('success');
|
||||||
|
|
||||||
|
$this->assertEquals($parentLocation->id, $location->fresh()->parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_update_location_to_cross_company_parent_when_fmcs_disabled()
|
||||||
|
{
|
||||||
|
$this->settings->disableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
$location = Location::factory()->create(['name' => 'Location B', 'company_id' => $globex->id]);
|
||||||
|
|
||||||
|
$this->actingAsForApi(User::factory()->superuser()->create())
|
||||||
|
->patchJson(route('api.locations.update', $location), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStatusMessageIs('success');
|
||||||
|
|
||||||
|
$this->assertEquals($parentLocation->id, $location->fresh()->parent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Locations\Ui;
|
||||||
|
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\User;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that a location cannot be given a parent that belongs to a different
|
||||||
|
* company when FMCS is enabled, and that the check is bypassed when FMCS is off.
|
||||||
|
*/
|
||||||
|
class LocationParentCompanyTest extends TestCase
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// store (create)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_cannot_create_location_with_cross_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->superuser()->create())
|
||||||
|
->from(route('locations.create'))
|
||||||
|
->post(route('locations.store'), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('locations.create'))
|
||||||
|
->assertSessionHas('error');
|
||||||
|
|
||||||
|
$this->assertFalse(Location::where('name', 'Location B')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_location_with_same_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->superuser()->create())
|
||||||
|
->post(route('locations.store'), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $acme->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('locations.index'))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertTrue(Location::where('name', 'Location B')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_location_with_cross_company_parent_when_fmcs_disabled()
|
||||||
|
{
|
||||||
|
$this->settings->disableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->superuser()->create())
|
||||||
|
->post(route('locations.store'), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('locations.index'))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertTrue(Location::where('name', 'Location B')->exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// update (edit)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_cannot_update_location_to_cross_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
$location = Location::factory()->create(['name' => 'Location B', 'company_id' => $globex->id]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->superuser()->create())
|
||||||
|
->from(route('locations.edit', $location))
|
||||||
|
->put(route('locations.update', $location), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('locations.edit', $location))
|
||||||
|
->assertSessionHas('error');
|
||||||
|
|
||||||
|
$this->assertNull($location->fresh()->parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_update_location_to_same_company_parent_when_fmcs_enabled()
|
||||||
|
{
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
$location = Location::factory()->create(['name' => 'Location B', 'company_id' => $acme->id]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->superuser()->create())
|
||||||
|
->put(route('locations.update', $location), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $acme->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('locations.index'))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertEquals($parentLocation->id, $location->fresh()->parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_update_location_to_cross_company_parent_when_fmcs_disabled()
|
||||||
|
{
|
||||||
|
$this->settings->disableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$acme = Company::factory()->create(['name' => 'Acme']);
|
||||||
|
$globex = Company::factory()->create(['name' => 'Globex']);
|
||||||
|
|
||||||
|
$parentLocation = Location::factory()->create(['company_id' => $acme->id]);
|
||||||
|
$location = Location::factory()->create(['name' => 'Location B', 'company_id' => $globex->id]);
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->superuser()->create())
|
||||||
|
->put(route('locations.update', $location), [
|
||||||
|
'name' => 'Location B',
|
||||||
|
'company_id' => $globex->id,
|
||||||
|
'parent_id' => $parentLocation->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('locations.index'))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertEquals($parentLocation->id, $location->fresh()->parent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ class ComponentsImportFileBuilder extends FileBuilder
|
|||||||
protected function getDictionary(): array
|
protected function getDictionary(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'assetTag' => 'Asset Tag',
|
||||||
'category' => 'Category',
|
'category' => 'Category',
|
||||||
'companyName' => 'Company',
|
'companyName' => 'Company',
|
||||||
'itemName' => 'item Name',
|
'itemName' => 'item Name',
|
||||||
@@ -51,6 +52,7 @@ class ComponentsImportFileBuilder extends FileBuilder
|
|||||||
$faker = fake();
|
$faker = fake();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'assetTag' => '',
|
||||||
'category' => Str::random(),
|
'category' => Str::random(),
|
||||||
'companyName' => Str::random()." {$faker->companySuffix}",
|
'companyName' => Str::random()." {$faker->companySuffix}",
|
||||||
'itemName' => Str::random(),
|
'itemName' => Str::random(),
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class LicensesImportFileBuilder extends FileBuilder
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'category' => 'Category',
|
'category' => 'Category',
|
||||||
|
'checkoutEmail' => 'Email',
|
||||||
|
'checkoutFullName' => 'Full Name',
|
||||||
|
'checkoutUsername' => 'Username',
|
||||||
'companyName' => 'Company',
|
'companyName' => 'Company',
|
||||||
'expirationDate' => 'expiration date',
|
'expirationDate' => 'expiration date',
|
||||||
'isMaintained' => 'maintained',
|
'isMaintained' => 'maintained',
|
||||||
@@ -66,6 +69,9 @@ class LicensesImportFileBuilder extends FileBuilder
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'category' => Str::random(),
|
'category' => Str::random(),
|
||||||
|
'checkoutEmail' => '',
|
||||||
|
'checkoutFullName' => '',
|
||||||
|
'checkoutUsername' => '',
|
||||||
'companyName' => Str::random()." {$faker->companySuffix}",
|
'companyName' => Str::random()." {$faker->companySuffix}",
|
||||||
'expirationDate' => $faker->date,
|
'expirationDate' => $faker->date,
|
||||||
'isMaintained' => $faker->randomElement(['TRUE', 'FALSE']),
|
'isMaintained' => $faker->randomElement(['TRUE', 'FALSE']),
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ class Settings
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function enableFloaterMode(): Settings
|
||||||
|
{
|
||||||
|
return $this->update([
|
||||||
|
'full_multiple_companies_support' => 1,
|
||||||
|
'null_company_is_floater' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disableFloaterMode(): Settings
|
||||||
|
{
|
||||||
|
return $this->update(['null_company_is_floater' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
public function enableSlackWebhook(): Settings
|
public function enableSlackWebhook(): Settings
|
||||||
{
|
{
|
||||||
return $this->update([
|
return $this->update([
|
||||||
|
|||||||
@@ -149,6 +149,66 @@ class CompanyScopingTest extends TestCase
|
|||||||
$this->assertCanSee($licenseSeatB);
|
$this->assertCanSee($licenseSeatB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[DataProvider('models')]
|
||||||
|
public function test_company_user_cannot_see_null_company_items_in_strict_mode($model)
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$nullCompanyItem = $model::factory()->create(['company_id' => null]);
|
||||||
|
$companyItem = $model::factory()->for($company)->create();
|
||||||
|
$companyUser = $company->users()->save(User::factory()->make());
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$this->actingAs($companyUser);
|
||||||
|
$this->assertCannotSee($nullCompanyItem);
|
||||||
|
$this->assertCanSee($companyItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('models')]
|
||||||
|
public function test_company_user_can_see_null_company_items_in_floater_mode($model)
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$nullCompanyItem = $model::factory()->create(['company_id' => null]);
|
||||||
|
$companyItem = $model::factory()->for($company)->create();
|
||||||
|
$companyUser = $company->users()->save(User::factory()->make());
|
||||||
|
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$this->actingAs($companyUser);
|
||||||
|
$this->assertCanSee($nullCompanyItem);
|
||||||
|
$this->assertCanSee($companyItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('models')]
|
||||||
|
public function test_null_company_user_cannot_see_company_items_in_strict_mode($model)
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$nullCompanyItem = $model::factory()->create(['company_id' => null]);
|
||||||
|
$companyItem = $model::factory()->for($company)->create();
|
||||||
|
$nullCompanyUser = User::factory()->create(['company_id' => null]);
|
||||||
|
|
||||||
|
$this->settings->enableMultipleFullCompanySupport();
|
||||||
|
|
||||||
|
$this->actingAs($nullCompanyUser);
|
||||||
|
$this->assertCanSee($nullCompanyItem);
|
||||||
|
$this->assertCannotSee($companyItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('models')]
|
||||||
|
public function test_null_company_user_can_see_all_items_in_floater_mode($model)
|
||||||
|
{
|
||||||
|
$company = Company::factory()->create();
|
||||||
|
$nullCompanyItem = $model::factory()->create(['company_id' => null]);
|
||||||
|
$companyItem = $model::factory()->for($company)->create();
|
||||||
|
$nullCompanyUser = User::factory()->create(['company_id' => null]);
|
||||||
|
|
||||||
|
$this->settings->enableFloaterMode();
|
||||||
|
|
||||||
|
$this->actingAs($nullCompanyUser);
|
||||||
|
$this->assertCanSee($nullCompanyItem);
|
||||||
|
$this->assertCanSee($companyItem);
|
||||||
|
}
|
||||||
|
|
||||||
private function assertCanSee(Model $model)
|
private function assertCanSee(Model $model)
|
||||||
{
|
{
|
||||||
$this->assertTrue(
|
$this->assertTrue(
|
||||||
|
|||||||
Reference in New Issue
Block a user