Repo rebuild

This commit is contained in:
Mark Gregory
2026-09-15 12:33:28 +01:00
commit be28c4feec
5629 changed files with 317074 additions and 0 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,58 @@
<?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\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', 'division_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 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);
}
}
@@ -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,57 @@
<x-layouts::app :title="__('Validation Rules')">
<div class="space-y-6">
<x-wms.page-hero eyebrow="Administration" title="Validation Rules"
description="Configure warehouse validation controls, severity levels and operational rule keys."
icon="fa-solid fa-shield-halved" />
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Add validation rule</h2>
<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,36 @@
<x-layouts::app :title="__('WMS Dashboard')">
<div class="space-y-6">
<x-wms.page-hero eyebrow="MPBA WMS" :title="auth()->user()->company->name"
description="Tenant warehouse dashboard for operational status, warehouse coverage and validation readiness."
:stats="[
['label' => 'Warehouses', 'value' => $warehouses->count(), 'icon' => 'fa-solid fa-warehouse'],
['label' => 'Status', 'value' => 'Live', 'icon' => 'fa-solid fa-circle-check'],
]" />
<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,32 @@
<x-layouts::app :title="__('Warehouses')">
<div class="space-y-6">
<x-wms.page-hero eyebrow="Administration" title="Warehouses"
description="Create and manage warehouse records, tenant ownership and operational locations."
icon="fa-solid fa-warehouse" />
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<h2 class="text-lg font-bold text-zinc-900 dark:text-white">Create warehouse</h2>
<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\Company\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');
}
}