From 67da97178d5cc3b34c22277593cb1e3e84780b30 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 7 May 2026 15:30:04 +0000 Subject: [PATCH] =?UTF-8?q?Batch=201:=20Procentaj=20+=20Finan=C8=9Be=20con?= =?UTF-8?q?solidat=20+=20Recomand=C4=83ri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ═══ Procentaj (markup rules) ═══ - markup_rules table cu type (category/brand/range), key, range_from/to, markup_pct, priority - MarkupRule::bestForPart($part) — rezolvare brand → category → range → 30% default - MarkupRule::applyToPart($part) — recalc sell_price = buy_price × (1 + pct/100) - Filament resource sub Depozit cu form dinamic per tip - Action 'Aplică toate regulile la stoc' — recalc tot catalogul (chunk 100) ═══ Finanțe consolidat ═══ - Custom Page /app/finance cu 4 tab-uri: • Overview: încasări/cheltuieli/profit/datorii (4 cards) • Cashflow: bar chart per zi (verde=in, roșu=out) + Net total • P&L: venituri (manopere + piese) vs costuri (cost piese + cheltuieli pe categorie) + profit net + marjă % • Balance: active (cash net + datorii + stoc), all-time totals - Period filter: lună / luna trecută / an / 30 zile ═══ Recomandări ═══ - Custom Page /app/recommendations 4 sectiuni: • Clienți pierduți (>6 luni fără WO + are istoric) • Mașini km>100k (sugestie revizie) • Fișe neplătite (rest > 0) • VIP fără contact >30 zile Total tenant routes: 100. --- app/Filament/Tenant/Pages/Finance.php | 177 ++++++++++++++++++ app/Filament/Tenant/Pages/Recommendations.php | 64 +++++++ .../Tenant/Resources/MarkupRuleResource.php | 132 +++++++++++++ .../Pages/CreateMarkupRule.php | 14 ++ .../Pages/EditMarkupRule.php | 14 ++ .../Pages/ListMarkupRules.php | 14 ++ app/Models/Tenant/MarkupRule.php | 72 +++++++ .../2026_05_07_150001_create_markup_rules.php | 31 +++ .../filament/tenant/pages/finance.blade.php | 135 +++++++++++++ .../tenant/pages/recommendations.blade.php | 105 +++++++++++ 10 files changed, 758 insertions(+) create mode 100644 app/Filament/Tenant/Pages/Finance.php create mode 100644 app/Filament/Tenant/Pages/Recommendations.php create mode 100644 app/Filament/Tenant/Resources/MarkupRuleResource.php create mode 100644 app/Filament/Tenant/Resources/MarkupRuleResource/Pages/CreateMarkupRule.php create mode 100644 app/Filament/Tenant/Resources/MarkupRuleResource/Pages/EditMarkupRule.php create mode 100644 app/Filament/Tenant/Resources/MarkupRuleResource/Pages/ListMarkupRules.php create mode 100644 app/Models/Tenant/MarkupRule.php create mode 100644 database/migrations/2026_05_07_150001_create_markup_rules.php create mode 100644 resources/views/filament/tenant/pages/finance.blade.php create mode 100644 resources/views/filament/tenant/pages/recommendations.blade.php diff --git a/app/Filament/Tenant/Pages/Finance.php b/app/Filament/Tenant/Pages/Finance.php new file mode 100644 index 0000000..fd20ae9 --- /dev/null +++ b/app/Filament/Tenant/Pages/Finance.php @@ -0,0 +1,177 @@ +tab = $tab; } + public function setPeriod(string $p): void { $this->period = $p; } + + public function tabs(): array + { + return [ + 'overview' => '📊 Overview', + 'cashflow' => '💸 Cashflow', + 'pnl' => '📈 P&L', + 'balance' => '⚖️ Balanță', + ]; + } + + public function periods(): array + { + return [ + 'this_month' => 'Luna curentă', + 'last_month' => 'Luna trecută', + 'this_year' => 'Anul curent', + 'last_30' => 'Ultimele 30 zile', + ]; + } + + public function dateRange(): array + { + return match ($this->period) { + 'last_month' => [Carbon::now()->subMonthNoOverflow()->startOfMonth(), Carbon::now()->subMonthNoOverflow()->endOfMonth()], + 'this_year' => [Carbon::now()->startOfYear(), Carbon::now()->endOfYear()], + 'last_30' => [Carbon::now()->subDays(30)->startOfDay(), Carbon::now()->endOfDay()], + default => [Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth()], + }; + } + + public function data(): array + { + [$start, $end] = $this->dateRange(); + + return match ($this->tab) { + 'overview' => $this->overview($start, $end), + 'cashflow' => $this->cashflow($start, $end), + 'pnl' => $this->pnl($start, $end), + 'balance' => $this->balance($start, $end), + default => [], + }; + } + + protected function overview($start, $end): array + { + $income = (float) Payment::whereBetween('paid_at', [$start, $end])->sum('amount'); + $expenses = (float) Expense::whereBetween('paid_at', [$start, $end])->sum('amount'); + $profit = $income - $expenses; + + $debtTotal = (float) WorkOrder::where('pay_status', '!=', 'paid') + ->whereNotIn('status', ['cancelled']) + ->get() + ->sum(fn ($w) => max(0, (float) $w->total - (float) $w->payments()->sum('amount'))); + + return compact('income', 'expenses', 'profit', 'debtTotal'); + } + + protected function cashflow($start, $end): array + { + // Aggregate per day. + $byDay = []; + $cur = $start->copy(); + while ($cur <= $end) { + $key = $cur->format('Y-m-d'); + $byDay[$key] = ['date' => $cur->format('d.m'), 'in' => 0, 'out' => 0]; + $cur->addDay(); + } + + Payment::whereBetween('paid_at', [$start, $end]) + ->selectRaw('DATE(paid_at) d, SUM(amount) total') + ->groupBy('d')->get() + ->each(fn ($r) => $byDay[$r->d]['in'] = (float) $r->total); + + Expense::whereBetween('paid_at', [$start, $end]) + ->selectRaw('DATE(paid_at) d, SUM(amount) total') + ->groupBy('d')->get() + ->each(fn ($r) => $byDay[$r->d]['out'] = (float) $r->total); + + // Totals + max (for bar chart scale). + $totalIn = array_sum(array_column($byDay, 'in')); + $totalOut = array_sum(array_column($byDay, 'out')); + $maxAbs = max(0.001, max(...array_map(fn ($d) => max($d['in'], $d['out']), $byDay))); + + return [ + 'rows' => array_values($byDay), + 'totalIn' => $totalIn, + 'totalOut' => $totalOut, + 'net' => $totalIn - $totalOut, + 'maxAbs' => $maxAbs, + ]; + } + + protected function pnl($start, $end): array + { + // Revenue split: works vs parts vs other (manually entered payments without WO link). + $worksRevenue = (float) WorkOrderWork::whereHas('workOrder', fn ($q) => $q->whereBetween('opened_at', [$start, $end])) + ->where('status', 'done') + ->sum('total'); + $partsRevenue = (float) WorkOrderPart::whereHas('workOrder', fn ($q) => $q->whereBetween('opened_at', [$start, $end])) + ->where('status', 'installed') + ->sum('total'); + $partsCost = (float) WorkOrderPart::whereHas('workOrder', fn ($q) => $q->whereBetween('opened_at', [$start, $end])) + ->where('status', 'installed') + ->get()->sum(fn ($p) => (float) $p->buy_price * (float) $p->qty); + $partsMargin = $partsRevenue - $partsCost; + + $expensesByCat = Expense::whereBetween('paid_at', [$start, $end]) + ->selectRaw('category, SUM(amount) total') + ->groupBy('category')->orderByDesc('total')->get(); + + $totalExpenses = (float) $expensesByCat->sum('total'); + $totalRevenue = $worksRevenue + $partsRevenue; + $netProfit = $totalRevenue - $partsCost - $totalExpenses; + $marginPct = $totalRevenue > 0 ? round($netProfit / $totalRevenue * 100, 1) : 0; + + return compact( + 'worksRevenue', 'partsRevenue', 'partsCost', 'partsMargin', + 'expensesByCat', 'totalExpenses', 'totalRevenue', 'netProfit', 'marginPct' + ); + } + + protected function balance($start, $end): array + { + $allPayments = (float) Payment::sum('amount'); + $allExpenses = (float) Expense::sum('amount'); + + $debt = (float) WorkOrder::where('pay_status', '!=', 'paid') + ->whereNotIn('status', ['cancelled']) + ->get() + ->sum(fn ($w) => max(0, (float) $w->total - (float) $w->payments()->sum('amount'))); + + $stockValue = (float) \App\Models\Tenant\Part::where('is_active', true) + ->get() + ->sum(fn ($p) => (float) $p->qty * (float) $p->buy_price); + + return [ + 'allTimePayments' => $allPayments, + 'allTimeExpenses' => $allExpenses, + 'debt' => $debt, + 'stockValue' => $stockValue, + 'netCash' => $allPayments - $allExpenses, + ]; + } +} diff --git a/app/Filament/Tenant/Pages/Recommendations.php b/app/Filament/Tenant/Pages/Recommendations.php new file mode 100644 index 0000000..cea6b50 --- /dev/null +++ b/app/Filament/Tenant/Pages/Recommendations.php @@ -0,0 +1,64 @@ += 1 visit. + $cutoff = now()->subMonths(6); + return Client::with(['workOrders' => fn ($q) => $q->latest('opened_at')->limit(1)]) + ->whereHas('workOrders', fn ($q) => $q->where('opened_at', '<', $cutoff)) + ->whereDoesntHave('workOrders', fn ($q) => $q->where('opened_at', '>=', $cutoff)) + ->where('status', '!=', 'lost') + ->limit(30) + ->get(); + } + + public function highMileageVehicles(): \Illuminate\Support\Collection + { + return Vehicle::with('client') + ->where('mileage', '>', 100000) + ->orderByDesc('mileage') + ->limit(20) + ->get(); + } + + public function unpaidWO(): \Illuminate\Support\Collection + { + return WorkOrder::with(['client', 'vehicle']) + ->where('pay_status', '!=', 'paid') + ->whereNotIn('status', ['cancelled']) + ->where('total', '>', 0) + ->orderByDesc('opened_at') + ->limit(30) + ->get(); + } + + public function vipNeedingTouchup(): \Illuminate\Support\Collection + { + // VIP clients with no contact in 30+ days. + return Client::where('status', 'vip') + ->where(fn ($q) => $q->whereNull('last_contact_at')->orWhere('last_contact_at', '<', now()->subDays(30))) + ->limit(20) + ->get(); + } +} diff --git a/app/Filament/Tenant/Resources/MarkupRuleResource.php b/app/Filament/Tenant/Resources/MarkupRuleResource.php new file mode 100644 index 0000000..396be6b --- /dev/null +++ b/app/Filament/Tenant/Resources/MarkupRuleResource.php @@ -0,0 +1,132 @@ +components([ + Schemas\Components\Section::make('Regulă') + ->columns(2) + ->schema([ + Forms\Components\Select::make('type') + ->label('Tip') + ->options(MarkupRule::TYPES) + ->default('category') + ->required() + ->live(), + Forms\Components\Select::make('key') + ->label(fn (Get $get) => $get('type') === 'brand' ? 'Brand' : 'Categorie') + ->options(fn (Get $get) => $get('type') === 'brand' + ? Part::distinct()->pluck('brand', 'brand')->filter()->toArray() + : array_combine(Part::CATEGORIES, Part::CATEGORIES)) + ->visible(fn (Get $get) => in_array($get('type'), ['category', 'brand'], true)) + ->searchable() + ->required(fn (Get $get) => in_array($get('type'), ['category', 'brand'], true)), + Forms\Components\TextInput::make('range_from') + ->label('De la (preț achiziție)') + ->numeric() + ->visible(fn (Get $get) => $get('type') === 'range'), + Forms\Components\TextInput::make('range_to') + ->label('Până la (gol = ∞)') + ->numeric() + ->visible(fn (Get $get) => $get('type') === 'range'), + Forms\Components\TextInput::make('markup_pct') + ->label('Markup %') + ->numeric() + ->required() + ->suffix('%') + ->helperText('Ex 30 → preț vânzare = preț achiziție × 1.30'), + Forms\Components\TextInput::make('priority') + ->label('Prioritate') + ->numeric() + ->default(100) + ->helperText('Mai mic = aplicat primul.'), + Forms\Components\Toggle::make('is_active')->label('Activă')->default(true), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('priority')->label('#')->sortable(), + Tables\Columns\TextColumn::make('type') + ->formatStateUsing(fn ($s) => MarkupRule::TYPES[$s] ?? $s) + ->badge(), + Tables\Columns\TextColumn::make('key')->label('Cheie')->placeholder('—'), + Tables\Columns\TextColumn::make('range_from')->label('De la')->placeholder('—'), + Tables\Columns\TextColumn::make('range_to')->label('Până la')->placeholder('∞'), + Tables\Columns\TextColumn::make('markup_pct') + ->label('Markup') + ->formatStateUsing(fn ($s) => '+' . $s . '%') + ->color('success') + ->weight('bold'), + Tables\Columns\IconColumn::make('is_active')->boolean(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('type')->options(MarkupRule::TYPES), + ]) + ->headerActions([ + Actions\Action::make('apply_all') + ->label('Aplică toate regulile la stoc') + ->icon('heroicon-m-bolt') + ->color('warning') + ->requiresConfirmation() + ->modalDescription('Va recalcula sell_price pentru TOATE piesele active. Continui?') + ->action(function () { + $count = 0; + Part::where('is_active', true)->where('buy_price', '>', 0)->chunk(100, function ($parts) use (&$count) { + foreach ($parts as $part) { + MarkupRule::applyToPart($part); + $count++; + } + }); + Notification::make()->title("Recalculat preț pentru {$count} piese")->success()->send(); + }), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->defaultSort('priority'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListMarkupRules::route('/'), + 'create' => Pages\CreateMarkupRule::route('/create'), + 'edit' => Pages\EditMarkupRule::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/MarkupRuleResource/Pages/CreateMarkupRule.php b/app/Filament/Tenant/Resources/MarkupRuleResource/Pages/CreateMarkupRule.php new file mode 100644 index 0000000..d5ee235 --- /dev/null +++ b/app/Filament/Tenant/Resources/MarkupRuleResource/Pages/CreateMarkupRule.php @@ -0,0 +1,14 @@ + 'Categorie', + 'brand' => 'Brand', + 'range' => 'Interval preț', + ]; + + protected $fillable = [ + 'company_id', 'type', 'key', + 'range_from', 'range_to', 'markup_pct', + 'priority', 'is_active', + ]; + + protected $casts = [ + 'range_from' => 'decimal:2', + 'range_to' => 'decimal:2', + 'markup_pct' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + /** + * Find best matching rule for a given Part. Priority order: + * 1) brand match 2) category match 3) range (buy_price intersect) 4) default 30%. + */ + public static function bestForPart(Part $part): ?self + { + // Brand exact + if ($part->brand) { + $r = static::where('type', 'brand') + ->where('is_active', true) + ->where('key', $part->brand) + ->orderBy('priority')->first(); + if ($r) return $r; + } + // Category exact + if ($part->category) { + $r = static::where('type', 'category') + ->where('is_active', true) + ->where('key', $part->category) + ->orderBy('priority')->first(); + if ($r) return $r; + } + // Range (buy_price ∈ [from, to)) + $price = (float) $part->buy_price; + $r = static::where('type', 'range') + ->where('is_active', true) + ->where('range_from', '<=', $price) + ->where(function ($q) use ($price) { + $q->whereNull('range_to')->orWhere('range_to', '>', $price); + }) + ->orderBy('priority')->orderByDesc('range_from')->first(); + return $r; + } + + public static function applyToPart(Part $part): bool + { + $rule = static::bestForPart($part); + $pct = $rule?->markup_pct ?? 30; + $part->sell_price = round((float) $part->buy_price * (1 + (float) $pct / 100), 2); + return $part->save(); + } +} diff --git a/database/migrations/2026_05_07_150001_create_markup_rules.php b/database/migrations/2026_05_07_150001_create_markup_rules.php new file mode 100644 index 0000000..ad4da91 --- /dev/null +++ b/database/migrations/2026_05_07_150001_create_markup_rules.php @@ -0,0 +1,31 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->string('type'); // category / brand / range + $t->string('key')->nullable(); // pentru type=category|brand: numele + $t->decimal('range_from', 10, 2)->default(0); // pentru type=range: limita inf + $t->decimal('range_to', 10, 2)->nullable(); // pentru type=range: limita sup + $t->decimal('markup_pct', 5, 2); // ex 30 = +30% + $t->unsignedSmallInteger('priority')->default(100); + $t->boolean('is_active')->default(true); + $t->timestamps(); + + $t->index(['company_id', 'type', 'is_active', 'priority']); + }); + } + + public function down(): void + { + Schema::dropIfExists('markup_rules'); + } +}; diff --git a/resources/views/filament/tenant/pages/finance.blade.php b/resources/views/filament/tenant/pages/finance.blade.php new file mode 100644 index 0000000..2f626a8 --- /dev/null +++ b/resources/views/filament/tenant/pages/finance.blade.php @@ -0,0 +1,135 @@ + + @php + $data = $this->data(); + $tabs = $this->tabs(); + $periods = $this->periods(); + @endphp + + + +
+ Perioadă: + @foreach ($periods as $key => $label) + + @endforeach +
+ +
+ @foreach ($tabs as $key => $label) + + @endforeach +
+ + @if ($tab === 'overview') +
+
Încasări
{{ number_format($data['income'], 2, '.', ' ') }} MDL
+
Cheltuieli
{{ number_format($data['expenses'], 2, '.', ' ') }} MDL
+
Profit
{{ number_format($data['profit'], 2, '.', ' ') }} MDL
+
Datorii clienți
{{ number_format($data['debtTotal'], 2, '.', ' ') }} MDL
+
+ + @elseif ($tab === 'cashflow') +
+
Total intrări
{{ number_format($data['totalIn'], 2, '.', ' ') }} MDL
+
Total ieșiri
{{ number_format($data['totalOut'], 2, '.', ' ') }} MDL
+
+
+
Cashflow zilnic — verde = încasări, roșu = cheltuieli
+
+ @foreach ($data['rows'] as $r) +
+
+
+ @if (count($data['rows']) <= 31) +
{{ $r['date'] }}
+ @endif +
+ @endforeach +
+
+ Net: {{ number_format($data['net'], 2, '.', ' ') }} MDL +
+
+ + @elseif ($tab === 'pnl') +
+
+
Venituri
+ + + + +
Manopere{{ number_format($data['worksRevenue'], 2, '.', ' ') }}
Piese (vânzare){{ number_format($data['partsRevenue'], 2, '.', ' ') }}
Total venituri{{ number_format($data['totalRevenue'], 2, '.', ' ') }}
+
+
+
Costuri
+ + + @foreach ($data['expensesByCat'] as $row) + + @endforeach + +
Cost piese (achiziție){{ number_format($data['partsCost'], 2, '.', ' ') }}
{{ \App\Models\Tenant\Expense::CATEGORIES[$row->category] ?? $row->category }}{{ number_format((float) $row->total, 2, '.', ' ') }}
Total costuri{{ number_format($data['partsCost'] + $data['totalExpenses'], 2, '.', ' ') }}
+
+
+
+
Profit net
+
{{ number_format($data['netProfit'], 2, '.', ' ') }} MDL
+
Marjă: {{ $data['marginPct'] }}%
+
Marjă piese: {{ number_format($data['partsMargin'], 2, '.', ' ') }} MDL
+
+ + @elseif ($tab === 'balance') +
+
+
Active
+ + + + + +
Numerar net (toate plățile - cheltuieli){{ number_format($data['netCash'], 2, '.', ' ') }} MDL
Datorii clienți (de încasat){{ number_format($data['debt'], 2, '.', ' ') }} MDL
Stoc piese (la preț achiziție){{ number_format($data['stockValue'], 2, '.', ' ') }} MDL
Total active{{ number_format($data['netCash'] + $data['debt'] + $data['stockValue'], 2, '.', ' ') }}
+
+
+
Toate timpurile
+ + + +
Total încasat{{ number_format($data['allTimePayments'], 2, '.', ' ') }} MDL
Total cheltuit{{ number_format($data['allTimeExpenses'], 2, '.', ' ') }} MDL
+
+
+ @endif +
diff --git a/resources/views/filament/tenant/pages/recommendations.blade.php b/resources/views/filament/tenant/pages/recommendations.blade.php new file mode 100644 index 0000000..9a85b48 --- /dev/null +++ b/resources/views/filament/tenant/pages/recommendations.blade.php @@ -0,0 +1,105 @@ + + + + @php + $lost = $this->lostClients(); + $highMileage = $this->highMileageVehicles(); + $unpaid = $this->unpaidWO(); + $vip = $this->vipNeedingTouchup(); + @endphp + +
+
+
😴 Clienți pierduți {{ $lost->count() }}
+
Clienți care n-au mai venit de peste 6 luni — bun moment pentru un mesaj de revenire.
+ + @forelse ($lost as $c) + + + + + @empty + + @endforelse +
+ {{ $c->name }} +
{{ $c->phone }}
+
+ ultima fișă
{{ $c->workOrders->first()?->opened_at?->diffForHumans() ?? '—' }} +
Niciun client pierdut. 🎉
+
+ +
+
🚗 Mașini cu kilometraj mare {{ $highMileage->count() }}
+
Sugerează revizie / piese de uzură.
+ + @forelse ($highMileage as $v) + + + + + @empty + + @endforelse +
+ {{ $v->make }} {{ $v->model }} {{ $v->year }} + @if ($v->plate) [{{ $v->plate }}] @endif +
{{ $v->client?->name }}
+
+ {{ number_format($v->mileage, 0, '.', ' ') }} km +
+
+ +
+
💰 Fișe neplătite {{ $unpaid->count() }}
+
Sună-i sau trimite-le un email cu suma datorată.
+ + @forelse ($unpaid as $w) + + + + + @empty + + @endforelse +
+ {{ $w->number }} + — {{ $w->client?->name }} +
{{ $w->vehicle?->make }} {{ $w->vehicle?->model }} {{ $w->vehicle?->plate ? '[' . $w->vehicle->plate . ']' : '' }}
+
+ {{ number_format(max(0, (float) $w->total - (float) $w->payments->sum('amount')), 2, '.', ' ') }} MDL +
Niciun rest de încasat. 🎉
+
+ +
+
⭐ VIP fără contact recent {{ $vip->count() }}
+
Clienți VIP cu care nu s-a vorbit de peste 30 zile.
+ + @forelse ($vip as $c) + + + + + @empty + + @endforelse +
+ {{ $c->name }} +
{{ $c->phone }}
+
+ {{ $c->last_contact_at?->diffForHumans() ?? 'niciodată' }} +
Toți VIP-ii sunt în contact recent.
+
+
+