diff --git a/app/Filament/Tenant/Resources/ClientResource.php b/app/Filament/Tenant/Resources/ClientResource.php
index f0a99c7..04c4266 100644
--- a/app/Filament/Tenant/Resources/ClientResource.php
+++ b/app/Filament/Tenant/Resources/ClientResource.php
@@ -26,6 +26,21 @@ class ClientResource extends Resource
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
{
return $schema->components([
diff --git a/app/Filament/Tenant/Resources/LeadResource.php b/app/Filament/Tenant/Resources/LeadResource.php
index 687cede..92f08c8 100644
--- a/app/Filament/Tenant/Resources/LeadResource.php
+++ b/app/Filament/Tenant/Resources/LeadResource.php
@@ -30,6 +30,19 @@ class LeadResource extends Resource
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
{
return $schema->components([
diff --git a/app/Filament/Tenant/Resources/PartResource.php b/app/Filament/Tenant/Resources/PartResource.php
index e08084e..9d821ec 100644
--- a/app/Filament/Tenant/Resources/PartResource.php
+++ b/app/Filament/Tenant/Resources/PartResource.php
@@ -29,6 +29,19 @@ class PartResource extends Resource
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
{
$low = static::getModel()::query()
diff --git a/app/Filament/Tenant/Resources/VehicleResource.php b/app/Filament/Tenant/Resources/VehicleResource.php
index fece9f0..1723815 100644
--- a/app/Filament/Tenant/Resources/VehicleResource.php
+++ b/app/Filament/Tenant/Resources/VehicleResource.php
@@ -27,6 +27,24 @@ class VehicleResource extends Resource
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
{
return $schema->components([
diff --git a/app/Filament/Tenant/Resources/WorkOrderResource.php b/app/Filament/Tenant/Resources/WorkOrderResource.php
index 479aab0..d60d241 100644
--- a/app/Filament/Tenant/Resources/WorkOrderResource.php
+++ b/app/Filament/Tenant/Resources/WorkOrderResource.php
@@ -34,6 +34,25 @@ class WorkOrderResource extends Resource
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
{
return $schema->components([
diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php
new file mode 100644
index 0000000..5a42721
--- /dev/null
+++ b/app/Http/Middleware/SetLocale.php
@@ -0,0 +1,46 @@
+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');
+ }
+}
diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php
index 13c7480..374d66d 100644
--- a/app/Providers/Filament/TenantPanelProvider.php
+++ b/app/Providers/Filament/TenantPanelProvider.php
@@ -38,6 +38,9 @@ class TenantPanelProvider extends PanelProvider
'primary' => Color::Blue,
])
->authGuard('web')
+ ->databaseNotifications()
+ ->databaseNotificationsPolling('30s')
+ ->globalSearchKeyBindings(['command+k', 'ctrl+k'])
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
->pages([
@@ -58,6 +61,7 @@ class TenantPanelProvider extends PanelProvider
// unauthenticated → endless redirect to /app/login.
ResolveTenant::class,
CheckTenantStatus::class,
+ \App\Http\Middleware\SetLocale::class,
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
@@ -114,6 +118,68 @@ class TenantPanelProvider extends PanelProvider
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
+
+
+
+
+
+ BLADE)
+ )
->renderHook(
PanelsRenderHook::SIDEBAR_LOGO_BEFORE,
fn (): string => Blade::render(<<<'BLADE'
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 8d9245a..40d8990 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -29,6 +29,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [
\App\Http\Middleware\ResolveTenant::class,
\App\Http\Middleware\CheckTenantStatus::class,
+ \App\Http\Middleware\SetLocale::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
diff --git a/database/migrations/2026_05_08_000001_create_notifications_table.php b/database/migrations/2026_05_08_000001_create_notifications_table.php
new file mode 100644
index 0000000..9793f46
--- /dev/null
+++ b/database/migrations/2026_05_08_000001_create_notifications_table.php
@@ -0,0 +1,24 @@
+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');
+ }
+};
diff --git a/lang/en.json b/lang/en.json
new file mode 100644
index 0000000..ffaae03
--- /dev/null
+++ b/lang/en.json
@@ -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"
+}
diff --git a/lang/ro.json b/lang/ro.json
new file mode 100644
index 0000000..92e6106
--- /dev/null
+++ b/lang/ro.json
@@ -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"
+}
diff --git a/lang/ru.json b/lang/ru.json
new file mode 100644
index 0000000..1444456
--- /dev/null
+++ b/lang/ru.json
@@ -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": "Сайт"
+}
diff --git a/routes/web.php b/routes/web.php
index bc7dcef..d540e32 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -24,6 +24,18 @@ Route::get('/', function () {
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.
Route::get('/manifest.json', function (Request $request) {
$tenant = app(TenantManager::class)->current();
diff --git a/tests/Feature/AuthFlowTest.php b/tests/Feature/AuthFlowTest.php
new file mode 100644
index 0000000..339cc5b
--- /dev/null
+++ b/tests/Feature/AuthFlowTest.php
@@ -0,0 +1,77 @@
+ '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);
+ }
+}
diff --git a/tests/Feature/TenantIsolationTest.php b/tests/Feature/TenantIsolationTest.php
new file mode 100644
index 0000000..ef4a326
--- /dev/null
+++ b/tests/Feature/TenantIsolationTest.php
@@ -0,0 +1,114 @@
+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];
+ }
+}
diff --git a/tests/Feature/WorkOrderCalcTest.php b/tests/Feature/WorkOrderCalcTest.php
new file mode 100644
index 0000000..493538c
--- /dev/null
+++ b/tests/Feature/WorkOrderCalcTest.php
@@ -0,0 +1,111 @@
+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,
+ ]);
+ }
+}
diff --git a/tests/Unit/MarkupRuleTest.php b/tests/Unit/MarkupRuleTest.php
new file mode 100644
index 0000000..82fdb49
--- /dev/null
+++ b/tests/Unit/MarkupRuleTest.php
@@ -0,0 +1,103 @@
+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);
+ }
+}