feat: marjă internă — Settings % + toggle per manoperă + visibility flag

Adds three usability improvements to the marja internă feature:

1. Settings UI section "Marjă internă (nu TVA)" — configurable at tenant level
2. Per-line toggle "Aplică marjă" on each manoperă
3. Global visibility flag to hide margin details in-session

== 1. Settings page ==

New section on /app/settings (gated by FINANCE_VIEW_INTERNAL_MARGIN):
- "% marjă implicit" numeric input with % suffix, 0–90 range
- "Afișează detalii marjă la procesele calculate" toggle (default on)
- Explicit label "Marjă internă (nu TVA)" plus helper text explaining
  it's not the Moldovan tax — feeds into MarginResolver as the tenant
  default, applied only when the mechanic has no per-user margin.

Persists as company.settings.default_internal_margin_pct and
company.settings.show_internal_margin_details.

== 2. Per-line "Aplică marjă" toggle ==

New wo_works.apply_margin boolean, default true. When false:
  applied_margin_pct = 0
  salary_base = total   (mechanic gets salaried on the full amount)

Use case: oil change, tire mount, and similar "at-cost" services where
the shop doesn't want to hold back part of the labor rate. The owner
can flag those specific lines while keeping margin on diagnostic and
premium labor.

WorksRelationManager form gains a Toggle field (gated by
FINANCE_VIEW_INTERNAL_MARGIN); table gains a ToggleColumn for quick
inline flipping without opening the row.

Booted hook now recomputes salary_base when apply_margin is dirtied,
so toggling live in the table takes effect immediately.

== 3. Show internal margin details flag ==

Global tenant flag (default on): when off, the gray subtitle line
"Bază salariu: 200 · marjă 20%" under the Total column disappears for
everyone, even users with FINANCE_VIEW_INTERNAL_MARGIN.

Practical use: when reviewing a Fișă face-to-face with the client on
the manager's screen, flip the flag off from Settings for the day →
no risk of the client accidentally seeing internal numbers. Flip back
when done.

The flag lives in company.settings.show_internal_margin_details.

== Description text on the Total column ==

Now shows either:
- "Bază salariu: 200.00 MDL · marjă 20%" when apply_margin=true
- "Fără marjă · bază salariu = Total" when apply_margin=false
- nothing when show_internal_margin_details=false or role lacks permission

== Tests ==

InternalMarginToggleTest (5):
- apply_margin=false → salary_base equals total, applied_margin_pct=0
- apply_margin=true (default) still applies 20% margin
- Toggling apply_margin recomputes salary_base bidirectionally
- Company default margin resolves when mechanic has no per-user setting
- show_internal_margin_details flag persists correctly in Company.settings

Suite: 303 passed (845 assertions). Was 298.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 19:39:51 +00:00
parent 70ca2fa74a
commit f4ccc306dc
5 changed files with 211 additions and 11 deletions
+24
View File
@@ -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'])),
@@ -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)
+16 -8
View File
@@ -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());
@@ -0,0 +1,26 @@
<?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::table('wo_works', function (Blueprint $t) {
if (! Schema::hasColumn('wo_works', 'apply_margin')) {
$t->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');
}
});
}
};
+119
View File
@@ -0,0 +1,119 @@
<?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\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderWork;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InternalMarginToggleTest extends TestCase
{
use RefreshDatabase;
private Company $company;
private User $mechanic;
private WorkOrder $wo;
protected function setUp(): void
{
parent::setUp();
$plan = Plan::firstOrCreate(['slug' => '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']);
}
}