From 0e067a15f34b3767d781f10a0150fe445d7b829c Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 3 Jul 2026 10:12:04 +0100 Subject: [PATCH] added complex address and devisions to company --- ..._120000_create_company_addresses_table.php | 40 ++++ ..._121000_create_company_divisions_table.php | 34 +++ ...000_create_company_division_user_table.php | 30 +++ ...ate_company_director_assignments_table.php | 32 +++ ...00_add_division_id_to_warehouses_table.php | 27 +++ .../Controllers/CompanyAddressController.php | 61 +++++ .../Http/Controllers/CompanyController.php | 18 +- .../Controllers/CompanyDivisionController.php | 54 +++++ .../CompanyMembershipController.php | 83 +++++++ .../CompanyWarehouseAssignmentController.php | 25 ++ Modules/Company/Models/Company.php | 17 ++ Modules/Company/Models/CompanyAddress.php | 54 +++++ .../Models/CompanyDirectorAssignment.php | 34 +++ Modules/Company/Models/CompanyDivision.php | 62 +++++ .../Company/Resources/views/index.blade.php | 7 +- .../views/partials/address-form.blade.php | 47 ++++ .../Company/Resources/views/show.blade.php | 220 +++++++++--------- Modules/Company/Routes/web.php | 28 ++- .../CompanyAdministrationBrowserTest.php | 36 +++ .../CompanyAdministrationRoutesTest.php | 73 ++++++ .../Tests/Feature/CompanyStructureTest.php | 70 ++++++ Modules/Company/changelog.yaml | 19 +- Modules/Company/module.json | 2 +- Modules/Warehouse/app/Models/Warehouse.php | 16 +- app/Models/User.php | 17 ++ 25 files changed, 972 insertions(+), 134 deletions(-) create mode 100644 Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php create mode 100644 Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php create mode 100644 Modules/Company/Http/Controllers/CompanyAddressController.php create mode 100644 Modules/Company/Http/Controllers/CompanyDivisionController.php create mode 100644 Modules/Company/Http/Controllers/CompanyMembershipController.php create mode 100644 Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php create mode 100644 Modules/Company/Models/CompanyAddress.php create mode 100644 Modules/Company/Models/CompanyDirectorAssignment.php create mode 100644 Modules/Company/Models/CompanyDivision.php create mode 100644 Modules/Company/Resources/views/partials/address-form.blade.php create mode 100644 Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php create mode 100644 Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php create mode 100644 Modules/Company/Tests/Feature/CompanyStructureTest.php diff --git a/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php new file mode 100644 index 00000000..627f7f17 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_120000_create_company_addresses_table.php @@ -0,0 +1,40 @@ +id(); + $table->morphs('addressable'); + $table->string('label', 80)->default('Registered office'); + $table->string('attention')->nullable(); + $table->string('line_1'); + $table->string('line_2')->nullable(); + $table->string('line_3')->nullable(); + $table->string('locality')->comment('City, town or locality.'); + $table->string('administrative_area')->nullable()->comment('County, state, province or region.'); + $table->string('postal_code', 32)->nullable(); + $table->foreignId('country_id')->constrained('countries')->restrictOnDelete(); + $table->foreignId('country_subdivision_id')->nullable()->constrained('country_subdivisions')->nullOnDelete(); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->boolean('is_primary')->default(true); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['addressable_type', 'addressable_id'], 'company_addresses_one_per_owner_unique'); + $table->index(['country_id', 'locality']); + $table->index(['postal_code']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_addresses'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php new file mode 100644 index 00000000..44cad595 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_121000_create_company_divisions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->string('name'); + $table->string('code', 40)->nullable(); + $table->string('slug'); + $table->string('status', 32)->default('active')->index(); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'slug']); + $table->unique(['company_id', 'code']); + $table->index(['company_id', 'status', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_divisions'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php new file mode 100644 index 00000000..16fed17b --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_122000_create_company_division_user_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->foreignId('company_division_id')->constrained('company_divisions')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role', 80)->default('member')->index(); + $table->boolean('is_director')->default(false)->index(); + $table->timestamps(); + + $table->unique(['company_division_id', 'user_id']); + $table->index(['company_id', 'user_id']); + $table->index(['company_id', 'is_director']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_division_user'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php new file mode 100644 index 00000000..c539c515 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_123000_create_company_director_assignments_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->foreignId('company_division_id')->nullable()->constrained('company_divisions')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('coverage_type', 32)->default('company')->index(); + $table->date('starts_at')->nullable(); + $table->date('ends_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'company_division_id', 'user_id'], 'company_director_coverage_unique'); + $table->index(['company_id', 'coverage_type']); + $table->index(['user_id', 'coverage_type']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_director_assignments'); + } +}; diff --git a/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php new file mode 100644 index 00000000..b7c52b09 --- /dev/null +++ b/Modules/Company/Database/Migrations/2026_07_02_124000_add_division_id_to_warehouses_table.php @@ -0,0 +1,27 @@ +foreignId('division_id')->nullable()->after('company_id')->constrained('company_divisions')->nullOnDelete(); + $table->index(['company_id', 'division_id']); + } + }); + } + + public function down(): void + { + Schema::table('warehouses', function (Blueprint $table): void { + if (Schema::hasColumn('warehouses', 'division_id')) { + $table->dropConstrainedForeignId('division_id'); + } + }); + } +}; diff --git a/Modules/Company/Http/Controllers/CompanyAddressController.php b/Modules/Company/Http/Controllers/CompanyAddressController.php new file mode 100644 index 00000000..abf17a7a --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyAddressController.php @@ -0,0 +1,61 @@ +upsertAddress($request, $company); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Company address updated.'); + } + + public function updateDivision(Request $request, Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + $this->upsertAddress($request, $division, 'Operating address'); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division address updated.'); + } + + public function updateWarehouse(Request $request, Company $company, Warehouse $warehouse) + { + abort_unless($warehouse->company_id === $company->id, 404); + + $this->upsertAddress($request, $warehouse, 'Warehouse address'); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse address updated.'); + } + + private function upsertAddress(Request $request, object $owner, string $defaultLabel = 'Registered office'): void + { + $data = $request->validate([ + 'label' => ['nullable', 'string', 'max:80'], + 'attention' => ['nullable', 'string', 'max:255'], + 'line_1' => ['required', 'string', 'max:255'], + 'line_2' => ['nullable', 'string', 'max:255'], + 'line_3' => ['nullable', 'string', 'max:255'], + 'locality' => ['required', 'string', 'max:255'], + 'administrative_area' => ['nullable', 'string', 'max:255'], + 'postal_code' => ['nullable', 'string', 'max:32'], + 'country_id' => ['required', Rule::exists('countries', 'id')->where('is_active', true)], + 'country_subdivision_id' => ['nullable', 'exists:country_subdivisions,id'], + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + ]); + + $data['label'] = $data['label'] ?: $defaultLabel; + $data['is_primary'] = true; + + $owner->address()->updateOrCreate([], $data); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyController.php b/Modules/Company/Http/Controllers/CompanyController.php index 4cdbd808..9a27f2e0 100644 --- a/Modules/Company/Http/Controllers/CompanyController.php +++ b/Modules/Company/Http/Controllers/CompanyController.php @@ -7,6 +7,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Modules\Company\Models\Company; +use Modules\Countries\Models\Country; class CompanyController extends Controller { @@ -17,7 +18,8 @@ class CompanyController extends Controller { return view('company::index', [ 'companies' => Company::query() - ->withCount(['users', 'warehouses', 'licenseKeys']) + ->with(['address.country']) + ->withCount(['users', 'warehouses', 'licenseKeys', 'divisions']) ->latest('id') ->paginate(20), ]); @@ -53,13 +55,25 @@ class CompanyController extends Controller */ public function show(Company $company) { - $company->loadCount(['users', 'warehouses', 'licenseKeys', 'payments']); + $company->load([ + 'address.country', + 'divisions.address.country', + 'divisions.warehouses', + 'divisions.users', + 'warehouses.address.country', + 'warehouses.division', + 'directorAssignments.user', + 'directorAssignments.division', + ])->loadCount(['users', 'warehouses', 'licenseKeys', 'payments', 'divisions']); return view('company::show', [ 'company' => $company, 'recentUsers' => $company->users()->latest('id')->limit(5)->get(), 'recentWarehouses' => $company->warehouses()->latest('id')->limit(5)->get(), 'recentLicenseKeys' => $company->licenseKeys()->latest('id')->limit(5)->get(), + 'countries' => Country::query()->active()->orderBy('sort_order')->orderBy('name')->get(['id', 'alpha2', 'name', 'display_name', 'common_name']), + 'companyUsers' => $company->users()->orderBy('name')->get(['id', 'name', 'email', 'company_id']), + 'unassignedWarehouses' => $company->warehouses()->whereNull('division_id')->orderBy('name')->get(), ]); } diff --git a/Modules/Company/Http/Controllers/CompanyDivisionController.php b/Modules/Company/Http/Controllers/CompanyDivisionController.php new file mode 100644 index 00000000..baf3b8ed --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyDivisionController.php @@ -0,0 +1,54 @@ +divisions()->create($this->validated($request, $company)); + + return redirect()->route('platform.companies.show', $company)->with('status', "Division {$division->name} created."); + } + + public function update(Request $request, Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + $division->update($this->validated($request, $company, $division)); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division updated.'); + } + + public function destroy(Company $company, CompanyDivision $division) + { + abort_unless($division->company_id === $company->id, 404); + + abort_if($division->warehouses()->exists(), 422, 'Move warehouses before deleting this division.'); + + $division->delete(); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division deleted.'); + } + + private function validated(Request $request, Company $company, ?CompanyDivision $division = null): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'code' => ['nullable', 'string', 'max:40', Rule::unique('company_divisions', 'code')->where('company_id', $company->id)->ignore($division?->id)], + 'status' => ['required', Rule::in(['active', 'suspended', 'inactive'])], + 'description' => ['nullable', 'string', 'max:2000'], + ]); + + $data['slug'] = Str::slug($data['name']); + + return $data; + } +} diff --git a/Modules/Company/Http/Controllers/CompanyMembershipController.php b/Modules/Company/Http/Controllers/CompanyMembershipController.php new file mode 100644 index 00000000..7ce6d23c --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyMembershipController.php @@ -0,0 +1,83 @@ +validate([ + 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], + 'company_division_id' => ['required', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + 'role' => ['nullable', 'string', 'max:80'], + 'is_director' => ['nullable', 'boolean'], + ]); + + $division = CompanyDivision::where('company_id', $company->id)->findOrFail($data['company_division_id']); + $division->users()->syncWithoutDetaching([ + $data['user_id'] => [ + 'company_id' => $company->id, + 'role' => $data['role'] ?? 'member', + 'is_director' => (bool) ($data['is_director'] ?? false), + ], + ]); + + if ((bool) ($data['is_director'] ?? false)) { + CompanyDirectorAssignment::updateOrCreate([ + 'company_id' => $company->id, + 'company_division_id' => $division->id, + 'user_id' => $data['user_id'], + ], ['coverage_type' => 'division']); + } + + return redirect()->route('platform.companies.show', $company)->with('status', 'Division membership updated.'); + } + + public function assignDirector(Request $request, Company $company) + { + $data = $request->validate([ + 'user_id' => ['required', Rule::exists('users', 'id')->where('company_id', $company->id)], + 'coverage_type' => ['required', Rule::in(['company', 'division'])], + 'company_division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + if ($data['coverage_type'] === 'company') { + $data['company_division_id'] = null; + } else { + abort_if(empty($data['company_division_id']), 422, 'Division coverage requires a division.'); + } + + CompanyDirectorAssignment::updateOrCreate([ + 'company_id' => $company->id, + 'company_division_id' => $data['company_division_id'], + 'user_id' => $data['user_id'], + ], [ + 'coverage_type' => $data['coverage_type'], + 'starts_at' => $data['starts_at'] ?? null, + 'ends_at' => $data['ends_at'] ?? null, + 'notes' => $data['notes'] ?? null, + ]); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage updated.'); + } + + public function removeDirector(Company $company, CompanyDirectorAssignment $assignment) + { + abort_unless($assignment->company_id === $company->id, 404); + + $assignment->delete(); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Director coverage removed.'); + } +} diff --git a/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php new file mode 100644 index 00000000..194be483 --- /dev/null +++ b/Modules/Company/Http/Controllers/CompanyWarehouseAssignmentController.php @@ -0,0 +1,25 @@ +company_id === $company->id, 404); + + $data = $request->validate([ + 'division_id' => ['nullable', Rule::exists('company_divisions', 'id')->where('company_id', $company->id)], + ]); + + $warehouse->update(['division_id' => $data['division_id'] ?? null]); + + return redirect()->route('platform.companies.show', $company)->with('status', 'Warehouse division assignment updated.'); + } +} diff --git a/Modules/Company/Models/Company.php b/Modules/Company/Models/Company.php index 3457f8a3..0a3bb73e 100644 --- a/Modules/Company/Models/Company.php +++ b/Modules/Company/Models/Company.php @@ -5,6 +5,7 @@ namespace Modules\Company\Models; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; use Modules\Platform\app\Models\LicenseKey; @@ -35,6 +36,22 @@ class Company extends Model }); } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + + public function divisions(): HasMany + { + return $this->hasMany(CompanyDivision::class); + } + + public function directorAssignments(): HasMany + { + return $this->hasMany(CompanyDirectorAssignment::class); + } + public function users(): HasMany { return $this->hasMany(User::class); diff --git a/Modules/Company/Models/CompanyAddress.php b/Modules/Company/Models/CompanyAddress.php new file mode 100644 index 00000000..1345e8fe --- /dev/null +++ b/Modules/Company/Models/CompanyAddress.php @@ -0,0 +1,54 @@ + 'boolean', + 'metadata' => 'array', + 'latitude' => 'decimal:7', + 'longitude' => 'decimal:7', + ]; + + public function addressable(): MorphTo + { + return $this->morphTo(); + } + + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + + public function subdivision(): BelongsTo + { + return $this->belongsTo(CountrySubdivision::class, 'country_subdivision_id'); + } + + public function getFormattedAttribute(): string + { + return collect([ + $this->attention, + $this->line_1, + $this->line_2, + $this->line_3, + $this->locality, + $this->administrative_area, + $this->postal_code, + $this->country?->effective_name ?? $this->country?->name, + ])->filter()->implode(', '); + } +} diff --git a/Modules/Company/Models/CompanyDirectorAssignment.php b/Modules/Company/Models/CompanyDirectorAssignment.php new file mode 100644 index 00000000..9c2923ea --- /dev/null +++ b/Modules/Company/Models/CompanyDirectorAssignment.php @@ -0,0 +1,34 @@ + 'date', + 'ends_at' => 'date', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function division(): BelongsTo + { + return $this->belongsTo(CompanyDivision::class, 'company_division_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/Modules/Company/Models/CompanyDivision.php b/Modules/Company/Models/CompanyDivision.php new file mode 100644 index 00000000..20ea2f29 --- /dev/null +++ b/Modules/Company/Models/CompanyDivision.php @@ -0,0 +1,62 @@ + 'array', + ]; + + protected static function booted(): void + { + static::creating(function (CompanyDivision $division): void { + $division->uuid ??= (string) Str::uuid(); + $division->slug ??= Str::slug($division->name); + $division->status ??= 'active'; + }); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'company_division_user') + ->withPivot(['company_id', 'role', 'is_director']) + ->withTimestamps(); + } + + public function warehouses(): HasMany + { + return $this->hasMany(Warehouse::class, 'division_id'); + } + + public function directorAssignments(): HasMany + { + return $this->hasMany(CompanyDirectorAssignment::class, 'company_division_id'); + } +} diff --git a/Modules/Company/Resources/views/index.blade.php b/Modules/Company/Resources/views/index.blade.php index 357d551d..e6754a13 100644 --- a/Modules/Company/Resources/views/index.blade.php +++ b/Modules/Company/Resources/views/index.blade.php @@ -22,6 +22,7 @@ Name Users + Divisions Warehouses Licences Status @@ -40,6 +41,10 @@ {{ $company->users_count }} + + {{ $company->divisions_count }} + + {{ $company->warehouses_count }} @@ -100,7 +105,7 @@ @empty - + No companies found. diff --git a/Modules/Company/Resources/views/partials/address-form.blade.php b/Modules/Company/Resources/views/partials/address-form.blade.php new file mode 100644 index 00000000..9f9cb438 --- /dev/null +++ b/Modules/Company/Resources/views/partials/address-form.blade.php @@ -0,0 +1,47 @@ +@php($address = $address ?? null) +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
diff --git a/Modules/Company/Resources/views/show.blade.php b/Modules/Company/Resources/views/show.blade.php index 7feb94e2..d489c447 100644 --- a/Modules/Company/Resources/views/show.blade.php +++ b/Modules/Company/Resources/views/show.blade.php @@ -1,144 +1,140 @@
- + -
-
-

Users

-

{{ $company->users_count }}

-
- -
-

Warehouses

-

{{ $company->warehouses_count }}

-
- -
-

Licences

-

{{ $company->license_keys_count }}

-
- -
-

Payments

-

{{ $company->payments_count }}

-
+
+

Users

{{ $company->users_count }}

+

Divisions

{{ $company->divisions_count }}

+

Warehouses

{{ $company->warehouses_count }}

+

Licences

{{ $company->license_keys_count }}

+

Payments

{{ $company->payments_count }}

-
-

Company profile

-

Core tenant identity and operational status.

-
- - - {{ $company->is_system_company ? 'System' : ucfirst($company->status) }} - +

Company profile

Core tenant identity and operational status.

+ Edit company
-
-
-
Billing email
-
{{ $company->billing_email ?? '—' }}
-
- -
-
Timezone
-
{{ $company->timezone }}
-
- -
-
Slug
-
{{ $company->slug }}
-
- -
-
Created
-
{{ $company->created_at?->format('d M Y') }}
-
+
Billing email
{{ $company->billing_email ?? '—' }}
+
Status
{{ $company->is_system_company ? 'System' : ucfirst($company->status) }}
+
Timezone
{{ $company->timezone }}
+
Slug
{{ $company->slug }}

Quick actions

- - Edit company - - - - Back to companies - - + Back to companies @unless ($company->is_system_company) -
- @csrf - @method('DELETE') - -
+
@csrf @method('DELETE')
@endunless
-
+
+

Company address

+ @if ($company->address) +

{{ $company->address->formatted }}

+ @else +

No company address has been recorded.

+ @endif +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $company->address, 'defaultLabel' => 'Registered office']) +
+
+ +
-

Recent users

-
- @forelse ($recentUsers as $user) -
-

{{ $user->name }}

-

{{ $user->email }}

-
- @empty -

No users found.

- @endforelse -
+

Create division

+
+ @csrf + + + + + +
-

Recent warehouses

+

Director coverage

+
+ @csrf + + + + +
- @forelse ($recentWarehouses as $warehouse) -
-

{{ $warehouse->name }}

-

{{ $warehouse->code ?? '—' }}

-
- @empty -

No warehouses found.

- @endforelse -
-
- -
-

Recent licences

-
- @forelse ($recentLicenseKeys as $license) -
-

{{ ucfirst($license->plan ?? 'Licence') }}

-

{{ ucfirst($license->status ?? 'unknown') }}

-
- @empty -

No licences found.

- @endforelse + @forelse($company->directorAssignments as $assignment) +
{{ $assignment->user?->name }} — {{ $assignment->coverage_type === 'company' ? 'Whole company' : $assignment->division?->name }}
@csrf @method('DELETE')
+ @empty

No director coverage recorded.

@endforelse
+ + +
+

Warehouse division and address alignment

+

Warehouses remain tenant-scoped to the company. Assign a division only when that operating structure exists and record an optional warehouse operating address where needed.

+
+ @forelse($company->warehouses as $warehouse) +
+
+
+

{{ $warehouse->name }}

+

{{ $warehouse->code }} · {{ $warehouse->division?->name ?? 'Company-level warehouse' }}

+ @if ($warehouse->address) +

{{ $warehouse->address->formatted }}

+ @else +

No warehouse address recorded.

+ @endif +
+
+ @csrf @method('PUT') + + +
+
+ +
+ Edit warehouse address +
+ @csrf @method('PUT') + @include('company::partials.address-form', ['address' => $warehouse->address, 'defaultLabel' => 'Warehouse address']) +
+
+
+ @empty +

No warehouses exist for this company yet.

+ @endforelse +
+
+ +
+

Divisions

+ @forelse($company->divisions as $division) +
+
+

{{ $division->name }}

{{ $division->code ?? 'No code' }} · {{ ucfirst($division->status) }} · {{ $division->warehouses->count() }} warehouses

+
@csrf @method('DELETE')
+
+
@csrf @method('PUT')
+

Division address

@csrf @method('PUT')@include('company::partials.address-form', ['address' => $division->address, 'defaultLabel' => 'Operating address'])
+
+ @empty +
No divisions exist. Warehouses can remain directly under the company until divisions are introduced.
+ @endforelse +
diff --git a/Modules/Company/Routes/web.php b/Modules/Company/Routes/web.php index cc7e8e11..ef9d49f6 100644 --- a/Modules/Company/Routes/web.php +++ b/Modules/Company/Routes/web.php @@ -1,21 +1,27 @@ prefix('admin') ->name('platform.') ->group(function () { Route::resource('companies', CompanyController::class); + + Route::put('companies/{company}/address', [CompanyAddressController::class, 'updateCompany'])->name('companies.address.update'); + Route::post('companies/{company}/divisions', [CompanyDivisionController::class, 'store'])->name('companies.divisions.store'); + Route::put('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'update'])->name('companies.divisions.update'); + Route::delete('companies/{company}/divisions/{division}', [CompanyDivisionController::class, 'destroy'])->name('companies.divisions.destroy'); + Route::put('companies/{company}/divisions/{division}/address', [CompanyAddressController::class, 'updateDivision'])->name('companies.divisions.address.update'); + + Route::post('companies/{company}/division-memberships', [CompanyMembershipController::class, 'assignDivision'])->name('companies.division-memberships.store'); + Route::post('companies/{company}/director-assignments', [CompanyMembershipController::class, 'assignDirector'])->name('companies.director-assignments.store'); + Route::delete('companies/{company}/director-assignments/{assignment}', [CompanyMembershipController::class, 'removeDirector'])->name('companies.director-assignments.destroy'); + + Route::put('companies/{company}/warehouses/{warehouse}/division', [CompanyWarehouseAssignmentController::class, 'update'])->name('companies.warehouses.division.update'); + Route::put('companies/{company}/warehouses/{warehouse}/address', [CompanyAddressController::class, 'updateWarehouse'])->name('companies.warehouses.address.update'); }); diff --git a/Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php b/Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php new file mode 100644 index 00000000..7700a8b3 --- /dev/null +++ b/Modules/Company/Tests/Browser/CompanyAdministrationBrowserTest.php @@ -0,0 +1,36 @@ +firstOrCreate(['slug' => 'mpba-limited'], ['name' => 'MPBA Limited', 'is_system_company' => true, 'status' => 'active']); + $tenant = Company::query()->firstOrCreate(['slug' => 'sentinel'], ['name' => 'Sentinel', 'status' => 'active']); + Country::query()->firstOrCreate(['alpha2' => 'GB'], ['alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $admin = User::query()->firstOrCreate(['email' => 'company-admin-browser@example.test'], [ + 'company_id' => $system->id, + 'name' => 'Company Browser Admin', + 'password' => bcrypt('password'), + 'is_platform_admin' => true, + 'status' => 'active', + ]); + + $this->browse(function (Browser $browser) use ($admin, $tenant): void { + $browser->loginAs($admin) + ->visitRoute('platform.companies.show', $tenant) + ->assertSee('Company address') + ->assertSee('Create division') + ->assertSee('Director coverage') + ->assertSee('Warehouse division and address alignment') + ->assertSee('Divisions'); + }); + } +} diff --git a/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php b/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php new file mode 100644 index 00000000..e4666c5a --- /dev/null +++ b/Modules/Company/Tests/Feature/CompanyAdministrationRoutesTest.php @@ -0,0 +1,73 @@ + 'MPBA Limited', 'is_system_company' => true]); + $tenant = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $admin = User::create(['company_id' => $system->id, 'name' => 'Platform Admin', 'email' => 'admin@example.test', 'password' => 'password', 'is_platform_admin' => true]); + $director = User::create(['company_id' => $tenant->id, 'name' => 'Division Director', 'email' => 'director@example.test', 'password' => 'password']); + + $this->actingAs($admin)->put(route('platform.companies.address.update', $tenant), [ + 'line_1' => '1 Example Street', + 'locality' => 'London', + 'postal_code' => 'SW1A 1AA', + 'country_id' => $country->id, + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $this->actingAs($admin)->post(route('platform.companies.divisions.store', $tenant), [ + 'name' => 'Northern Operations', + 'code' => 'NORTH', + 'status' => 'active', + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $division = $tenant->divisions()->where('code', 'NORTH')->firstOrFail(); + + $this->actingAs($admin)->post(route('platform.companies.director-assignments.store', $tenant), [ + 'user_id' => $director->id, + 'coverage_type' => 'division', + 'company_division_id' => $division->id, + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $this->assertDatabaseHas('company_addresses', ['line_1' => '1 Example Street', 'country_id' => $country->id]); + $this->assertDatabaseHas('company_divisions', ['company_id' => $tenant->id, 'code' => 'NORTH']); + $this->assertDatabaseHas('company_director_assignments', ['company_id' => $tenant->id, 'company_division_id' => $division->id, 'user_id' => $director->id]); + } + public function test_platform_admin_can_update_warehouse_address(): void + { + $system = Company::create(['name' => 'MPBA Limited', 'is_system_company' => true]); + $tenant = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $warehouse = Warehouse::create(['company_id' => $tenant->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']); + $admin = User::create(['company_id' => $system->id, 'name' => 'Platform Admin', 'email' => 'warehouse-admin@example.test', 'password' => 'password', 'is_platform_admin' => true]); + + $this->actingAs($admin)->put(route('platform.companies.warehouses.address.update', [$tenant, $warehouse]), [ + 'label' => 'Warehouse address', + 'line_1' => '100 Distribution Park', + 'locality' => 'Birmingham', + 'postal_code' => 'B1 1AA', + 'country_id' => $country->id, + ])->assertRedirect(route('platform.companies.show', $tenant)); + + $this->assertDatabaseHas('company_addresses', [ + 'addressable_type' => Warehouse::class, + 'addressable_id' => $warehouse->id, + 'line_1' => '100 Distribution Park', + 'country_id' => $country->id, + ]); + } + +} diff --git a/Modules/Company/Tests/Feature/CompanyStructureTest.php b/Modules/Company/Tests/Feature/CompanyStructureTest.php new file mode 100644 index 00000000..c6e14706 --- /dev/null +++ b/Modules/Company/Tests/Feature/CompanyStructureTest.php @@ -0,0 +1,70 @@ + 'Abbott Logistics Limited', 'billing_email' => 'billing@abbott.example']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + + $company->address()->create([ + 'line_1' => '1 Warehouse Way', + 'locality' => 'London', + 'postal_code' => 'SW1A 1AA', + 'country_id' => $country->id, + ]); + + $company->address()->update(['line_1' => '2 Warehouse Way']); + + $this->assertSame(1, CompanyAddress::count()); + $this->assertSame('2 Warehouse Way', $company->fresh()->address->line_1); + $this->assertSame('United Kingdom', $company->fresh()->address->country->name); + } + + public function test_company_division_has_address_users_directors_and_optional_warehouses(): void + { + $company = Company::create(['name' => 'Sentinel', 'billing_email' => 'sentinel@example.test']); + $country = Country::create(['alpha2' => 'GB', 'alpha3' => 'GBR', 'numeric' => '826', 'name' => 'United Kingdom', 'is_active' => true]); + $user = User::create(['company_id' => $company->id, 'name' => 'Jane Director', 'email' => 'jane@example.test', 'password' => 'password']); + $division = $company->divisions()->create(['name' => 'North Operations', 'code' => 'NORTH']); + $warehouse = Warehouse::create(['company_id' => $company->id, 'division_id' => $division->id, 'code' => 'NTH', 'name' => 'North DC']); + + $division->address()->create([ + 'label' => 'Operating address', + 'line_1' => 'North Road', + 'locality' => 'Manchester', + 'country_id' => $country->id, + ]); + $division->users()->attach($user->id, ['company_id' => $company->id, 'role' => 'director', 'is_director' => true]); + CompanyDirectorAssignment::create(['company_id' => $company->id, 'company_division_id' => $division->id, 'user_id' => $user->id, 'coverage_type' => 'division']); + + $this->assertTrue($company->fresh()->divisions()->exists()); + $this->assertSame('North Operations', $warehouse->fresh()->division->name); + $this->assertSame('Manchester', $division->fresh()->address->locality); + $this->assertTrue($user->fresh()->divisions()->whereKey($division->id)->exists()); + $this->assertTrue($user->fresh()->directorAssignments()->where('company_division_id', $division->id)->exists()); + } + + public function test_warehouse_can_remain_at_company_level_without_division(): void + { + $company = Company::create(['name' => 'Company Level Warehousing']); + $warehouse = Warehouse::create(['company_id' => $company->id, 'code' => 'MAIN', 'name' => 'Main Warehouse']); + + $this->assertNull($warehouse->division_id); + $this->assertSame('Company Level Warehousing', $warehouse->company->name); + } +} diff --git a/Modules/Company/changelog.yaml b/Modules/Company/changelog.yaml index ece3cff1..55cc3e18 100644 --- a/Modules/Company/changelog.yaml +++ b/Modules/Company/changelog.yaml @@ -1,6 +1,14 @@ -latest: 1.1.0 +latest: 1.2.0 versions: + - version: 1.2.0 + date: 2026-07-02 + changes: + - Added one-address-per-owner company address records using an industry-standard address layout backed by the Countries module. + - Added company divisions with optional codes, status, soft deletes, one address per division, user memberships and director coverage. + - Added optional division assignment and proper address relationship support for warehouses. + - Added Company module migrations with foreign keys, unique constraints and performance indexes. + - Added feature and Dusk coverage for company addresses, divisions, director coverage and warehouse division behaviour. - version: 1.1.0 date: 2026-07-02 changes: @@ -8,12 +16,11 @@ versions: - Added company workbench show page with profile, KPI cards, recent users, warehouses and licences. - Added guarded deletion so the system company cannot be deleted. - Standardised Company module views on x-layouts::app and company::_form includes. + - version: 1.0.2 + date: 2026-07-02 + changes: + - Fixed company create/update validation when timezone is omitted from request payloads. - version: 1.0.0 date: 2026-07-02 changes: - Initial Company module skeleton. - -- version: 1.0.2 - date: 2026-07-02 - changes: - - Fixed company create/update validation when timezone is omitted from request payloads. diff --git a/Modules/Company/module.json b/Modules/Company/module.json index b11a0fb5..1689228d 100644 --- a/Modules/Company/module.json +++ b/Modules/Company/module.json @@ -1,7 +1,7 @@ { "name": "Company", "alias": "company", - "version": "1.1.0", + "version": "1.2.0", "description": "", "keywords": [], "priority": 0, diff --git a/Modules/Warehouse/app/Models/Warehouse.php b/Modules/Warehouse/app/Models/Warehouse.php index 361ec75c..911211b2 100644 --- a/Modules/Warehouse/app/Models/Warehouse.php +++ b/Modules/Warehouse/app/Models/Warehouse.php @@ -5,13 +5,16 @@ namespace Modules\Warehouse\app\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Support\Str; use Modules\Company\Models\Company; +use Modules\Company\Models\CompanyAddress; +use Modules\Company\Models\CompanyDivision; class Warehouse extends Model { protected $fillable = [ - 'company_id', 'uuid', + 'company_id', 'division_id', 'uuid', 'code', 'name', 'status', @@ -38,6 +41,17 @@ class Warehouse extends Model return $this->belongsTo(Company::class); } + + public function division(): BelongsTo + { + return $this->belongsTo(CompanyDivision::class, 'division_id'); + } + + public function address(): MorphOne + { + return $this->morphOne(CompanyAddress::class, 'addressable'); + } + public function validationRules(): HasMany { return $this->hasMany(WarehouseValidationRule::class); diff --git a/app/Models/User.php b/app/Models/User.php index 627681d5..0d0f2862 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,8 @@ namespace App\Models; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -12,6 +14,8 @@ use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; use Modules\Company\Models\Company; +use Modules\Company\Models\CompanyDirectorAssignment; +use Modules\Company\Models\CompanyDivision; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable implements PasskeyUser @@ -40,6 +44,19 @@ class User extends Authenticatable implements PasskeyUser return $this->belongsTo(Company::class); } + + public function divisions(): BelongsToMany + { + return $this->belongsToMany(CompanyDivision::class, 'company_division_user') + ->withPivot(['company_id', 'role', 'is_director']) + ->withTimestamps(); + } + + public function directorAssignments(): HasMany + { + return $this->hasMany(CompanyDirectorAssignment::class); + } + public function initials(): string { return Str::of($this->name)