diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index 45f14fb..9b6095c 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -69,6 +69,7 @@ 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), + 'min_discount_margin_pct' => $settings['min_discount_margin_pct'] ?? 0, '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), @@ -123,6 +124,21 @@ class Settings extends Page ->schema([ Forms\Components\TextInput::make('labor_rate')->label(__('Tarif normo-oră'))->numeric()->required(), ]), + Schemas\Components\Section::make(__('Reduceri & limite preț')) + ->description(__('Setează marja minimă permisă la vânzare. Sub această limită formularul blochează salvarea.')) + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('min_discount_margin_pct') + ->label(__('Marjă minimă vânzare (%)')) + ->numeric() + ->step(0.1) + ->minValue(0) + ->maxValue(99) + ->suffix('%') + ->placeholder('Ex: 10') + ->default(0) + ->helperText(__('0 = fără limită. Ex: 10 = marja minimă 10%, adică nu poți da reducere care aduce marja sub 10%.')), + ]), 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) @@ -315,6 +331,8 @@ class Settings extends Page 'after_days' => (int) ($data['reminder_after_days'] ?? 365), 'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30), ], + 'min_discount_margin_pct' => isset($data['min_discount_margin_pct']) && $data['min_discount_margin_pct'] !== '' + ? (float) $data['min_discount_margin_pct'] : 0, '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), diff --git a/app/Filament/Tenant/Resources/LaborResource.php b/app/Filament/Tenant/Resources/LaborResource.php index 46dd64d..005b977 100644 --- a/app/Filament/Tenant/Resources/LaborResource.php +++ b/app/Filament/Tenant/Resources/LaborResource.php @@ -71,6 +71,8 @@ class LaborResource extends Resource Forms\Components\TextInput::make('fixed_price')->label(__('Preț fix (MDL)'))->numeric()->default(0) ->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') === 'fixed'), Forms\Components\TextInput::make('price')->label(__('Preț orientativ (MDL)'))->numeric()->default(0), + Forms\Components\TextInput::make('cost_price')->label(__('Preț cost (MDL)'))->numeric()->default(0) + ->helperText(__('Costul intern — limita minimă sub care nu se poate vinde cu reducere.')), Forms\Components\Toggle::make('is_active')->label(__('Activă'))->default(true), ]), Forms\Components\Textarea::make('notes')->label(__('Observații'))->columnSpanFull()->rows(2), diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php index 23b82a7..0974cb4 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/PartsRelationManager.php @@ -6,10 +6,12 @@ use App\Models\Tenant\Part; use App\Models\Tenant\PartReservation; use App\Models\Tenant\WorkOrderPart; use App\Services\Warehouse\WarehouseService; +use App\Tenancy\TenantManager; use Filament\Actions; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables; @@ -26,6 +28,12 @@ class PartsRelationManager extends RelationManager return __('Piese'); } + private static function minMargin(): float + { + $company = app(TenantManager::class)->current(); + return (float) data_get($company?->settings, 'min_discount_margin_pct', 0); + } + public function form(Schema $schema): Schema { return $schema->components([ @@ -55,7 +63,55 @@ class PartsRelationManager extends RelationManager Forms\Components\Select::make('unit_id')->label(__('UM'))->options(\App\Models\Tenant\Unit::forSelect())->searchable()->default(fn () => \App\Models\Tenant\Unit::where('code','buc')->value('id'))->default('buc'), Forms\Components\TextInput::make('buy_price')->label(__('Preț achiziție'))->numeric()->default(0), Forms\Components\TextInput::make('sell_price')->label(__('Preț vânzare'))->numeric()->required(), - Forms\Components\TextInput::make('discount_pct')->label(__('Discount %'))->numeric()->default(0), + Forms\Components\TextInput::make('discount_pct') + ->label(__('Reducere %')) + ->numeric() + ->default(0) + ->minValue(0) + ->maxValue(100) + ->step(0.01) + ->live(onBlur: true) + ->rules([ + fn (Get $get): \Closure => function (string $attribute, $value, \Closure $fail) use ($get) { + $sellPrice = max(0, (float) $get('sell_price')); + $buyPrice = max(0, (float) $get('buy_price')); + $disc = min(100, max(0, (float) $value)); + $effective = round($sellPrice * (1 - $disc / 100), 2); + + if ($buyPrice > 0 && $effective < $buyPrice) { + $fail(__('Prețul efectiv (:eff MDL) este sub prețul de achiziție (:buy MDL).', [ + 'eff' => number_format($effective, 2), + 'buy' => number_format($buyPrice, 2), + ])); + return; + } + + $minMargin = self::minMargin(); + if ($minMargin > 0 && $sellPrice > 0 && $buyPrice > 0 && $effective > 0) { + $margin = ($effective - $buyPrice) / $effective * 100; + if ($margin < $minMargin) { + $fail(__('Marja (:m%) este sub limita minimă (:min%). Necesită permisiune specială.', [ + 'm' => number_format($margin, 1), + 'min' => number_format($minMargin, 1), + ])); + } + } + }, + ]) + ->helperText(function (Get $get, $state) { + $sellPrice = max(0, (float) $get('sell_price')); + $buyPrice = max(0, (float) $get('buy_price')); + $disc = min(100, max(0, (float) $state)); + if ($sellPrice <= 0) return null; + $effective = round($sellPrice * (1 - $disc / 100), 2); + $minMargin = self::minMargin(); + if ($buyPrice > 0) { + $margin = $effective > 0 ? ($effective - $buyPrice) / $effective * 100 : 0; + $color = ($effective < $buyPrice) ? '🔴' : (($minMargin > 0 && $margin < $minMargin) ? '🟠' : '🟢'); + return "{$color} " . __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL · ' . __('Marjă:') . ' ' . number_format($margin, 1) . '%'; + } + return __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL'; + }), Forms\Components\Select::make('status') ->options(\App\Support\I18n::opts(WorkOrderPart::STATUSES)) ->default('needed') @@ -75,6 +131,9 @@ class PartsRelationManager extends RelationManager Tables\Columns\TextColumn::make('brand')->placeholder('—'), Tables\Columns\TextColumn::make('qty')->label(__('Cant.'))->alignRight(), Tables\Columns\TextColumn::make('sell_price')->label(__('Preț'))->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('discount_pct')->label(__('Disc.'))->alignRight() + ->formatStateUsing(fn ($state) => $state > 0 ? number_format((float)$state, 1) . '%' : '—') + ->color(fn ($state) => $state > 0 ? 'warning' : null), Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(), Tables\Columns\TextColumn::make('status') ->formatStateUsing(fn ($s) => __(WorkOrderPart::STATUSES[$s] ?? $s)) @@ -90,6 +149,49 @@ class PartsRelationManager extends RelationManager Actions\CreateAction::make() ->label(__('Adaugă piesă')) ->modalHeading(__('Adaugă piesă în fișă')), + + Actions\Action::make('bulk_discount_parts') + ->label(__('Reducere globală')) + ->icon('heroicon-m-tag') + ->color('warning') + ->schema([ + Forms\Components\TextInput::make('discount_pct') + ->label(__('Reducere % (toate piesele)')) + ->numeric() + ->minValue(0) + ->maxValue(100) + ->step(0.01) + ->required() + ->helperText(__('Aplică același % de reducere la toate piesele din această fișă.')), + ]) + ->action(function (array $data) { + $disc = min(100, max(0, (float) $data['discount_pct'])); + $minMargin = self::minMargin(); + $blocked = 0; + + foreach ($this->getOwnerRecord()->parts as $part) { + $sell = (float) $part->sell_price; + $buy = (float) $part->buy_price; + $effective = round($sell * (1 - $disc / 100), 2); + + if ($buy > 0 && $effective < $buy) { $blocked++; continue; } + if ($minMargin > 0 && $sell > 0 && $buy > 0 && $effective > 0) { + $margin = ($effective - $buy) / $effective * 100; + if ($margin < $minMargin) { $blocked++; continue; } + } + $part->update(['discount_pct' => $disc]); + } + + $msg = $blocked > 0 + ? __(':n piese blocate (sub limita de marjă sau preț achiziție).', ['n' => $blocked]) + : null; + + Notification::make() + ->title(__('Reducere aplicată')) + ->body($msg) + ->{$blocked > 0 ? 'warning' : 'success'}() + ->send(); + }), ]) ->actions([ Actions\Action::make('smart_price') diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php index d3b22d3..8beb8ab 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php @@ -5,9 +5,11 @@ namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers; use App\Models\Tenant\Labor; use App\Models\Tenant\User; use App\Models\Tenant\WorkOrderWork; +use App\Tenancy\TenantManager; use Filament\Actions; use Filament\Forms; use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables; @@ -24,6 +26,12 @@ class WorksRelationManager extends RelationManager return __('Manopere'); } + private static function minMargin(): float + { + $company = app(TenantManager::class)->current(); + return (float) data_get($company?->settings, 'min_discount_margin_pct', 0); + } + public function form(Schema $schema): Schema { return $schema->components([ @@ -40,6 +48,8 @@ class WorksRelationManager extends RelationManager $set('name', $labor->label()); $set('hours', $labor->hours); $set('price_per_hour', $labor->hours > 0 ? round($labor->price / max((float) $labor->hours, 1), 2) : 0); + $set('cost_price', $labor->cost_price); + $set('discount_pct', 0); } }) ->createOptionForm([ @@ -49,15 +59,77 @@ class WorksRelationManager extends RelationManager Forms\Components\TextInput::make('name_ro')->label(__('Denumire'))->required(), Forms\Components\TextInput::make('hours')->label(__('Ore normate'))->numeric()->default(1)->required(), Forms\Components\TextInput::make('price')->label(__('Preț total (MDL)'))->numeric()->default(0)->required(), + Forms\Components\TextInput::make('cost_price')->label(__('Preț cost (MDL)'))->numeric()->default(0), ]) ->createOptionUsing(function (array $data) { $data['is_active'] = true; return Labor::create($data)->id; }) ->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\TextInput::make('hours')->label(__('Ore'))->numeric()->default(1)->required()->live(onBlur: true), + Forms\Components\TextInput::make('price_per_hour')->label(__('Preț/h'))->numeric()->required()->live(onBlur: true), + Forms\Components\TextInput::make('cost_price') + ->label(__('Preț cost (MDL)')) + ->numeric() + ->default(0) + ->helperText(__('Costul intern al manoperei — limita minimă de vânzare')), + + Forms\Components\TextInput::make('discount_pct') + ->label(__('Reducere %')) + ->numeric() + ->default(0) + ->minValue(0) + ->maxValue(100) + ->step(0.01) + ->live(onBlur: true) + ->rules([ + fn (Get $get): \Closure => function (string $attribute, $value, \Closure $fail) use ($get) { + $hours = max(0, (float) $get('hours')); + $rate = max(0, (float) $get('price_per_hour')); + $costPrice = max(0, (float) $get('cost_price')); + $disc = min(100, max(0, (float) $value)); + $gross = $hours * $rate; + $effective = round($gross * (1 - $disc / 100), 2); + + if ($costPrice > 0 && $effective < $costPrice) { + $fail(__('Prețul efectiv (:eff MDL) este sub prețul de cost (:cost MDL).', [ + 'eff' => number_format($effective, 2), + 'cost' => number_format($costPrice, 2), + ])); + return; + } + + $minMargin = self::minMargin(); + if ($minMargin > 0 && $gross > 0 && $costPrice > 0) { + $margin = ($effective - $costPrice) / $effective * 100; + if ($margin < $minMargin) { + $fail(__('Marja (:m%) este sub limita minimă (:min%). Necesită permisiune specială.', [ + 'm' => number_format($margin, 1), + 'min' => number_format($minMargin, 1), + ])); + } + } + }, + ]) + ->helperText(function (Get $get, $state) { + $hours = max(0, (float) $get('hours')); + $rate = max(0, (float) $get('price_per_hour')); + $cost = max(0, (float) $get('cost_price')); + $disc = min(100, max(0, (float) $state)); + $gross = $hours * $rate; + $effective = round($gross * (1 - $disc / 100), 2); + if ($gross <= 0) return null; + $minMargin = self::minMargin(); + if ($cost > 0 && $gross > 0) { + $margin = $effective > 0 ? ($effective - $cost) / $effective * 100 : 0; + $color = ($margin < $minMargin && $minMargin > 0) ? '🔴' : '🟢'; + return "{$color} " . __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL · ' . __('Marjă:') . ' ' . number_format($margin, 1) . '%'; + } + return __('Efectiv:') . ' ' . number_format($effective, 2) . ' MDL'; + }), + Forms\Components\Select::make('master_id') ->label(__('Maistru')) ->options(fn () => User::pluck('name', 'id')) @@ -70,13 +142,12 @@ class WorksRelationManager extends RelationManager ]); } - /** 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(); + $tenant = app(TenantManager::class)->current(); return (bool) data_get($tenant?->settings, 'show_internal_margin_details', true); } @@ -89,13 +160,15 @@ class WorksRelationManager extends RelationManager ->getStateUsing(fn ($record) => $record->labor?->label() ?: $record->name), 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('discount_pct')->label(__('Discount'))->alignRight() + ->formatStateUsing(fn ($state) => $state > 0 ? number_format((float)$state, 1) . '%' : '—') + ->color(fn ($state) => $state > 0 ? 'warning' : null), Tables\Columns\TextColumn::make('total') ->label(__('Total')) ->money('MDL') ->alignRight() ->description(function ($record) { if (! self::marginDetailsVisible() || $record->salary_base === null) return null; - // WO-level toggle off → hide margin text complet if ($record->workOrder && $record->workOrder->apply_margin === false) return null; if ((float) $record->applied_margin_pct === 0.0) return null; return 'Bază salariu: ' . number_format((float) $record->salary_base, 2) . ' MDL · marjă ' . rtrim(rtrim(number_format((float) $record->applied_margin_pct, 2), '0'), '.') . '%'; @@ -111,7 +184,6 @@ class WorksRelationManager extends RelationManager ->label(__('Adaugă manoperă')) ->modalHeading(__('Adaugă manoperă în fișă')) ->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; @@ -126,6 +198,49 @@ class WorksRelationManager extends RelationManager ->title(__('Piese implicite adăugate (:n)', ['n' => $labor->laborParts->count()])) ->success()->send(); }), + + Actions\Action::make('bulk_discount_works') + ->label(__('Reducere globală')) + ->icon('heroicon-m-tag') + ->color('warning') + ->schema([ + Forms\Components\TextInput::make('discount_pct') + ->label(__('Reducere % (toate manoperele)')) + ->numeric() + ->minValue(0) + ->maxValue(100) + ->step(0.01) + ->required() + ->helperText(__('Aplică același % de reducere la toate liniile de manoperă din această fișă.')), + ]) + ->action(function (array $data) { + $disc = min(100, max(0, (float) $data['discount_pct'])); + $minMargin = self::minMargin(); + $blocked = 0; + + foreach ($this->getOwnerRecord()->works as $work) { + $gross = (float) $work->hours * (float) $work->price_per_hour; + $effective = round($gross * (1 - $disc / 100), 2); + $cost = (float) $work->cost_price; + + if ($cost > 0 && $effective < $cost) { $blocked++; continue; } + if ($minMargin > 0 && $effective > 0 && $cost > 0) { + $margin = ($effective - $cost) / $effective * 100; + if ($margin < $minMargin) { $blocked++; continue; } + } + $work->update(['discount_pct' => $disc]); + } + + $msg = $blocked > 0 + ? __(':n linii blocate (sub limita de marjă sau preț cost).', ['n' => $blocked]) + : null; + + \Filament\Notifications\Notification::make() + ->title(__('Reducere aplicată')) + ->body($msg) + ->{$blocked > 0 ? 'warning' : 'success'}() + ->send(); + }), ]) ->actions([ Actions\EditAction::make(), diff --git a/app/Models/Tenant/Labor.php b/app/Models/Tenant/Labor.php index c2d3f4b..785084d 100644 --- a/app/Models/Tenant/Labor.php +++ b/app/Models/Tenant/Labor.php @@ -23,7 +23,7 @@ class Labor extends Model protected $fillable = [ 'company_id', 'category', 'name_ro', 'name_ru', 'name_en', 'code', - 'hours', 'pricing_mode', 'fixed_price', 'price', 'is_active', 'notes', + 'hours', 'pricing_mode', 'fixed_price', 'price', 'cost_price', 'is_active', 'notes', ]; /** Localised name — prefers name_{locale}, falls back to name_ro. */ @@ -38,6 +38,7 @@ class Labor extends Model 'hours' => 'decimal:2', 'fixed_price' => 'decimal:2', 'price' => 'decimal:2', + 'cost_price' => 'decimal:2', 'is_active' => 'boolean', ]; diff --git a/app/Models/Tenant/WorkOrderWork.php b/app/Models/Tenant/WorkOrderWork.php index 98843ce..a82b02a 100644 --- a/app/Models/Tenant/WorkOrderWork.php +++ b/app/Models/Tenant/WorkOrderWork.php @@ -41,7 +41,7 @@ class WorkOrderWork extends Model protected $fillable = [ 'company_id', 'work_order_id', 'labor_id', 'master_id', - 'name', 'hours', 'price_per_hour', 'total', 'status', 'notes', + 'name', 'hours', 'price_per_hour', 'discount_pct', 'cost_price', 'total', 'status', 'notes', 'requires_approval', 'approved_at', 'approval_token', 'declined_at', 'mechanic_status', 'mechanic_started_at', 'mechanic_done_at', 'actual_hours', 'paused_seconds_total', 'paused_at', @@ -52,6 +52,8 @@ class WorkOrderWork extends Model protected $casts = [ 'hours' => 'decimal:2', 'price_per_hour' => 'decimal:2', + 'discount_pct' => 'decimal:2', + 'cost_price' => 'decimal:2', 'total' => 'decimal:2', 'requires_approval' => 'boolean', 'approved_at' => 'datetime', @@ -173,7 +175,9 @@ class WorkOrderWork extends Model protected static function booted(): void { static::saving(function (self $row) { - $row->total = round((float) $row->hours * (float) $row->price_per_hour, 2); + $gross = round((float) $row->hours * (float) $row->price_per_hour, 2); + $disc = min(100, max(0, (float) $row->discount_pct)); + $row->total = round($gross * (1 - $disc / 100), 2); if ($row->requires_approval && empty($row->approval_token)) { $row->approval_token = \Illuminate\Support\Str::random(24); } diff --git a/database/migrations/2026_09_11_100000_add_discount_and_cost_to_works_and_labors.php b/database/migrations/2026_09_11_100000_add_discount_and_cost_to_works_and_labors.php new file mode 100644 index 0000000..390c7d5 --- /dev/null +++ b/database/migrations/2026_09_11_100000_add_discount_and_cost_to_works_and_labors.php @@ -0,0 +1,29 @@ +decimal('discount_pct', 5, 2)->default(0)->after('total'); + $table->decimal('cost_price', 10, 2)->default(0)->after('discount_pct'); + }); + + Schema::table('labors', function (Blueprint $table) { + $table->decimal('cost_price', 10, 2)->default(0)->after('price'); + }); + } + + public function down(): void + { + Schema::table('wo_works', function (Blueprint $table) { + $table->dropColumn(['discount_pct', 'cost_price']); + }); + Schema::table('labors', function (Blueprint $table) { + $table->dropColumn('cost_price'); + }); + } +};