added full module support

This commit is contained in:
Mark Gregory
2026-06-10 12:42:07 +01:00
parent 12d4eec076
commit c59b16d791
63 changed files with 1639 additions and 131 deletions
@@ -0,0 +1,19 @@
<?php
namespace Modules\Warehouse\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class WarehouseServiceProvider extends ServiceProvider
{
/**
* Bootstrap module services.
*/
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'warehouse');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
Route::middleware('web')->group(__DIR__ . '/../routes/web.php');
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Warehouse\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Warehouse\app\Models\Warehouse;
class WarehouseController extends Controller
{
/**
* Display tenant warehouses.
*/
public function index()
{
return view('warehouse::warehouses.index', [
'warehouses' => Warehouse::withCount('validationRules')
->where('company_id', auth()->user()->company_id)
->orderBy('name')
->paginate(20),
]);
}
/**
* Create a tenant warehouse.
*/
public function store(Request $request)
{
$data = $request->validate([
'code' => ['required', 'string', 'max:50'],
'name' => ['required', 'string', 'max:255'],
'address' => ['nullable', 'string', 'max:2000'],
]);
Warehouse::create([
...$data,
'company_id' => $request->user()->company_id,
'status' => 'active',
'timezone' => $request->user()->company->timezone ?? 'Europe/London',
]);
return back()->with('status', 'Warehouse created.');
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Warehouse\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\Warehouse\app\Models\Warehouse;
use Modules\Warehouse\app\Models\WarehouseValidationRule;
class WarehouseDashboardController extends Controller
{
/**
* Show tenant warehouse dashboard.
*/
public function __invoke()
{
$companyId = auth()->user()->company_id;
return view('warehouse::warehouses.dashboard', [
'warehousesCount' => Warehouse::where('company_id', $companyId)->count(),
'rulesCount' => WarehouseValidationRule::whereHas('warehouse', fn ($query) => $query->where('company_id', $companyId))->count(),
'warehouses' => Warehouse::withCount('validationRules')->where('company_id', $companyId)->orderBy('name')->get(),
]);
}
}
@@ -0,0 +1,56 @@
<?php
namespace Modules\Warehouse\app\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Warehouse\app\Models\Warehouse;
use Modules\Warehouse\app\Models\WarehouseValidationRule;
class WarehouseValidationRuleController extends Controller
{
/**
* Display warehouse validation rules.
*/
public function index()
{
$companyId = auth()->user()->company_id;
return view('warehouse::rules.index', [
'warehouses' => Warehouse::where('company_id', $companyId)->orderBy('name')->get(),
'rules' => WarehouseValidationRule::with('warehouse')
->whereHas('warehouse', fn ($query) => $query->where('company_id', $companyId))
->latest()
->paginate(20),
]);
}
/**
* Create a validation rule for a warehouse.
*/
public function store(Request $request)
{
$companyId = $request->user()->company_id;
$data = $request->validate([
'warehouse_id' => ['required', 'exists:warehouses,id'],
'name' => ['required', 'string', 'max:255'],
'rule_key' => ['required', 'string', 'max:100'],
'severity' => ['required', 'in:info,warning,error,blocker'],
'configuration' => ['nullable', 'json'],
]);
$warehouse = Warehouse::where('company_id', $companyId)->findOrFail($data['warehouse_id']);
WarehouseValidationRule::create([
'warehouse_id' => $warehouse->id,
'name' => $data['name'],
'rule_key' => $data['rule_key'],
'severity' => $data['severity'],
'is_active' => true,
'configuration' => $data['configuration'] ? json_decode($data['configuration'], true) : null,
]);
return back()->with('status', 'Validation rule created.');
}
}
@@ -0,0 +1,37 @@
<?php
namespace Modules\Warehouse\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Modules\Platform\app\Models\Company;
class Warehouse extends Model
{
protected $fillable = [
'company_id', 'uuid', 'code', 'name', 'status', 'address', 'timezone', 'settings',
];
protected $casts = ['settings' => 'array'];
protected static function booted(): void
{
static::creating(function (Warehouse $warehouse): void {
$warehouse->uuid ??= (string) Str::uuid();
$warehouse->status ??= 'active';
$warehouse->timezone ??= 'Europe/London';
});
}
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
public function validationRules(): HasMany
{
return $this->hasMany(WarehouseValidationRule::class);
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Warehouse\app\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WarehouseValidationRule extends Model
{
protected $fillable = [
'warehouse_id', 'name', 'rule_key', 'severity', 'is_active', 'configuration',
];
protected $casts = [
'is_active' => 'boolean',
'configuration' => 'array',
];
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
}
@@ -0,0 +1,49 @@
<?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('warehouses', function (Blueprint $table): void {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->uuid('uuid')->unique();
$table->string('code');
$table->string('name');
$table->string('status')->default('active')->index();
$table->text('address')->nullable();
$table->string('timezone')->default('Europe/London');
$table->json('settings')->nullable();
$table->timestamps();
$table->unique(['company_id', 'code']);
});
Schema::create('warehouse_validation_rules', function (Blueprint $table): void {
$table->id();
$table->foreignId('warehouse_id')->constrained('warehouses')->cascadeOnDelete();
$table->string('name');
$table->string('rule_key')->index();
$table->string('severity')->default('error');
$table->boolean('is_active')->default(true)->index();
$table->json('configuration')->nullable();
$table->timestamps();
$table->unique(['warehouse_id', 'rule_key']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('warehouse_validation_rules');
Schema::dropIfExists('warehouses');
}
};
+1
View File
@@ -0,0 +1 @@
{"name":"Warehouse","alias":"warehouse","description":"Warehouse management, warehouse-specific validation rules and operational dashboard.","keywords":["wms","warehouse"],"priority":1,"providers":["Modules\\Warehouse\\Providers\\WarehouseServiceProvider"],"files":[]}
@@ -0,0 +1,18 @@
<x-layouts::app :title="__('Validation Rules')">
<div class="space-y-6">
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<h1 class="text-2xl font-bold">Warehouse validation rules</h1>
<form method="POST" action="{{ route('warehouse-rules.store') }}" class="mt-5 grid gap-4 md:grid-cols-6">
@csrf
<select name="warehouse_id" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">@foreach($warehouses as $warehouse)<option value="{{ $warehouse->id }}">{{ $warehouse->name }}</option>@endforeach</select>
<input name="name" placeholder="Rule name" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<input name="rule_key" placeholder="stock.location.required" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<select name="severity" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950"><option>error</option><option>warning</option><option>blocker</option><option>info</option></select>
<input name="configuration" placeholder='{"min": 1}' class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Add rule</button>
</form>
</div>
<div class="overflow-x-auto rounded-2xl bg-white shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"><table class="min-w-full text-sm"><thead><tr class="border-b"><th class="p-4 text-left">Warehouse</th><th class="p-4 text-left">Rule</th><th class="p-4 text-left">Key</th><th class="p-4 text-left">Severity</th></tr></thead><tbody>@foreach($rules as $rule)<tr class="border-b border-zinc-100 dark:border-zinc-800"><td class="p-4">{{ $rule->warehouse->name }}</td><td class="p-4 font-semibold">{{ $rule->name }}</td><td class="p-4"><code>{{ $rule->rule_key }}</code></td><td class="p-4">{{ $rule->severity }}</td></tr>@endforeach</tbody></table></div>
{{ $rules->links() }}
</div>
</x-layouts::app>
@@ -0,0 +1,27 @@
<x-layouts::app :title="__('WMS Dashboard')">
<div class="space-y-6">
<section class="overflow-hidden rounded-3xl bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-950 p-6 text-white shadow-xl sm:p-8">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-300">Warehouse management</p>
<h1 class="mt-3 text-3xl font-bold tracking-tight sm:text-5xl">{{ auth()->user()->company->name }}</h1>
<p class="mt-3 max-w-2xl text-slate-300">High-performance tenant-isolated warehouse operations with per-warehouse validation and licence-aware access.</p>
</div>
<div class="grid grid-cols-2 gap-3 sm:min-w-80">
<div class="rounded-2xl bg-white/10 p-4 backdrop-blur"><p class="text-sm text-slate-300">Warehouses</p><p class="mt-2 text-3xl font-bold">{{ $warehousesCount }}</p></div>
<div class="rounded-2xl bg-white/10 p-4 backdrop-blur"><p class="text-sm text-slate-300">Rules</p><p class="mt-2 text-3xl font-bold">{{ $rulesCount }}</p></div>
</div>
</div>
</section>
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@forelse($warehouses as $warehouse)
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div class="flex items-start justify-between gap-4"><div><p class="text-sm font-semibold text-cyan-600">{{ $warehouse->code }}</p><h2 class="mt-1 text-xl font-bold">{{ $warehouse->name }}</h2></div><span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">{{ $warehouse->status }}</span></div>
<p class="mt-4 text-sm text-zinc-500">{{ $warehouse->validation_rules_count }} validation rules configured.</p>
</article>
@empty
<article class="rounded-2xl border border-dashed border-zinc-300 bg-white p-8 text-center dark:border-zinc-800 dark:bg-zinc-900 md:col-span-2 xl:col-span-3"><h2 class="text-xl font-bold">Create your first warehouse</h2><p class="mt-2 text-zinc-500">Warehouses are isolated to your company tenant.</p><a href="{{ route('warehouses.index') }}" class="mt-5 inline-flex rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Add warehouse</a></article>
@endforelse
</section>
</div>
</x-layouts::app>
@@ -0,0 +1,20 @@
<x-layouts::app :title="__('Warehouses')">
<div class="space-y-6">
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<h1 class="text-2xl font-bold">Warehouses</h1>
<form method="POST" action="{{ route('warehouses.store') }}" class="mt-5 grid gap-4 md:grid-cols-4">
@csrf
<input name="code" placeholder="Code" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<input name="name" placeholder="Warehouse name" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950" required>
<input name="address" placeholder="Address" class="rounded-xl border-zinc-300 dark:border-zinc-700 dark:bg-zinc-950">
<button class="rounded-xl bg-zinc-900 px-4 py-2 font-semibold text-white dark:bg-white dark:text-zinc-900">Create</button>
</form>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach($warehouses as $warehouse)
<article class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"><p class="text-sm text-cyan-600">{{ $warehouse->code }}</p><h2 class="mt-1 text-xl font-bold">{{ $warehouse->name }}</h2><p class="mt-3 text-sm text-zinc-500">{{ $warehouse->validation_rules_count }} rules</p></article>
@endforeach
</div>
{{ $warehouses->links() }}
</div>
</x-layouts::app>
+15
View File
@@ -0,0 +1,15 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Warehouse\app\Http\Controllers\WarehouseController;
use Modules\Warehouse\app\Http\Controllers\WarehouseDashboardController;
use Modules\Warehouse\app\Http\Controllers\WarehouseValidationRuleController;
Route::middleware(['auth', 'verified', 'tenant.assigned', 'tenant.licensed'])
->group(function (): void {
Route::get('/dashboard', WarehouseDashboardController::class)->name('dashboard');
Route::get('/warehouses', [WarehouseController::class, 'index'])->name('warehouses.index');
Route::post('/warehouses', [WarehouseController::class, 'store'])->name('warehouses.store');
Route::get('/warehouse-rules', [WarehouseValidationRuleController::class, 'index'])->name('warehouse-rules.index');
Route::post('/warehouse-rules', [WarehouseValidationRuleController::class, 'store'])->name('warehouse-rules.store');
});
@@ -0,0 +1,32 @@
<?php
namespace Modules\Warehouse\tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Platform\app\Models\Company;
use Modules\Platform\app\Models\LicenseKey;
use Modules\Warehouse\app\Models\Warehouse;
use Tests\TestCase;
class WarehouseIsolationTest extends TestCase
{
use RefreshDatabase;
public function test_company_user_only_sees_their_warehouses(): void
{
$companyA = Company::create(['name' => 'A Ltd', 'slug' => 'a-ltd']);
$companyB = Company::create(['name' => 'B Ltd', 'slug' => 'b-ltd']);
LicenseKey::issueFor($companyA, 'A licence');
$user = User::factory()->create(['company_id' => $companyA->id]);
Warehouse::create(['company_id' => $companyA->id, 'code' => 'A1', 'name' => 'A Warehouse']);
Warehouse::create(['company_id' => $companyB->id, 'code' => 'B1', 'name' => 'B Warehouse']);
$this->actingAs($user)
->get('/warehouses')
->assertOk()
->assertSee('A Warehouse')
->assertDontSee('B Warehouse');
}
}