diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index 2128f1b..d034b17 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -55,6 +55,8 @@ class Settings extends Page 'telegram_bot_token' => data_get($settings, 'telegram.bot_token'), 'reminder_after_days' => data_get($settings, 'reminder.after_days', 365), 'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30), + 'default_internal_margin_pct' => $settings['default_internal_margin_pct'] ?? null, + 'show_internal_margin_details' => data_get($settings, 'show_internal_margin_details', true), 'shop_enabled' => data_get($settings, 'shop.enabled', false), 'shop_delivery_methods' => data_get($settings, 'shop.delivery_methods', ['pickup']), 'shop_delivery_fee' => data_get($settings, 'shop.delivery_fee', 0), @@ -107,6 +109,25 @@ class Settings extends Page ->schema([ Forms\Components\TextInput::make('labor_rate')->label('Tarif normo-oră')->numeric()->required(), ]), + Schemas\Components\Section::make('Marjă internă (nu TVA)') + ->description('Procent implicit aplicat la manopere. Se scade din prețul de manoperă pentru a determina baza salariului mecanicului. Vizibilă doar rolurilor cu drept „finance.view_internal_margin".') + ->columns(2) + ->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false) + ->schema([ + Forms\Components\TextInput::make('default_internal_margin_pct') + ->label('% marjă implicit') + ->numeric() + ->step(0.01) + ->minValue(0) + ->maxValue(90) + ->suffix('%') + ->placeholder('Ex: 20') + ->helperText('Fallback când mecanicul nu are marja proprie setată. Lasă gol = 0%.'), + Forms\Components\Toggle::make('show_internal_margin_details') + ->label('Afișează detalii marjă la procesele calculate') + ->helperText('On = arată „Bază salariu: X · marjă Y%" sub Total în tabelul Manopere. Off = doar Total (util când review-uiești o Fișă cu clientul de față).') + ->default(true), + ]), Schemas\Components\Section::make('Liste configurabile') ->columns(1) ->schema([ @@ -250,6 +271,9 @@ class Settings extends Page 'after_days' => (int) ($data['reminder_after_days'] ?? 365), 'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30), ], + 'default_internal_margin_pct' => $data['default_internal_margin_pct'] !== '' && $data['default_internal_margin_pct'] !== null + ? (float) $data['default_internal_margin_pct'] : null, + 'show_internal_margin_details' => (bool) ($data['show_internal_margin_details'] ?? true), 'shop' => [ 'enabled' => (bool) ($data['shop_enabled'] ?? false), 'delivery_methods' => array_values((array) ($data['shop_delivery_methods'] ?? ['pickup'])), diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php index 1d28e21..5a26f7f 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php @@ -49,10 +49,25 @@ class WorksRelationManager extends RelationManager ->options(WorkOrderWork::STATUSES) ->default('todo') ->required(), + Forms\Components\Toggle::make('apply_margin') + ->label('Aplică marjă internă') + ->default(true) + ->helperText('On = din prețul manoperei se scade marja pentru salariu. Off = manoperă la cost (salariu se calculează pe Total integral).') + ->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false), Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(2), ]); } + /** Global visibility flag from tenant Settings — user can hide margin details in-session. */ + private static function marginDetailsVisible(): bool + { + if (! auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN)) { + return false; + } + $tenant = app(\App\Tenancy\TenantManager::class)->current(); + return (bool) data_get($tenant?->settings, 'show_internal_margin_details', true); + } + public function table(Table $table): Table { return $table @@ -65,9 +80,17 @@ class WorksRelationManager extends RelationManager ->label('Total') ->money('MDL') ->alignRight() - ->description(fn ($record) => (auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) && $record->salary_base !== null) - ? 'Bază salariu: ' . number_format((float) $record->salary_base, 2) . ' MDL · marjă ' . rtrim(rtrim(number_format((float) $record->applied_margin_pct, 2), '0'), '.') . '%' - : null), + ->description(function ($record) { + if (! self::marginDetailsVisible() || $record->salary_base === null) return null; + if (! $record->apply_margin) { + return 'Fără marjă · bază salariu = Total'; + } + return 'Bază salariu: ' . number_format((float) $record->salary_base, 2) . ' MDL · marjă ' . rtrim(rtrim(number_format((float) $record->applied_margin_pct, 2), '0'), '.') . '%'; + }), + Tables\Columns\ToggleColumn::make('apply_margin') + ->label('Marjă') + ->visible(fn () => auth()->user()?->canDo(\App\Auth\Permissions::FINANCE_VIEW_INTERNAL_MARGIN) ?? false) + ->tooltip('On = se aplică marja internă. Off = manoperă la cost.'), Tables\Columns\TextColumn::make('master.name')->label('Maistru')->placeholder('—'), Tables\Columns\TextColumn::make('status') ->formatStateUsing(fn ($s) => WorkOrderWork::STATUSES[$s] ?? $s) diff --git a/app/Models/Tenant/WorkOrderWork.php b/app/Models/Tenant/WorkOrderWork.php index 6306915..eebd2e9 100644 --- a/app/Models/Tenant/WorkOrderWork.php +++ b/app/Models/Tenant/WorkOrderWork.php @@ -15,6 +15,7 @@ class WorkOrderWork extends Model protected $attributes = [ 'mechanic_status' => 'pending', 'paused_seconds_total' => 0, + 'apply_margin' => true, ]; public const STATUSES = [ @@ -45,7 +46,7 @@ class WorkOrderWork extends Model 'mechanic_status', 'mechanic_started_at', 'mechanic_done_at', 'actual_hours', 'paused_seconds_total', 'paused_at', 'block_reason', 'block_note', - 'salary_base', 'applied_margin_pct', + 'salary_base', 'applied_margin_pct', 'apply_margin', ]; protected $casts = [ @@ -62,6 +63,7 @@ class WorkOrderWork extends Model 'paused_seconds_total' => 'integer', 'salary_base' => 'decimal:2', 'applied_margin_pct' => 'decimal:2', + 'apply_margin' => 'boolean', ]; // ── State machine ──────────────────────────────────────────── @@ -177,13 +179,19 @@ class WorkOrderWork extends Model } // Compute internal margin & freeze salary_base at save time. // Once frozen, changing user.internal_margin_pct later does NOT rewrite history. - if (($row->salary_base === null || $row->isDirty(['total', 'master_id'])) && (float) $row->total > 0) { - $resolver = app(\App\Services\MarginResolver::class); - $mechanic = $row->master_id ? User::find($row->master_id) : null; - $wo = $row->workOrder; - $marginPct = $resolver->resolve($wo, $mechanic); - $row->applied_margin_pct = $marginPct; - $row->salary_base = $resolver->computeSalaryBase((float) $row->total, $marginPct); + // apply_margin=false → this line is at-cost (no reduction); salary_base = total. + if (($row->salary_base === null || $row->isDirty(['total', 'master_id', 'apply_margin'])) && (float) $row->total > 0) { + if ((bool) $row->apply_margin === false) { + $row->applied_margin_pct = 0; + $row->salary_base = (float) $row->total; + } else { + $resolver = app(\App\Services\MarginResolver::class); + $mechanic = $row->master_id ? User::find($row->master_id) : null; + $wo = $row->workOrder; + $marginPct = $resolver->resolve($wo, $mechanic); + $row->applied_margin_pct = $marginPct; + $row->salary_base = $resolver->computeSalaryBase((float) $row->total, $marginPct); + } } }); static::saved(fn (self $row) => $row->workOrder?->recalcTotal()); diff --git a/database/migrations/2026_07_07_000002_add_apply_margin_to_wo_works.php b/database/migrations/2026_07_07_000002_add_apply_margin_to_wo_works.php new file mode 100644 index 0000000..e283507 --- /dev/null +++ b/database/migrations/2026_07_07_000002_add_apply_margin_to_wo_works.php @@ -0,0 +1,26 @@ +boolean('apply_margin')->default(true)->after('applied_margin_pct'); + } + }); + } + + public function down(): void + { + Schema::table('wo_works', function (Blueprint $t) { + if (Schema::hasColumn('wo_works', 'apply_margin')) { + $t->dropColumn('apply_margin'); + } + }); + } +}; diff --git a/tests/Feature/InternalMarginToggleTest.php b/tests/Feature/InternalMarginToggleTest.php new file mode 100644 index 0000000..187675b --- /dev/null +++ b/tests/Feature/InternalMarginToggleTest.php @@ -0,0 +1,119 @@ + 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $this->company = Company::create(['plan_id' => $plan->id, 'slug' => 'imt-' . uniqid(), 'name' => 'IMT', 'status' => 'active']); + app(TenantManager::class)->setCurrent($this->company); + + $this->mechanic = User::create(['name' => 'Andrei', 'email' => 'a@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active', 'internal_margin_pct' => 20]); + $client = Client::create(['name' => 'C', 'phone' => '+37399000000', 'type' => 'individual', 'status' => 'active']); + $vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'MT-1']); + $this->wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($this->company->id), + 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'master_id' => $this->mechanic->id, + 'opened_at' => today(), 'status' => 'in_work', 'total' => 0, + ]); + } + + public function test_apply_margin_off_makes_salary_base_equal_to_total(): void + { + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id, + 'name' => 'Ulei la cost', 'hours' => 1, 'price_per_hour' => 250, + 'apply_margin' => false, + ]); + + $this->assertEquals(250.00, (float) $work->total); + $this->assertEquals(250.00, (float) $work->salary_base); + $this->assertEquals(0.00, (float) $work->applied_margin_pct); + $this->assertFalse((bool) $work->apply_margin); + } + + public function test_apply_margin_on_default_still_applies_margin(): void + { + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id, + 'name' => 'Diagnoză cu marjă', 'hours' => 1, 'price_per_hour' => 250, + ]); + + $this->assertEquals(250.00, (float) $work->total); + $this->assertEquals(200.00, (float) $work->salary_base); + $this->assertEquals(20.00, (float) $work->applied_margin_pct); + $this->assertTrue((bool) $work->apply_margin); + } + + public function test_toggling_apply_margin_recomputes_salary_base(): void + { + // Start with margin ON + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, 'master_id' => $this->mechanic->id, + 'name' => 'X', 'hours' => 1, 'price_per_hour' => 250, + ]); + $this->assertEquals(200.00, (float) $work->salary_base); + + // Toggle OFF + $work->update(['apply_margin' => false]); + $work->refresh(); + $this->assertEquals(250.00, (float) $work->salary_base); + $this->assertEquals(0.00, (float) $work->applied_margin_pct); + + // Toggle back ON + $work->update(['apply_margin' => true]); + $work->refresh(); + $this->assertEquals(200.00, (float) $work->salary_base); + $this->assertEquals(20.00, (float) $work->applied_margin_pct); + } + + public function test_company_default_margin_used_when_mechanic_has_none(): void + { + // Set tenant default via Company.settings + $this->company->update(['settings' => ['default_internal_margin_pct' => 15]]); + app(TenantManager::class)->setCurrent($this->company->fresh()); + + $bareUser = User::create(['name' => 'X', 'email' => 'x@e.com', 'password' => bcrypt('x'), 'role' => 'mechanic', 'status' => 'active']); + + $work = WorkOrderWork::create([ + 'work_order_id' => $this->wo->id, 'master_id' => $bareUser->id, + 'name' => 'Y', 'hours' => 1, 'price_per_hour' => 100, + ]); + + // 15% margin applied → salary_base = 85 + $this->assertEquals(85.00, (float) $work->salary_base); + $this->assertEquals(15.00, (float) $work->applied_margin_pct); + } + + public function test_show_internal_margin_details_flag_persists_in_settings(): void + { + $this->company->update(['settings' => [ + 'default_internal_margin_pct' => 25, + 'show_internal_margin_details' => false, + ]]); + + $fresh = Company::find($this->company->id); + $this->assertEquals(25.0, (float) $fresh->settings['default_internal_margin_pct']); + $this->assertFalse((bool) $fresh->settings['show_internal_margin_details']); + } +}