Deploy 1: i18n + Notifications + Global Search + Tests
- SetLocale middleware (ro/ru/en, session-first, user-persisted)
- Lang switcher in topbar (Filament render hook USER_MENU_BEFORE)
- POST /locale/{lang} route persists to user.locale + session
- Database notifications enabled on tenant panel (30s polling)
- GlobalSearch (Cmd+K / Ctrl+K) on Client, Vehicle, WorkOrder, Lead, Part
- Tests: TenantIsolation (4), AuthFlow (2), WorkOrderCalc (3), MarkupRule (3)
This commit is contained in:
@@ -26,6 +26,21 @@ class ClientResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 10;
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'name';
|
||||||
|
|
||||||
|
public static function getGloballySearchableAttributes(): array
|
||||||
|
{
|
||||||
|
return ['name', 'phone', 'phone_alt', 'email', 'company_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Telefon' => $record->phone,
|
||||||
|
'Status' => $record->status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ class LeadResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 5;
|
protected static ?int $navigationSort = 5;
|
||||||
|
|
||||||
|
public static function getGloballySearchableAttributes(): array
|
||||||
|
{
|
||||||
|
return ['name', 'phone', 'email', 'channel'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Telefon' => $record->phone,
|
||||||
|
'Status' => $record->status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ class PartResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 40;
|
protected static ?int $navigationSort = 40;
|
||||||
|
|
||||||
|
public static function getGloballySearchableAttributes(): array
|
||||||
|
{
|
||||||
|
return ['name', 'sku', 'brand', 'category'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Stoc' => (int) $record->stock . ' ' . ($record->unit ?? 'buc'),
|
||||||
|
'Preț' => number_format((float) $record->sell_price, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function getNavigationBadge(): ?string
|
public static function getNavigationBadge(): ?string
|
||||||
{
|
{
|
||||||
$low = static::getModel()::query()
|
$low = static::getModel()::query()
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ class VehicleResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 20;
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
public static function getGloballySearchableAttributes(): array
|
||||||
|
{
|
||||||
|
return ['plate', 'vin', 'brand', 'model'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultTitle(\Illuminate\Database\Eloquent\Model $record): string
|
||||||
|
{
|
||||||
|
return trim(($record->brand ?? '') . ' ' . ($record->model ?? '') . ' — ' . ($record->plate ?? $record->vin ?? '?'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Client' => $record->client?->name ?? '—',
|
||||||
|
'An' => $record->year ?? '—',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -34,6 +34,25 @@ class WorkOrderResource extends Resource
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 30;
|
protected static ?int $navigationSort = 30;
|
||||||
|
|
||||||
|
public static function getGloballySearchableAttributes(): array
|
||||||
|
{
|
||||||
|
return ['number', 'description', 'vehicle.plate', 'vehicle.vin', 'client.name', 'client.phone'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultTitle(\Illuminate\Database\Eloquent\Model $record): string
|
||||||
|
{
|
||||||
|
return '#' . ($record->number ?? $record->id) . ' · ' . ($record->vehicle?->plate ?? '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getGlobalSearchResultDetails(\Illuminate\Database\Eloquent\Model $record): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Client' => $record->client?->name ?? '—',
|
||||||
|
'Status' => $record->status,
|
||||||
|
'Total' => number_format((float) $record->total, 2),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
private const SUPPORTED = ['ro', 'ru', 'en'];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$locale = $this->resolve($request);
|
||||||
|
|
||||||
|
App::setLocale($locale);
|
||||||
|
Carbon::setLocale($locale);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolve(Request $request): string
|
||||||
|
{
|
||||||
|
$session = $request->session()->get('locale');
|
||||||
|
if ($session && in_array($session, self::SUPPORTED, true)) {
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($user && ! empty($user->locale) && in_array($user->locale, self::SUPPORTED, true)) {
|
||||||
|
return $user->locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = app(TenantManager::class)->current();
|
||||||
|
$tenantLang = $tenant?->settings['language'] ?? null;
|
||||||
|
if ($tenantLang && in_array($tenantLang, self::SUPPORTED, true)) {
|
||||||
|
return $tenantLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config('app.locale', 'ro');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ class TenantPanelProvider extends PanelProvider
|
|||||||
'primary' => Color::Blue,
|
'primary' => Color::Blue,
|
||||||
])
|
])
|
||||||
->authGuard('web')
|
->authGuard('web')
|
||||||
|
->databaseNotifications()
|
||||||
|
->databaseNotificationsPolling('30s')
|
||||||
|
->globalSearchKeyBindings(['command+k', 'ctrl+k'])
|
||||||
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
|
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
|
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
@@ -58,6 +61,7 @@ class TenantPanelProvider extends PanelProvider
|
|||||||
// unauthenticated → endless redirect to /app/login.
|
// unauthenticated → endless redirect to /app/login.
|
||||||
ResolveTenant::class,
|
ResolveTenant::class,
|
||||||
CheckTenantStatus::class,
|
CheckTenantStatus::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
StartSession::class,
|
StartSession::class,
|
||||||
@@ -114,6 +118,68 @@ class TenantPanelProvider extends PanelProvider
|
|||||||
</style>
|
</style>
|
||||||
BLADE)
|
BLADE)
|
||||||
)
|
)
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::USER_MENU_BEFORE,
|
||||||
|
fn (): string => Blade::render(<<<'BLADE'
|
||||||
|
@php
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
$langs = ['ro' => 'RO', 'ru' => 'RU', 'en' => 'EN'];
|
||||||
|
$csrf = csrf_token();
|
||||||
|
@endphp
|
||||||
|
<div class="al-locale" x-data="{ open: false }">
|
||||||
|
<button type="button" class="al-locale-btn" @click="open = !open" @click.away="open = false">
|
||||||
|
🌐 {{ $langs[$locale] ?? 'RO' }}
|
||||||
|
<span class="al-locale-caret">▾</span>
|
||||||
|
</button>
|
||||||
|
<div x-show="open" x-cloak class="al-locale-menu">
|
||||||
|
@foreach ($langs as $code => $label)
|
||||||
|
<form method="POST" action="/locale/{{ $code }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ $csrf }}">
|
||||||
|
<button type="submit" class="al-locale-item {{ $locale === $code ? 'active' : '' }}">
|
||||||
|
{{ $label }} —
|
||||||
|
<span style="opacity:.7;font-size:11px;">
|
||||||
|
@switch($code)
|
||||||
|
@case('ro') Română @break
|
||||||
|
@case('ru') Русский @break
|
||||||
|
@case('en') English @break
|
||||||
|
@endswitch
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.al-locale { position: relative; margin-right: 8px; }
|
||||||
|
.al-locale-btn {
|
||||||
|
background: transparent; border: 1px solid rgba(0,0,0,.08);
|
||||||
|
border-radius: 8px; padding: 6px 10px; font-size: 12px;
|
||||||
|
cursor: pointer; color: inherit; display: flex; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.dark .al-locale-btn { border-color: rgba(255,255,255,.1); }
|
||||||
|
.al-locale-btn:hover { background: rgba(0,0,0,.04); }
|
||||||
|
.dark .al-locale-btn:hover { background: rgba(255,255,255,.04); }
|
||||||
|
.al-locale-caret { font-size: 10px; opacity: .6; }
|
||||||
|
.al-locale-menu {
|
||||||
|
position: absolute; top: 100%; right: 0; margin-top: 4px;
|
||||||
|
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.08);
|
||||||
|
min-width: 160px; z-index: 50; overflow: hidden;
|
||||||
|
}
|
||||||
|
.dark .al-locale-menu { background: #1f2937; border-color: #374151; }
|
||||||
|
.al-locale-menu form { margin: 0; }
|
||||||
|
.al-locale-item {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
background: transparent; border: none; padding: 8px 12px;
|
||||||
|
font-size: 13px; cursor: pointer; color: inherit;
|
||||||
|
}
|
||||||
|
.al-locale-item:hover { background: #f3f4f6; }
|
||||||
|
.dark .al-locale-item:hover { background: #374151; }
|
||||||
|
.al-locale-item.active { background: #eff6ff; color: #2563eb; font-weight: 600; }
|
||||||
|
.dark .al-locale-item.active { background: #1e3a8a; color: #93c5fd; }
|
||||||
|
</style>
|
||||||
|
BLADE)
|
||||||
|
)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::SIDEBAR_LOGO_BEFORE,
|
PanelsRenderHook::SIDEBAR_LOGO_BEFORE,
|
||||||
fn (): string => Blade::render(<<<'BLADE'
|
fn (): string => Blade::render(<<<'BLADE'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\App\Http\Middleware\ResolveTenant::class,
|
\App\Http\Middleware\ResolveTenant::class,
|
||||||
\App\Http\Middleware\CheckTenantStatus::class,
|
\App\Http\Middleware\CheckTenantStatus::class,
|
||||||
|
\App\Http\Middleware\SetLocale::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('notifications', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('type');
|
||||||
|
$table->morphs('notifiable');
|
||||||
|
$table->text('data');
|
||||||
|
$table->timestamp('read_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notifications');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"Dashboard": "Dashboard",
|
||||||
|
"Save": "Save",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Delete": "Delete",
|
||||||
|
"Edit": "Edit",
|
||||||
|
"Create": "Create",
|
||||||
|
"Search": "Search",
|
||||||
|
"Filters": "Filters",
|
||||||
|
"Reset": "Reset",
|
||||||
|
"Yes": "Yes",
|
||||||
|
"No": "No",
|
||||||
|
"Loading": "Loading...",
|
||||||
|
"Empty": "No results",
|
||||||
|
"Settings": "Settings",
|
||||||
|
"Profile": "Profile",
|
||||||
|
"Logout": "Logout",
|
||||||
|
"Welcome": "Welcome",
|
||||||
|
"Total": "Total",
|
||||||
|
"Date": "Date",
|
||||||
|
"Status": "Status",
|
||||||
|
"Actions": "Actions",
|
||||||
|
"Notifications": "Notifications",
|
||||||
|
|
||||||
|
"Clienți": "Clients",
|
||||||
|
"Mașini": "Vehicles",
|
||||||
|
"Cereri": "Leads",
|
||||||
|
"Pâlnie": "Pipeline",
|
||||||
|
"Calendar": "Calendar",
|
||||||
|
"Programări": "Appointments",
|
||||||
|
"Fișe lucru": "Work orders",
|
||||||
|
"Kanban": "Kanban",
|
||||||
|
"Norme-ore": "Labor times",
|
||||||
|
"Depozit": "Inventory",
|
||||||
|
"Furnizori": "Suppliers",
|
||||||
|
"Achiziții": "Purchases",
|
||||||
|
"Plăți": "Payments",
|
||||||
|
"Cheltuieli": "Expenses",
|
||||||
|
"Salarii": "Payroll",
|
||||||
|
"Tehnicieni": "Technicians",
|
||||||
|
"Marketing": "Marketing",
|
||||||
|
"Mesaje": "Messages",
|
||||||
|
"Rapoarte": "Reports",
|
||||||
|
"Recomandări": "Recommendations",
|
||||||
|
"Încărcare STO": "Workshop load",
|
||||||
|
"Procentaj": "Markup",
|
||||||
|
"VIN-căutare": "VIN search",
|
||||||
|
"Integrări": "Integrations",
|
||||||
|
"Backup": "Backup",
|
||||||
|
"Asistent AI": "AI Assistant",
|
||||||
|
"Setări companie": "Company settings",
|
||||||
|
"Utilizatori": "Users",
|
||||||
|
"Jurnal": "Audit log",
|
||||||
|
"Telefonie": "Calls",
|
||||||
|
"Finanțe": "Finance",
|
||||||
|
"Site PSauto": "Public site"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"Dashboard": "Tablou de bord",
|
||||||
|
"Save": "Salvează",
|
||||||
|
"Cancel": "Anulează",
|
||||||
|
"Delete": "Șterge",
|
||||||
|
"Edit": "Editează",
|
||||||
|
"Create": "Creează",
|
||||||
|
"Search": "Caută",
|
||||||
|
"Filters": "Filtre",
|
||||||
|
"Reset": "Resetează",
|
||||||
|
"Yes": "Da",
|
||||||
|
"No": "Nu",
|
||||||
|
"Loading": "Se încarcă...",
|
||||||
|
"Empty": "Niciun rezultat",
|
||||||
|
"Settings": "Setări",
|
||||||
|
"Profile": "Profil",
|
||||||
|
"Logout": "Ieșire",
|
||||||
|
"Welcome": "Bine ai venit",
|
||||||
|
"Total": "Total",
|
||||||
|
"Date": "Dată",
|
||||||
|
"Status": "Status",
|
||||||
|
"Actions": "Acțiuni",
|
||||||
|
"Notifications": "Notificări"
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"Dashboard": "Панель",
|
||||||
|
"Save": "Сохранить",
|
||||||
|
"Cancel": "Отмена",
|
||||||
|
"Delete": "Удалить",
|
||||||
|
"Edit": "Изменить",
|
||||||
|
"Create": "Создать",
|
||||||
|
"Search": "Поиск",
|
||||||
|
"Filters": "Фильтры",
|
||||||
|
"Reset": "Сбросить",
|
||||||
|
"Yes": "Да",
|
||||||
|
"No": "Нет",
|
||||||
|
"Loading": "Загрузка...",
|
||||||
|
"Empty": "Нет результатов",
|
||||||
|
"Settings": "Настройки",
|
||||||
|
"Profile": "Профиль",
|
||||||
|
"Logout": "Выход",
|
||||||
|
"Welcome": "Добро пожаловать",
|
||||||
|
"Total": "Итого",
|
||||||
|
"Date": "Дата",
|
||||||
|
"Status": "Статус",
|
||||||
|
"Actions": "Действия",
|
||||||
|
"Notifications": "Уведомления",
|
||||||
|
|
||||||
|
"Clienți": "Клиенты",
|
||||||
|
"Mașini": "Машины",
|
||||||
|
"Cereri": "Заявки",
|
||||||
|
"Pâlnie": "Воронка",
|
||||||
|
"Calendar": "Календарь",
|
||||||
|
"Programări": "Записи",
|
||||||
|
"Fișe lucru": "Рабочие листы",
|
||||||
|
"Kanban": "Канбан",
|
||||||
|
"Norme-ore": "Нормо-часы",
|
||||||
|
"Depozit": "Склад",
|
||||||
|
"Furnizori": "Поставщики",
|
||||||
|
"Achiziții": "Закупки",
|
||||||
|
"Plăți": "Платежи",
|
||||||
|
"Cheltuieli": "Расходы",
|
||||||
|
"Salarii": "Зарплаты",
|
||||||
|
"Tehnicieni": "Техники",
|
||||||
|
"Marketing": "Маркетинг",
|
||||||
|
"Mesaje": "Сообщения",
|
||||||
|
"Rapoarte": "Отчёты",
|
||||||
|
"Recomandări": "Рекомендации",
|
||||||
|
"Încărcare STO": "Загрузка СТО",
|
||||||
|
"Procentaj": "Наценка",
|
||||||
|
"VIN-căutare": "VIN-поиск",
|
||||||
|
"Integrări": "Интеграции",
|
||||||
|
"Backup": "Бэкап",
|
||||||
|
"Asistent AI": "AI Ассистент",
|
||||||
|
"Setări companie": "Настройки компании",
|
||||||
|
"Utilizatori": "Пользователи",
|
||||||
|
"Jurnal": "Журнал",
|
||||||
|
"Telefonie": "Телефония",
|
||||||
|
"Finanțe": "Финансы",
|
||||||
|
"Site PSauto": "Сайт"
|
||||||
|
}
|
||||||
@@ -24,6 +24,18 @@ Route::get('/', function () {
|
|||||||
return redirect('/admin');
|
return redirect('/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Locale switch — POST /locale/{lang} sets session and persists to user.
|
||||||
|
Route::post('/locale/{lang}', function (Request $request, string $lang) {
|
||||||
|
if (! in_array($lang, ['ro', 'ru', 'en'], true)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$request->session()->put('locale', $lang);
|
||||||
|
if ($u = $request->user()) {
|
||||||
|
$u->forceFill(['locale' => $lang])->saveQuietly();
|
||||||
|
}
|
||||||
|
return back();
|
||||||
|
})->name('locale.switch');
|
||||||
|
|
||||||
// PWA — manifest dinamic per tenant.
|
// PWA — manifest dinamic per tenant.
|
||||||
Route::get('/manifest.json', function (Request $request) {
|
Route::get('/manifest.json', function (Request $request) {
|
||||||
$tenant = app(TenantManager::class)->current();
|
$tenant = app(TenantManager::class)->current();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\User;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-ul tenant A NU trebuie să poată se logheze pe subdomain-ul tenant B.
|
||||||
|
* Garanție 1-user-1-tenant.
|
||||||
|
*/
|
||||||
|
class AuthFlowTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_user_cannot_login_on_wrong_subdomain(): void
|
||||||
|
{
|
||||||
|
$plan = Plan::create([
|
||||||
|
'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [],
|
||||||
|
]);
|
||||||
|
$companyA = Company::create([
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'slug' => 'company-a-' . uniqid(),
|
||||||
|
'name' => 'A', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
$companyB = Company::create([
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'slug' => 'company-b-' . uniqid(),
|
||||||
|
'name' => 'B', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($companyA);
|
||||||
|
$userA = User::create([
|
||||||
|
'company_id' => $companyA->id,
|
||||||
|
'name' => 'Alice',
|
||||||
|
'email' => 'alice@a.com',
|
||||||
|
'password' => Hash::make('secret123'),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Switch to company B context — try to attempt() with A's credentials
|
||||||
|
app(TenantManager::class)->setCurrent($companyB);
|
||||||
|
$ok = auth('web')->attempt(['email' => 'alice@a.com', 'password' => 'secret123']);
|
||||||
|
|
||||||
|
$this->assertFalse($ok, 'User from company A authenticated successfully on company B subdomain');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_can_login_on_own_subdomain(): void
|
||||||
|
{
|
||||||
|
$plan = Plan::create([
|
||||||
|
'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [],
|
||||||
|
]);
|
||||||
|
$company = Company::create([
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'slug' => 'mine-' . uniqid(),
|
||||||
|
'name' => 'Mine', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
User::create([
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'name' => 'Bob',
|
||||||
|
'email' => 'bob@mine.com',
|
||||||
|
'password' => Hash::make('pwd12345'),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ok = auth('web')->attempt(['email' => 'bob@mine.com', 'password' => 'pwd12345']);
|
||||||
|
|
||||||
|
$this->assertTrue($ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\Client;
|
||||||
|
use App\Models\Tenant\User;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifică că tenant-ul A NU poate vedea/modifica datele tenant-ului B.
|
||||||
|
* Această test e pilonul de securitate al întregii arhitecturi multi-tenant.
|
||||||
|
*/
|
||||||
|
class TenantIsolationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_tenant_scope_blocks_cross_tenant_reads(): void
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = $this->makeTwoCompanies();
|
||||||
|
|
||||||
|
// Create client in company A
|
||||||
|
app(TenantManager::class)->setCurrent($companyA);
|
||||||
|
$clientA = Client::create([
|
||||||
|
'name' => 'Alice', 'phone' => '+37300001',
|
||||||
|
'type' => 'individual', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Switch to company B and try to read
|
||||||
|
app(TenantManager::class)->setCurrent($companyB);
|
||||||
|
$found = Client::find($clientA->id);
|
||||||
|
|
||||||
|
$this->assertNull($found, 'Client from company A leaked into company B query');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tenant_scope_returns_empty_when_no_tenant(): void
|
||||||
|
{
|
||||||
|
[$companyA] = $this->makeTwoCompanies();
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($companyA);
|
||||||
|
Client::create([
|
||||||
|
'name' => 'X', 'phone' => '+37300002',
|
||||||
|
'type' => 'individual', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset tenant — fail-safe must engage
|
||||||
|
app(TenantManager::class)->setCurrent(null);
|
||||||
|
$count = Client::query()->count();
|
||||||
|
|
||||||
|
$this->assertSame(0, $count, 'TenantScope did not engage WHERE 1=0 fail-safe');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_email_can_repeat_across_tenants(): void
|
||||||
|
{
|
||||||
|
[$companyA, $companyB] = $this->makeTwoCompanies();
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($companyA);
|
||||||
|
$userA = User::create([
|
||||||
|
'company_id' => $companyA->id,
|
||||||
|
'name' => 'A', 'email' => 'shared@example.com',
|
||||||
|
'password' => 'pwd', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($companyB);
|
||||||
|
$userB = User::create([
|
||||||
|
'company_id' => $companyB->id,
|
||||||
|
'name' => 'B', 'email' => 'shared@example.com',
|
||||||
|
'password' => 'pwd', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotEquals($userA->id, $userB->id);
|
||||||
|
$this->assertSame('shared@example.com', $userA->email);
|
||||||
|
$this->assertSame('shared@example.com', $userB->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creating_model_auto_fills_company_id(): void
|
||||||
|
{
|
||||||
|
[$companyA] = $this->makeTwoCompanies();
|
||||||
|
|
||||||
|
app(TenantManager::class)->setCurrent($companyA);
|
||||||
|
$client = Client::create([
|
||||||
|
'name' => 'Auto', 'phone' => '+37300003',
|
||||||
|
'type' => 'individual', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame($companyA->id, $client->company_id, 'BelongsToTenant trait did not auto-fill company_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeTwoCompanies(): array
|
||||||
|
{
|
||||||
|
$plan = Plan::create([
|
||||||
|
'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$a = Company::create([
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'slug' => 'aaa-' . uniqid(),
|
||||||
|
'name' => 'AAA Service',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$b = Company::create([
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'slug' => 'bbb-' . uniqid(),
|
||||||
|
'name' => 'BBB Service',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$a, $b];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\Client;
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\Vehicle;
|
||||||
|
use App\Models\Tenant\WorkOrder;
|
||||||
|
use App\Models\Tenant\WorkOrderPart;
|
||||||
|
use App\Models\Tenant\WorkOrderWork;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WorkOrderCalcTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_total_recalculates_on_work_added(): void
|
||||||
|
{
|
||||||
|
$wo = $this->makeWorkOrder();
|
||||||
|
|
||||||
|
WorkOrderWork::create([
|
||||||
|
'work_order_id' => $wo->id,
|
||||||
|
'name' => 'Schimb plăcuțe',
|
||||||
|
'hours' => 2,
|
||||||
|
'price_per_hour' => 400,
|
||||||
|
'status' => 'todo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$wo->refresh();
|
||||||
|
$this->assertEquals(800.00, (float) $wo->total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_total_includes_works_plus_parts(): void
|
||||||
|
{
|
||||||
|
$wo = $this->makeWorkOrder();
|
||||||
|
|
||||||
|
WorkOrderWork::create([
|
||||||
|
'work_order_id' => $wo->id,
|
||||||
|
'name' => 'Manoperă',
|
||||||
|
'hours' => 1,
|
||||||
|
'price_per_hour' => 400,
|
||||||
|
'status' => 'todo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkOrderPart::create([
|
||||||
|
'work_order_id' => $wo->id,
|
||||||
|
'name' => 'Filtru',
|
||||||
|
'qty' => 2,
|
||||||
|
'price' => 150,
|
||||||
|
'total' => 300,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$wo->refresh();
|
||||||
|
$this->assertEquals(700.00, (float) $wo->total);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_discount_applies_to_total(): void
|
||||||
|
{
|
||||||
|
$wo = $this->makeWorkOrder();
|
||||||
|
$wo->update(['discount_pct' => 10]);
|
||||||
|
|
||||||
|
WorkOrderWork::create([
|
||||||
|
'work_order_id' => $wo->id,
|
||||||
|
'name' => 'X',
|
||||||
|
'hours' => 1,
|
||||||
|
'price_per_hour' => 1000,
|
||||||
|
'status' => 'todo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$wo->refresh();
|
||||||
|
$this->assertEquals(900.00, (float) $wo->total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeWorkOrder(): WorkOrder
|
||||||
|
{
|
||||||
|
$plan = Plan::create(['name' => 'P', 'slug' => 'p', 'price' => 0, 'features' => []]);
|
||||||
|
$company = Company::create([
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'slug' => 'wo-' . uniqid(),
|
||||||
|
'name' => 'WO Test',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
|
||||||
|
$client = Client::create([
|
||||||
|
'name' => 'Test', 'phone' => '+1', 'type' => 'individual', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
$vehicle = Vehicle::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'plate' => 'TST 001',
|
||||||
|
'brand' => 'Test',
|
||||||
|
'model' => 'X',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return WorkOrder::create([
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'vehicle_id' => $vehicle->id,
|
||||||
|
'number' => 'WO-001',
|
||||||
|
'opened_at' => now(),
|
||||||
|
'status' => 'new',
|
||||||
|
'pay_status' => 'unpaid',
|
||||||
|
'discount_pct' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\MarkupRule;
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MarkupRuleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_category_rule_applies_to_part(): void
|
||||||
|
{
|
||||||
|
$this->setupTenant();
|
||||||
|
|
||||||
|
$part = Part::create([
|
||||||
|
'name' => 'Plăcuțe frână',
|
||||||
|
'category' => 'Frâne',
|
||||||
|
'buy_price' => 100,
|
||||||
|
'sell_price' => 100,
|
||||||
|
'stock' => 10,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MarkupRule::create([
|
||||||
|
'type' => 'category',
|
||||||
|
'key' => 'Frâne',
|
||||||
|
'markup_pct' => 35,
|
||||||
|
'priority' => 100,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MarkupRule::applyToPart($part);
|
||||||
|
$part->refresh();
|
||||||
|
|
||||||
|
$this->assertEquals(135.00, (float) $part->sell_price);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_priority_picks_first_matching_rule(): void
|
||||||
|
{
|
||||||
|
$this->setupTenant();
|
||||||
|
|
||||||
|
$part = Part::create([
|
||||||
|
'name' => 'Filtru',
|
||||||
|
'category' => 'Filtre',
|
||||||
|
'brand' => 'Bosch',
|
||||||
|
'buy_price' => 50,
|
||||||
|
'sell_price' => 50,
|
||||||
|
'stock' => 5,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Brand rule (priority 1 → applied first)
|
||||||
|
MarkupRule::create(['type' => 'brand', 'key' => 'Bosch', 'markup_pct' => 50, 'priority' => 1, 'is_active' => true]);
|
||||||
|
MarkupRule::create(['type' => 'category', 'key' => 'Filtre', 'markup_pct' => 20, 'priority' => 100, 'is_active' => true]);
|
||||||
|
|
||||||
|
MarkupRule::applyToPart($part);
|
||||||
|
$part->refresh();
|
||||||
|
|
||||||
|
$this->assertEquals(75.00, (float) $part->sell_price);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_inactive_rule_is_skipped(): void
|
||||||
|
{
|
||||||
|
$this->setupTenant();
|
||||||
|
|
||||||
|
$part = Part::create([
|
||||||
|
'name' => 'X', 'category' => 'Test',
|
||||||
|
'buy_price' => 100, 'sell_price' => 100,
|
||||||
|
'stock' => 1, 'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MarkupRule::create([
|
||||||
|
'type' => 'category', 'key' => 'Test',
|
||||||
|
'markup_pct' => 99, 'priority' => 1, 'is_active' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MarkupRule::applyToPart($part);
|
||||||
|
$part->refresh();
|
||||||
|
|
||||||
|
// Inactive rule skipped → fallback default markup 30%.
|
||||||
|
$this->assertEquals(130.00, (float) $part->sell_price);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setupTenant(): void
|
||||||
|
{
|
||||||
|
$plan = Plan::create([
|
||||||
|
'name' => 'Test', 'slug' => 'test', 'price' => 0, 'features' => [],
|
||||||
|
]);
|
||||||
|
$company = Company::create([
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'slug' => 'unit-' . uniqid(),
|
||||||
|
'name' => 'Unit Test',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user