70ca2fa74a
Client sees only Total. Salary is calculated from salary_base = client_price
× (1 − margin/100). Margin never appears in customer-facing surfaces (PDF,
tracking JSON, portal).
Terminology: "marjă internă" — internal profit margin. NOT VAT/TVA. Never
called NDS/TVA anywhere in the code to avoid confusion with real Moldova
tax reporting (Doc 19/1C integration).
== Configuration ==
Fallback chain (in MarginResolver::resolve):
1. WorkOrder.override_margin_pct — per-Fișă for special contracts/VIP
2. User.internal_margin_pct — per-mechanic (main setting)
3. Company.settings.default_internal_margin_pct — tenant default
4. 0.0 — no margin
Example (mechanic Andrei with 20% margin):
User enters price_per_hour = 250 for 1h diagnosis
→ total = 250 (what client sees, goes into PDF)
→ salary_base = 250 × 0.80 = 200 (what mechanic gets salaried on)
→ applied_margin_pct = 20 (frozen)
If admin later changes Andrei's margin to 40%, the row's salary_base does
NOT change — history is immutable. Only new rows use the new margin.
Solves the retroactive-recompute problem for closed payroll periods.
== salary_base freeze semantics ==
wo_works gains 2 columns:
salary_base decimal(10,2) nullable
applied_margin_pct decimal(5,2) nullable
Frozen at save time by WorkOrderWork::saving hook. Recomputes only if
total OR master_id changes (i.e., someone actively edits the price or
reassigns the mechanic — in those cases we WANT the salary_base to
follow). Legacy rows (before this feature) have null salary_base;
PayrollCalculator falls back to total for them.
== PayrollCalculator uses salary_base ==
Previously: sum(wo_works.total) × works_pct → gave the mechanic a cut
of the price INCLUDING margin.
Now: sum(salary_base ?? total) × works_pct → the cut is from the
labor rate excluding margin.
Impact: for a 250 lei diagnosis at 20% margin with 50% payroll cut, the
mechanic gets 200 × 50% = 100 lei (was 250 × 50% = 125 lei). The shop
keeps the 50 lei margin regardless of the payroll %.
== RBAC gate ==
New permission FINANCE_VIEW_INTERNAL_MARGIN. Assigned to owner + admin +
manager + accountant in seed matrix. Not granted to mechanic,
receptionist, or viewer — those roles never see the "Bază salariu"
disclosure line or the margin % fields.
== UI surfaces ==
UserResource — new "Salariu & marjă" section (visible only with
FINANCE_VIEW_INTERNAL_MARGIN):
- Tarif orar (MDL)
- Marjă internă (%) with helper text explaining the -X% semantics
- Placeholder tells manager the exact formula
WorkOrderResource form — new override_margin_pct field in the "Plată &
total" section, gated by same permission. Helper text: "Doar pentru
cazuri speciale. Lasă gol pentru a folosi marja mecanicului."
WorksRelationManager (WO edit page) — Total column now shows a gray
subtitle line "Bază salariu: 200.00 MDL · marjă 20%" ONLY for users
with FINANCE_VIEW_INTERNAL_MARGIN. Everyone else sees just Total.
== Contract tests: NO leak ==
InternalMarginTest verifies with black-box grepping that:
- WorkOrderPdfService::generate output contains NONE of
{salary_base, internal_margin, applied_margin_pct, marja intern,
Bază salariu}
- /api/track/{token} JSON payload contains NONE of the same terms
- wo_parts table has no salary_base column (margin ONLY on labor)
- Changing mechanic.internal_margin_pct after work is saved does NOT
rewrite the historical salary_base (frozen)
- WO override wins over mechanic margin (contract-priced clients)
- Fallback chain: WO → mechanic → company default → 0
== Suite ==
298 passed (828 assertions). Was 285. +13 InternalMarginTest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
4.8 KiB
PHP
102 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
|
|
|
|
use App\Models\Tenant\Labor;
|
|
use App\Models\Tenant\User;
|
|
use App\Models\Tenant\WorkOrderWork;
|
|
use Filament\Actions;
|
|
use Filament\Forms;
|
|
use Filament\Resources\RelationManagers\RelationManager;
|
|
use Filament\Schemas\Components\Utilities\Set;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
|
|
class WorksRelationManager extends RelationManager
|
|
{
|
|
protected static string $relationship = 'works';
|
|
|
|
protected static ?string $title = 'Manopere';
|
|
|
|
public function form(Schema $schema): Schema
|
|
{
|
|
return $schema->components([
|
|
Forms\Components\Select::make('labor_id')
|
|
->label('Catalog manoperă')
|
|
->options(fn () => Labor::where('is_active', true)
|
|
->get()
|
|
->mapWithKeys(fn ($l) => [$l->id => "[{$l->category}] {$l->name_ro} ({$l->hours}h)"])
|
|
->toArray())
|
|
->searchable()
|
|
->live()
|
|
->afterStateUpdated(function ($state, Set $set) {
|
|
if ($state && $labor = Labor::find($state)) {
|
|
$set('name', $labor->name_ro);
|
|
$set('hours', $labor->hours);
|
|
$set('price_per_hour', $labor->hours > 0 ? round($labor->price / max((float) $labor->hours, 1), 2) : 0);
|
|
}
|
|
})
|
|
->columnSpanFull(),
|
|
Forms\Components\TextInput::make('name')->label('Nume')->required()->columnSpanFull(),
|
|
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()->default(1)->required(),
|
|
Forms\Components\TextInput::make('price_per_hour')->label('Preț/h')->numeric()->required(),
|
|
Forms\Components\Select::make('master_id')
|
|
->label('Maistru')
|
|
->options(fn () => User::pluck('name', 'id'))
|
|
->searchable(),
|
|
Forms\Components\Select::make('status')
|
|
->options(WorkOrderWork::STATUSES)
|
|
->default('todo')
|
|
->required(),
|
|
Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(2),
|
|
]);
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->recordTitleAttribute('name')
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('name')->label('Manoperă')->wrap(),
|
|
Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(),
|
|
Tables\Columns\TextColumn::make('price_per_hour')->label('Preț/h')->money('MDL')->alignRight(),
|
|
Tables\Columns\TextColumn::make('total')
|
|
->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),
|
|
Tables\Columns\TextColumn::make('master.name')->label('Maistru')->placeholder('—'),
|
|
Tables\Columns\TextColumn::make('status')
|
|
->formatStateUsing(fn ($s) => WorkOrderWork::STATUSES[$s] ?? $s)
|
|
->badge()
|
|
->colors(['gray' => ['todo'], 'warning' => ['in_progress'], 'success' => ['done']]),
|
|
])
|
|
->headerActions([
|
|
Actions\CreateAction::make()
|
|
->after(function (WorkOrderWork $record) {
|
|
// Auto-add the labor's default parts to the parent WO.
|
|
if (! $record->labor_id) return;
|
|
$labor = Labor::with('laborParts.part')->find($record->labor_id);
|
|
$wo = $record->workOrder;
|
|
if (! $labor || ! $wo || $labor->laborParts->isEmpty()) return;
|
|
$composer = app(\App\Services\ServiceComposer::class);
|
|
foreach ($labor->laborParts as $lp) {
|
|
if ($lp->part) {
|
|
$composer->addPart($wo, $lp->part, (float) $lp->qty, $lp->unit);
|
|
}
|
|
}
|
|
\Filament\Notifications\Notification::make()
|
|
->title('Piese implicite adăugate (' . $labor->laborParts->count() . ')')
|
|
->success()->send();
|
|
}),
|
|
])
|
|
->actions([
|
|
Actions\EditAction::make(),
|
|
Actions\DeleteAction::make(),
|
|
]);
|
|
}
|
|
}
|