Locations

This commit is contained in:
David Bomba 2025-02-21 10:33:41 +11:00
parent d4662c808e
commit 0265bc1603
24 changed files with 1488 additions and 89 deletions

View File

@ -0,0 +1,30 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Factory;
use App\Models\Location;
class LocationFactory
{
public static function create(int $company_id, int $user_id): Location
{
$location = new Location();
$location->company_id = $company_id;
$location->user_id = $user_id;
$location->name = '';
$location->country_id = null;
$location->is_deleted = false;
return $location;
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Filters;
use Illuminate\Database\Eloquent\Builder;
/**
* LocationFilters.
*/
class LocationFilters extends QueryFilters
{
/**
* Filter based on search text.
*
* @param string $filter
* @return Builder
* @deprecated
*/
public function filter(string $filter = ''): Builder
{
if (strlen($filter) == 0) {
return $this->builder;
}
return $this->builder->where('name', 'like', '%'.$filter.'%');
}
/**
* Sorts the list based on $sort.
*
* @param string $sort formatted as column|asc
* @return Builder
*/
public function sort(string $sort = ''): Builder
{
$sort_col = explode('|', $sort);
if (!is_array($sort_col) || count($sort_col) != 2 || !in_array($sort_col[0], \Illuminate\Support\Facades\Schema::getColumnListing($this->builder->getModel()->getTable()))) {
return $this->builder;
}
if (is_array($sort_col) && in_array($sort_col[1], ['asc', 'desc']) && in_array($sort_col[0], ['name'])) {
return $this->builder->orderBy($sort_col[0], $sort_col[1]);
}
return $this->builder;
}
/**
* Filters the query by the users company ID.
*
* @return Builder
*/
public function entityFilter(): Builder
{
return $this->builder->company();
}
}

View File

@ -0,0 +1,480 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Factory\LocationFactory;
use App\Filters\LocationFilters;
use App\Http\Requests\Location\CreateLocationRequest;
use App\Http\Requests\Location\DestroyLocationRequest;
use App\Http\Requests\Location\EditLocationRequest;
use App\Http\Requests\Location\ShowLocationRequest;
use App\Http\Requests\Location\StoreLocationRequest;
use App\Http\Requests\Location\UpdateLocationRequest;
use App\Models\Expense;
use App\Models\Location;
use App\Repositories\BaseRepository;
use App\Transformers\LocationTransformer;
use App\Utils\Traits\MakesHash;
use Illuminate\Http\Response;
/**
* Class LocationController.
*/
class LocationController extends BaseController
{
use MakesHash;
protected $entity_type = Location::class;
protected $entity_transformer = LocationTransformer::class;
protected $base_repo;
public function __construct(BaseRepository $base_repo)
{
parent::__construct();
$this->base_repo = $base_repo;
}
/**
* @OA\Get(
* path="/api/v1/locations",
* operationId="getLocations",
* tags={"locations"},
* summary="Gets a list of locations",
* description="Lists tax rates",
* @OA\Parameter(ref="#/components/parameters/index"),
* @OA\Response(
* response=200,
* description="A list of locations",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Location"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*
*
* Display a listing of the resource.
*
* @return Response| \Illuminate\Http\JsonResponse
*/
public function index(LocationFilters $filters)
{
$locations = Location::filter($filters);
return $this->listResponse($locations);
}
/**
* Show the form for creating a new resource.
*
* @param CreateLocationRequest $request
* @return Response| \Illuminate\Http\JsonResponse
*
*
*
* @OA\Get(
* path="/api/v1/locations/create",
* operationId="getLocationCreate",
* tags={"locations"},
* summary="Gets a new blank Expens Category object",
* description="Returns a blank object with default values",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Response(
* response=200,
* description="A blank Expens Category object",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Location"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function create(CreateLocationRequest $request)
{
/** @var \App\Models\User $user */
$user = auth()->user();
$location = LocationFactory::create($user->company()->id, auth()->user()->id);
return $this->itemResponse($location);
}
/**
* Store a newly created resource in storage.
*
* @param StoreLocationRequest $request
* @return Response| \Illuminate\Http\JsonResponse
*
*
* @OA\Post(
* path="/api/v1/locations",
* operationId="storeLocation",
* tags={"locations"},
* summary="Adds a expense category",
* description="Adds an expense category to the system",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(ref="#/components/parameters/include"),
* @OA\Response(
* response=200,
* description="Returns the saved invoice object",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Location"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function store(StoreLocationRequest $request)
{
/** @var \App\Models\User $user **/
$user = auth()->user();
$location = LocationFactory::create($user->company()->id, $user->id);
$location->fill($request->all());
$location->save();
return $this->itemResponse($location);
}
/**
* Display the specified resource.
*
* @param ShowLocationRequest $request
* @param Location $location
* @return Response| \Illuminate\Http\JsonResponse
*
*
* @OA\Get(
* path="/api/v1/locations/{id}",
* operationId="showLocation",
* tags={"locations"},
* summary="Shows a Expens Category",
* description="Displays an Location by id",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(
* name="id",
* in="path",
* description="The Location Hashed ID",
* example="D2J234DFA",
* required=true,
* @OA\Schema(
* type="string",
* format="string",
* ),
* ),
* @OA\Response(
* response=200,
* description="Returns the Expens Category object",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Location"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function show(ShowLocationRequest $request, Location $location)
{
return $this->itemResponse($location);
}
/**
* Show the form for editing the specified resource.
*
* @param EditLocationRequest $request
* @param Location $location
* @return Response| \Illuminate\Http\JsonResponse
*
*
* @OA\Get(
* path="/api/v1/locations/{id}/edit",
* operationId="editLocation",
* tags={"locations"},
* summary="Shows a Expens Category for editting",
* description="Displays a Expens Category by id",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(
* name="id",
* in="path",
* description="The Location Hashed ID",
* example="D2J234DFA",
* required=true,
* @OA\Schema(
* type="string",
* format="string",
* ),
* ),
* @OA\Response(
* response=200,
* description="Returns the Expens Category object",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Location"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function edit(EditLocationRequest $request, Location $location)
{
return $this->itemResponse($location);
}
/**
* Update the specified resource in storage.
*
* @param UpdateLocationRequest $request
* @param Location $location
* @return Response| \Illuminate\Http\JsonResponse
*
*
*
* @OA\Put(
* path="/api/v1/locations/{id}",
* operationId="updateLocation",
* tags={"locations"},
* summary="Updates a tax rate",
* description="Handles the updating of a tax rate by id",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(
* name="id",
* in="path",
* description="The Location Hashed ID",
* example="D2J234DFA",
* required=true,
* @OA\Schema(
* type="string",
* format="string",
* ),
* ),
* @OA\Response(
* response=200,
* description="Returns the Location object",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Location"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function update(UpdateLocationRequest $request, Location $location)
{
$location->fill($request->all());
$location->save();
return $this->itemResponse($location);
}
/**
* Remove the specified resource from storage.
*
* @param DestroyLocationRequest $request
* @param Location $location
* @return Response| \Illuminate\Http\JsonResponse
*
*
* @throws \Exception
* @OA\Delete(
* path="/api/v1/locations/{id}",
* operationId="deleteLocation",
* tags={"locations"},
* summary="Deletes a Location",
* description="Handles the deletion of an Location by id",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(
* name="id",
* in="path",
* description="The Location Hashed ID",
* example="D2J234DFA",
* required=true,
* @OA\Schema(
* type="string",
* format="string",
* ),
* ),
* @OA\Response(
* response=200,
* description="Returns a HTTP status",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
*
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function destroy(DestroyLocationRequest $request, Location $location)
{
$location->is_deleted = true;
$location->save();
$location->delete();
return $this->itemResponse($location);
}
/**
* Perform bulk actions on the list view.
*
* @return Response| \Illuminate\Http\JsonResponse
*
*
* @OA\Post(
* path="/api/v1/locations/bulk",
* operationId="bulkLocations",
* tags={"locations"},
* summary="Performs bulk actions on an array of Locations",
* description="",
* @OA\Parameter(ref="#/components/parameters/X-API-TOKEN"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\Parameter(ref="#/components/parameters/index"),
* @OA\RequestBody(
* description="Expens Categorys",
* required=true,
* @OA\MediaType(
* mediaType="application/json",
* @OA\Schema(
* type="array",
* @OA\Items(
* type="integer",
* description="Array of hashed IDs to be bulk 'actioned",
* example="[0,1,2,3]",
* ),
* )
* )
* ),
* @OA\Response(
* response=200,
* description="The Location List response",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* @OA\JsonContent(ref="#/components/schemas/Webhook"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function bulk()
{
/** @var \App\Models\User $user **/
$user = auth()->user();
$action = request()->input('action');
$ids = request()->input('ids');
$locations = Location::withTrashed()->find($this->transformKeys($ids));
$locations->each(function ($location, $key) use ($action, $user) {
if ($user->can('edit', $location)) {
$this->base_repo->{$action}($location);
}
});
return $this->listResponse(Location::withTrashed()->whereIn('id', $this->transformKeys($ids)));
}
}

View File

@ -0,0 +1,40 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Location;
use App\Http\Requests\Request;
use App\Utils\Traits\BulkOptions;
class BulkLocationRequest extends Request
{
use BulkOptions;
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return auth()->user()->isAdmin();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [];
}
}

View File

@ -0,0 +1,28 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Location;
use App\Http\Requests\Request;
use App\Models\Location;
class CreateLocationRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->can('create', Location::class);
}
}

View File

@ -0,0 +1,27 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Location;
use App\Http\Requests\Request;
class DestroyLocationRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->can('edit', $this->location);
}
}

View File

@ -0,0 +1,37 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Location;
use App\Http\Requests\Request;
class EditLocationRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->can('edit', $this->location);
}
// public function prepareForValidation()
// {
// $input = $this->all();
// //$input['id'] = $this->encodePrimaryKey($input['id']);
// $this->replace($input);
// }
}

View File

@ -0,0 +1,27 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Location;
use App\Http\Requests\Request;
class ShowLocationRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->can('view', $this->location);
}
}

View File

@ -0,0 +1,58 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Location;
use App\Models\Expense;
use App\Http\Requests\Request;
use App\Models\Location;
class StoreLocationRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
/** @var \App\Models\User $user */
$user = auth()->user();
return $user->can('create', Location::class) || $user->can('create', Expense::class);
}
public function rules()
{
/** @var \App\Models\User $user */
$user = auth()->user();
$rules = [];
$rules['name'] = 'required|unique:expense_categories,name,null,null,company_id,'.$user->companyId();
return $this->globalRules($rules);
}
public function prepareForValidation()
{
$input = $this->all();
$input = $this->decodePrimaryKeys($input);
if (array_key_exists('color', $input) && is_null($input['color'])) {
$input['color'] = '';
}
$this->replace($input);
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Location;
use App\Http\Requests\Request;
use App\Utils\Traits\ChecksEntityStatus;
use Illuminate\Validation\Rule;
class UpdateLocationRequest extends Request
{
use ChecksEntityStatus;
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
/** @var \App\Models\User $user */
$user = auth()->user();
return $user->can('edit', $this->location);
}
public function rules()
{
/** @var \App\Models\User $user */
$user = auth()->user();
$rules = [];
if ($this->input('name')) {
$rules['name'] = Rule::unique('locations')->where('company_id', $user->company()->id)->ignore($this->location->id);
}
return $rules;
}
public function prepareForValidation()
{
$input = $this->all();
$this->replace($input);
}
}

View File

@ -320,6 +320,11 @@ class Client extends BaseModel implements HasLocalePreference
return $this->hasMany(Project::class)->withTrashed();
}
public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Location::class)->withTrashed();
}
/**
* Retrieves the specific payment token per
* gateway - per payment method.

View File

@ -478,6 +478,11 @@ class Company extends BaseModel
return $this->hasMany(ClientContact::class)->withTrashed();
}
public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Location::class)->withTrashed();
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/

120
app/Models/Location.php Normal file
View File

@ -0,0 +1,120 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Models;
use App\Models\Traits\Excludable;
use Illuminate\Support\Facades\App;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* App\Models\Client
*
* @property int $id
* @property int $company_id
* @property int $user_id
* @property int|null $assigned_user_id
* @property string|null $name
* @property string|null $website
* @property string|null $private_notes
* @property string|null $public_notes
* @property string|null $client_hash
* @property string|null $logo
* @property string|null $phone
* @property string|null $routing_id
* @property float $balance
* @property float $paid_to_date
* @property float $credit_balance
* @property int|null $last_login
* @property int|null $industry_id
* @property int|null $size_id
* @property object|array|null $e_invoice
* @property string|null $address1
* @property string|null $address2
* @property string|null $city
* @property string|null $state
* @property string|null $postal_code
* @property int|null $country_id
* @property string|null $custom_value1
* @property string|null $custom_value2
* @property string|null $custom_value3
* @property string|null $custom_value4
* @property bool $is_deleted
* @property int|null $created_at
* @property int|null $updated_at
* @property int|null $deleted_at
* @property-read mixed $hashed_id
* @property-read \App\Models\User $user
* @property-read \App\Models\Client $client
* @property-read \App\Models\Vendor $vendor
* @property-read \App\Models\Company $company
* @property-read \App\Models\Country|null $country
*
* @mixin \Eloquent
*/
class Location extends BaseModel
{
use SoftDeletes;
use Filterable;
use Excludable;
protected $hidden = [
'id',
'user_id',
'company_id',
];
protected $fillable = [
'name',
'address1',
'address2',
'city',
'state',
'postal_code',
'country_id',
'custom_value1',
'custom_value2',
'custom_value3',
'custom_value4',
'is_deleted',
];
protected $touches = [];
public function client()
{
return $this->belongsTo(Client::class);
}
public function vendor()
{
return $this->belongsTo(Vendor::class);
}
public function company()
{
return $this->belongsTo(Company::class);
}
public function country()
{
return $this->belongsTo(Country::class);
}
public function getEntityType()
{
return self::class;
}
}

View File

@ -215,6 +215,11 @@ class Vendor extends BaseModel
return $this->hasMany(Activity::class);
}
public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Location::class)->withTrashed();
}
public function getCurrencyCode(): string
{
if ($this->currency()) {

View File

@ -0,0 +1,32 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Policies;
use App\Models\User;
/**
* Class ClientPolicy.
*/
class LocationPolicy extends EntityPolicy
{
/**
* Checks if the user has create permissions.
*
* @param User $user
* @return bool
*/
public function create(User $user): bool
{
return $user->isAdmin() || $user->hasPermission('create_vendor') || $user->hasPermission('create_client') || $user->hasPermission('create_all');
}
}

View File

@ -11,72 +11,74 @@
namespace App\Providers;
use App\Models\Activity;
use App\Models\BankIntegration;
use App\Models\BankTransaction;
use App\Models\BankTransactionRule;
use App\Models\Task;
use App\Models\User;
use App\Models\Quote;
use App\Models\Client;
use App\Models\Company;
use App\Models\CompanyGateway;
use App\Models\CompanyToken;
use App\Models\Credit;
use App\Models\Design;
use App\Models\Document;
use App\Models\Vendor;
use App\Models\Company;
use App\Models\Expense;
use App\Models\ExpenseCategory;
use App\Models\GroupSetting;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\PaymentTerm;
use App\Models\Product;
use App\Models\Project;
use App\Models\PurchaseOrder;
use App\Models\Quote;
use App\Models\RecurringExpense;
use App\Models\RecurringInvoice;
use App\Models\RecurringQuote;
use App\Models\Scheduler;
use App\Models\Subscription;
use App\Models\Task;
use App\Models\TaskStatus;
use App\Models\TaxRate;
use App\Models\User;
use App\Models\Vendor;
use App\Models\Webhook;
use App\Policies\ActivityPolicy;
use App\Policies\BankIntegrationPolicy;
use App\Policies\BankTransactionPolicy;
use App\Policies\BankTransactionRulePolicy;
use App\Models\Activity;
use App\Models\Document;
use App\Models\Location;
use App\Models\Scheduler;
use App\Models\TaskStatus;
use App\Models\PaymentTerm;
use App\Models\CompanyToken;
use App\Models\GroupSetting;
use App\Models\Subscription;
use App\Policies\TaskPolicy;
use App\Policies\UserPolicy;
use App\Models\PurchaseOrder;
use App\Policies\QuotePolicy;
use App\Models\CompanyGateway;
use App\Models\RecurringQuote;
use App\Policies\ClientPolicy;
use App\Policies\CompanyGatewayPolicy;
use App\Policies\CompanyPolicy;
use App\Policies\CompanyTokenPolicy;
use App\Policies\CreditPolicy;
use App\Policies\DesignPolicy;
use App\Policies\DocumentPolicy;
use App\Policies\ExpenseCategoryPolicy;
use App\Policies\VendorPolicy;
use App\Models\BankIntegration;
use App\Models\BankTransaction;
use App\Models\ExpenseCategory;
use App\Policies\CompanyPolicy;
use App\Policies\ExpensePolicy;
use App\Policies\GroupSettingPolicy;
use App\Policies\InvoicePolicy;
use App\Policies\PaymentPolicy;
use App\Policies\PaymentTermPolicy;
use App\Policies\ProductPolicy;
use App\Policies\ProjectPolicy;
use App\Policies\TaxRatePolicy;
use App\Policies\WebhookPolicy;
use App\Models\RecurringExpense;
use App\Models\RecurringInvoice;
use App\Policies\ActivityPolicy;
use App\Policies\DocumentPolicy;
use App\Policies\LocationPolicy;
use App\Policies\SchedulerPolicy;
use App\Policies\TaskStatusPolicy;
use App\Models\BankTransactionRule;
use App\Policies\PaymentTermPolicy;
use App\Policies\CompanyTokenPolicy;
use App\Policies\GroupSettingPolicy;
use App\Policies\SubscriptionPolicy;
use Illuminate\Support\Facades\Gate;
use App\Policies\PurchaseOrderPolicy;
use App\Policies\QuotePolicy;
use App\Policies\CompanyGatewayPolicy;
use App\Policies\RecurringQuotePolicy;
use App\Policies\BankIntegrationPolicy;
use App\Policies\BankTransactionPolicy;
use App\Policies\ExpenseCategoryPolicy;
use App\Policies\RecurringExpensePolicy;
use App\Policies\RecurringInvoicePolicy;
use App\Policies\RecurringQuotePolicy;
use App\Policies\SchedulerPolicy;
use App\Policies\SubscriptionPolicy;
use App\Policies\TaskPolicy;
use App\Policies\TaskStatusPolicy;
use App\Policies\TaxRatePolicy;
use App\Policies\UserPolicy;
use App\Policies\VendorPolicy;
use App\Policies\WebhookPolicy;
use App\Policies\BankTransactionRulePolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
@ -101,6 +103,7 @@ class AuthServiceProvider extends ServiceProvider
ExpenseCategory::class => ExpenseCategoryPolicy::class,
GroupSetting::class => GroupSettingPolicy::class,
Invoice::class => InvoicePolicy::class,
Location::class => LocationPolicy::class,
Payment::class => PaymentPolicy::class,
PaymentTerm::class => PaymentTermPolicy::class,
Product::class => ProductPolicy::class,

View File

@ -11,16 +11,17 @@
namespace App\Transformers;
use App\Models\Activity;
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\ClientGatewayToken;
use App\Models\CompanyLedger;
use App\Models\Document;
use App\Models\GroupSetting;
use App\Models\SystemLog;
use App\Utils\Traits\MakesHash;
use stdClass;
use App\Models\Client;
use App\Models\Activity;
use App\Models\Document;
use App\Models\Location;
use App\Models\SystemLog;
use App\Models\GroupSetting;
use App\Models\ClientContact;
use App\Models\CompanyLedger;
use App\Utils\Traits\MakesHash;
use App\Models\ClientGatewayToken;
/**
* class ClientTransformer.
@ -43,6 +44,7 @@ class ClientTransformer extends EntityTransformer
'ledger',
'system_logs',
'group_settings',
'locations',
];
/**
@ -76,6 +78,18 @@ class ClientTransformer extends EntityTransformer
return $this->includeCollection($client->contacts, $transformer, ClientContact::class);
}
/**
* @param Client $client
*
* @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*/
public function includeLocations(Client $client)
{
$transformer = new LocationTransformer($this->serializer);
return $this->includeCollection($client->locations, $transformer, Location::class);
}
public function includeGatewayTokens(Client $client)
{
$transformer = new ClientGatewayTokenTransformer($this->serializer);

View File

@ -11,43 +11,44 @@
namespace App\Transformers;
use App\Models\Account;
use App\Models\Activity;
use App\Models\BankIntegration;
use App\Models\BankTransaction;
use App\Models\BankTransactionRule;
use stdClass;
use App\Models\Task;
use App\Models\User;
use App\Models\Quote;
use App\Models\Client;
use App\Models\Company;
use App\Models\CompanyGateway;
use App\Models\CompanyLedger;
use App\Models\CompanyToken;
use App\Models\CompanyUser;
use App\Models\Credit;
use App\Models\Design;
use App\Models\Document;
use App\Models\Vendor;
use App\Models\Account;
use App\Models\Company;
use App\Models\Expense;
use App\Models\ExpenseCategory;
use App\Models\GroupSetting;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\PaymentTerm;
use App\Models\Product;
use App\Models\Project;
use App\Models\TaxRate;
use App\Models\Webhook;
use App\Models\Activity;
use App\Models\Document;
use App\Models\Location;
use App\Models\Scheduler;
use App\Models\SystemLog;
use App\Models\TaskStatus;
use App\Models\CompanyUser;
use App\Models\PaymentTerm;
use App\Models\CompanyToken;
use App\Models\GroupSetting;
use App\Models\Subscription;
use App\Models\CompanyLedger;
use App\Models\PurchaseOrder;
use App\Models\Quote;
use App\Models\CompanyGateway;
use App\Models\BankIntegration;
use App\Models\BankTransaction;
use App\Models\ExpenseCategory;
use App\Utils\Traits\MakesHash;
use App\Models\RecurringExpense;
use App\Models\RecurringInvoice;
use App\Models\Scheduler;
use App\Models\Subscription;
use App\Models\SystemLog;
use App\Models\Task;
use App\Models\TaskStatus;
use App\Models\TaxRate;
use App\Models\User;
use App\Models\Vendor;
use App\Models\Webhook;
use App\Utils\Traits\MakesHash;
use stdClass;
use App\Models\BankTransactionRule;
/**
* Class CompanyTransformer.
@ -107,6 +108,7 @@ class CompanyTransformer extends EntityTransformer
'bank_transaction_rules',
'task_schedulers',
'schedulers',
'locations',
];
/**
@ -297,6 +299,14 @@ class CompanyTransformer extends EntityTransformer
return $this->includeCollection($company->schedulers, $transformer, Scheduler::class);
}
public function includeLocations(Company $company)
{
$transformer = new LocationTransformer($this->serializer);
return $this->includeCollection($company->locations, $transformer, Location::class);
}
public function includeBankTransactionRules(Company $company)
{
$transformer = new BankTransactionRuleTransformer($this->serializer);

View File

@ -0,0 +1,66 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Transformers;
use App\Models\Location;
use App\Utils\Traits\MakesHash;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* class LocationTransformer.
*/
class LocationTransformer extends EntityTransformer
{
use MakesHash;
use SoftDeletes;
protected array $defaultIncludes = [
];
/**
* @var array
*/
protected array $availableIncludes = [
];
/**
* @param Location $location
*
* @return array
*/
public function transform(Location $location)
{
return [
'id' => $location->hashed_id,
'user_id' => $this->encodePrimaryKey($location->user_id),
'vendor_id' => $this->encodePrimaryKey($location->vendor_id),
'client_id' => $this->encodePrimaryKey($location->client_id),
'name' => (string) $location->name ?: '',
'address1' => $location->address1 ?: '',
'address2' => $location->address2 ?: '',
'phone' => $location->phone ?: '',
'city' => $location->city ?: '',
'state' => $location->state ?: '',
'postal_code' => $location->postal_code ?: '',
'country_id' => (string) $location->country_id ?: '',
'custom_value1' => $location->custom_value1 ?: '',
'custom_value2' => $location->custom_value2 ?: '',
'custom_value3' => $location->custom_value3 ?: '',
'custom_value4' => $location->custom_value4 ?: '',
'is_deleted' => (bool) $location->is_deleted,
'updated_at' => (int) $location->updated_at,
'archived_at' => (int) $location->deleted_at,
'created_at' => (int) $location->created_at,
];
}
}

View File

@ -11,9 +11,10 @@
namespace App\Transformers;
use App\Models\Vendor;
use App\Models\Activity;
use App\Models\Document;
use App\Models\Vendor;
use App\Models\Location;
use App\Models\VendorContact;
use App\Utils\Traits\MakesHash;
@ -34,6 +35,7 @@ class VendorTransformer extends EntityTransformer
*/
protected array $availableIncludes = [
'activities',
'locations',
];
/**
@ -67,6 +69,18 @@ class VendorTransformer extends EntityTransformer
return $this->includeCollection($vendor->documents, $transformer, Document::class);
}
/**
* @param Vendor $vendor
*
* @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*/
public function includeLocations(Vendor $vendor)
{
$transformer = new LocationTransformer($this->serializer);
return $this->includeCollection($vendor->locations, $transformer, Location::class);
}
/**
* @param Vendor $vendor
*

View File

@ -0,0 +1,41 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Str;
class LocationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => $this->faker->company(),
'custom_value1' => $this->faker->dateTime(),
'custom_value2' => $this->faker->colorName(),
'custom_value3' => $this->faker->word(),
'custom_value4' => $this->faker->email(),
'address1' => $this->faker->buildingNumber(),
'address2' => $this->faker->streetAddress(),
'city' => $this->faker->city(),
'state' => $this->faker->state(),
'postal_code' => $this->faker->postcode(),
'country_id' => 4,
];
}
}

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('locations', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('user_id');
$table->unsignedInteger('client_id')->nullable()->index();
$table->unsignedInteger('vendor_id')->nullable()->index();
$table->unsignedInteger('company_id')->index();
$table->string('name')->nullable();
$table->string('address1')->nullable();
$table->string('address2')->nullable();
$table->string('city')->nullable();
$table->string('state')->nullable();
$table->string('postal_code')->nullable();
$table->boolean('is_deleted')->default(false);
$table->unsignedInteger('country_id')->nullable();
$table->text('custom_value1')->nullable();
$table->text('custom_value2')->nullable();
$table->text('custom_value3')->nullable();
$table->text('custom_value4')->nullable();
$table->softDeletes('deleted_at', 6);
$table->timestamps(6);
$table->foreign('client_id')->references('id')->on('clients')->onDelete('cascade')->onUpdate('cascade');
$table->foreign('vendor_id')->references('id')->on('vendors')->onDelete('cascade')->onUpdate('cascade');
$table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -10,16 +10,13 @@
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
use App\Http\Controllers\EInvoicePeppolController;
use App\Http\Controllers\EInvoiceTokenController;
use App\Http\Controllers\SubscriptionStepsController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\BaseController;
use App\Http\Controllers\BrevoController;
use App\Http\Controllers\PingController;
use App\Http\Controllers\SmtpController;
use App\Http\Controllers\TaskController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\BrevoController;
use App\Http\Controllers\ChartController;
use App\Http\Controllers\EmailController;
use App\Http\Controllers\QuoteController;
@ -42,8 +39,6 @@ use App\Http\Controllers\ExpenseController;
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\LicenseController;
use App\Http\Controllers\MailgunController;
use App\Http\Controllers\MigrationController;
use App\Http\Controllers\OneTimeTokenController;
use App\Http\Controllers\PaymentController;
use App\Http\Controllers\PreviewController;
use App\Http\Controllers\ProductController;
@ -53,30 +48,35 @@ use App\Http\Controllers\WebCronController;
use App\Http\Controllers\WebhookController;
use App\Http\Controllers\ActivityController;
use App\Http\Controllers\DocumentController;
use App\Http\Controllers\EInvoiceController;
use App\Http\Controllers\LocationController;
use App\Http\Controllers\PostMarkController;
use App\Http\Controllers\TemplateController;
use App\Http\Controllers\MigrationController;
use App\Http\Controllers\SchedulerController;
use App\Http\Controllers\SubdomainController;
use App\Http\Controllers\SystemLogController;
use App\Http\Controllers\TwoFactorController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\ImportJsonController;
use App\Http\Controllers\ImportQuickbooksController;
use App\Http\Controllers\SelfUpdateController;
use App\Http\Controllers\TaskStatusController;
use App\Http\Controllers\Bank\YodleeController;
use App\Http\Controllers\CompanyUserController;
use App\Http\Controllers\PaymentTermController;
use App\PaymentDrivers\PayPalPPCPPaymentDriver;
use App\PaymentDrivers\BlockonomicsPaymentDriver;
use App\Http\Controllers\EmailHistoryController;
use App\Http\Controllers\GroupSettingController;
use App\Http\Controllers\OneTimeTokenController;
use App\Http\Controllers\SubscriptionController;
use App\Http\Controllers\Bank\NordigenController;
use App\Http\Controllers\CompanyLedgerController;
use App\Http\Controllers\EInvoiceTokenController;
use App\Http\Controllers\PurchaseOrderController;
use App\Http\Controllers\TaskSchedulerController;
use App\PaymentDrivers\BlockonomicsPaymentDriver;
use App\Http\Controllers\CompanyGatewayController;
use App\Http\Controllers\EInvoicePeppolController;
use App\Http\Controllers\PaymentWebhookController;
use App\Http\Controllers\RecurringQuoteController;
use App\Http\Controllers\BankIntegrationController;
@ -86,9 +86,11 @@ use App\Http\Controllers\ExpenseCategoryController;
use App\Http\Controllers\HostedMigrationController;
use App\Http\Controllers\TemplatePreviewController;
use App\Http\Controllers\ConnectedAccountController;
use App\Http\Controllers\ImportQuickbooksController;
use App\Http\Controllers\RecurringExpenseController;
use App\Http\Controllers\RecurringInvoiceController;
use App\Http\Controllers\ProtectedDownloadController;
use App\Http\Controllers\SubscriptionStepsController;
use App\Http\Controllers\ClientGatewayTokenController;
use App\Http\Controllers\Reports\TaskReportController;
use App\Http\Controllers\Auth\ForgotPasswordController;
@ -96,7 +98,6 @@ use App\Http\Controllers\BankTransactionRuleController;
use App\Http\Controllers\InAppPurchase\AppleController;
use App\Http\Controllers\Reports\QuoteReportController;
use App\Http\Controllers\Auth\PasswordTimeoutController;
use App\Http\Controllers\EInvoiceController;
use App\Http\Controllers\PreviewPurchaseOrderController;
use App\Http\Controllers\Reports\ClientReportController;
use App\Http\Controllers\Reports\CreditReportController;
@ -277,6 +278,9 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale']
Route::post('invoices/bulk', [InvoiceController::class, 'bulk'])->name('invoices.bulk');
Route::post('invoices/update_reminders', [InvoiceController::class, 'update_reminders'])->name('invoices.update_reminders');
Route::resource('locations', LocationController::class); // name = (locations. index / create / show / update / destroy / edit
Route::post('locations/bulk', [LocationController::class, 'bulk'])->name('locations.bulk');
Route::post('logout', [LogoutController::class, 'index'])->name('logout');
Route::post('migrate', [MigrationController::class, 'index'])->name('migrate.start');

View File

@ -0,0 +1,175 @@
<?php
namespace Tests\Feature;
use App\Models\Location;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Session;
use Tests\MockAccountData;
use Tests\TestCase;
class LocationApiTest extends TestCase
{
use DatabaseTransactions;
use MockAccountData;
protected function setUp(): void
{
parent::setUp();
$this->makeTestData();
Session::start();
}
public function testLocationPost()
{
$data = [
'name' => 'Test Location',
'address1' => '123 Test St',
'address2' => 'Suite 100',
'city' => 'Test City',
'state' => 'TS',
'postal_code' => '12345',
'country_id' => '840', // USA
];
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/locations', $data);
$response->assertStatus(200);
$arr = $response->json();
$this->assertEquals($data['name'], $arr['data']['name']);
$this->assertEquals($data['address1'], $arr['data']['address1']);
}
public function testLocationGet()
{
$location = Location::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
]);
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->get('/api/v1/locations/' . $this->encodePrimaryKey($location->id));
$response->assertStatus(200);
$arr = $response->json();
$this->assertEquals($location->name, $arr['data']['name']);
}
public function testLocationPut()
{
$location = Location::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
]);
$data = [
'name' => 'Updated Location',
'address1' => '456 Update St',
];
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/locations/' . $this->encodePrimaryKey($location->id), $data);
$response->assertStatus(200);
$arr = $response->json();
$this->assertEquals($data['name'], $arr['data']['name']);
$this->assertEquals($data['address1'], $arr['data']['address1']);
}
public function testLocationDelete()
{
$location = Location::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
]);
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->deleteJson('/api/v1/locations/' . $this->encodePrimaryKey($location->id));
$response->assertStatus(200);
}
public function testLocationList()
{
Location::factory()->count(3)->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
]);
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->get('/api/v1/locations');
$response->assertStatus(200);
$arr = $response->json();
$this->assertCount(3, $arr['data']);
}
public function testLocationValidation()
{
$data = [
'name' => '', // Required field is empty
];
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/locations', $data);
$response->assertStatus(422);
}
public function testBulkActions()
{
$locations = Location::factory()->count(3)->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
]);
$data = [
'action' => 'archive',
'ids' => $locations->pluck('hashed_id')->values()->toArray(),
];
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/locations/bulk', $data);
$response->assertStatus(200);
foreach ($locations as $location) {
$this->assertNotNull($location->fresh()->deleted_at);
}
}
public function testLocationRestore()
{
$location = Location::factory()->create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'deleted_at' => now(),
]);
$data = [
'action' => 'restore',
'ids' => [$location->hashed_id],
];
$response = $this->withHeaders([
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/locations/bulk', $data);
$response->assertStatus(200);
$this->assertNull($location->fresh()->deleted_at);
}
}