Batch 1: Procentaj + Finanțe consolidat + Recomandări

═══ 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.
This commit is contained in:
2026-05-07 15:30:04 +00:00
parent 976c0f03e3
commit 67da97178d
10 changed files with 758 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Expense;
use App\Models\Tenant\Payment;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Models\Tenant\WorkOrderWork;
use Carbon\Carbon;
use Filament\Pages\Page;
class Finance extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-currency-dollar';
protected static ?string $navigationLabel = 'Finanțe (consolidat)';
protected static string|\UnitEnum|null $navigationGroup = 'Finanțe';
protected static ?int $navigationSort = 49;
protected static ?string $title = 'Finanțe';
protected string $view = 'filament.tenant.pages.finance';
public string $tab = 'overview';
public string $period = 'this_month';
public function setTab(string $tab): void { $this->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,
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use Filament\Pages\Page;
class Recommendations extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-light-bulb';
protected static ?string $navigationLabel = 'Recomandări';
protected static string|\UnitEnum|null $navigationGroup = 'Analiză';
protected static ?int $navigationSort = 72;
protected static ?string $title = 'Recomandări — clienți de urmărit';
protected string $view = 'filament.tenant.pages.recommendations';
public function lostClients(): \Illuminate\Support\Collection
{
// Clients with no WO in last 6 months and previously >= 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();
}
}
@@ -0,0 +1,132 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\MarkupRuleResource\Pages;
use App\Models\Tenant\MarkupRule;
use App\Models\Tenant\Part;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class MarkupRuleResource extends Resource
{
protected static ?string $model = MarkupRule::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-percent-badge';
protected static ?string $navigationLabel = 'Procentaj';
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
protected static ?string $modelLabel = 'regulă';
protected static ?string $pluralModelLabel = 'reguli markup';
protected static ?int $navigationSort = 44;
public static function form(Schema $schema): Schema
{
return $schema->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'),
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Filament\Tenant\Resources\MarkupRuleResource\Pages;
use App\Filament\Tenant\Resources\MarkupRuleResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
class CreateMarkupRule extends CreateRecord
{
protected static string $resource = MarkupRuleResource::class;
}
@@ -0,0 +1,14 @@
<?php
namespace App\Filament\Tenant\Resources\MarkupRuleResource\Pages;
use App\Filament\Tenant\Resources\MarkupRuleResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditMarkupRule extends EditRecord
{
protected static string $resource = MarkupRuleResource::class;
protected function getHeaderActions(): array { return [Actions\DeleteAction::make()]; }
}
@@ -0,0 +1,14 @@
<?php
namespace App\Filament\Tenant\Resources\MarkupRuleResource\Pages;
use App\Filament\Tenant\Resources\MarkupRuleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListMarkupRules extends ListRecords
{
protected static string $resource = MarkupRuleResource::class;
protected function getHeaderActions(): array { return [Actions\CreateAction::make()]; }
}