297 lines
12 KiB
PHP
297 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repositories;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class FinancialStatisticsRepository implements FinancialStatisticsRepositoryInterface
|
|
{
|
|
/**
|
|
* Statuses considered as actual revenue (exclude drafts & cancelled).
|
|
*/
|
|
private const REVENUE_STATUSES = ['emise', 'envoyee', 'partiellement_payee', 'payee', 'echue'];
|
|
|
|
/**
|
|
* Statuses representing open receivables (unpaid, overdue).
|
|
*/
|
|
private const RECEIVABLE_STATUSES = ['emise', 'envoyee', 'partiellement_payee', 'echue'];
|
|
|
|
/**
|
|
* Avoir statuses that count as effectively emitted.
|
|
*/
|
|
private const AVOIR_EMITTED_STATUSES = ['emis', 'applique'];
|
|
|
|
public function getStatistics(): array
|
|
{
|
|
return [
|
|
'revenue' => $this->revenueStats(),
|
|
'quote_conversion' => $this->quoteConversionRate(),
|
|
'avg_amount_per_case' => $this->avgAmountPerCase(),
|
|
'avg_payment_delay' => $this->avgPaymentDelay(),
|
|
'avoirs' => $this->avoirsStats(),
|
|
'receivables' => $this->receivablesStats(),
|
|
];
|
|
}
|
|
|
|
// ─── 1. Chiffre d'affaires mensuel & annuel ───────────────────────────────
|
|
|
|
private function revenueStats(): array
|
|
{
|
|
$currentYear = now()->year;
|
|
|
|
// Monthly CA for current year
|
|
$monthly = DB::table('invoices')
|
|
->selectRaw('MONTH(invoice_date) as month, SUM(total_ttc) as total_ttc, SUM(total_ht) as total_ht, COUNT(*) as count')
|
|
->whereIn('status', self::REVENUE_STATUSES)
|
|
->whereYear('invoice_date', $currentYear)
|
|
->groupByRaw('MONTH(invoice_date)')
|
|
->orderByRaw('MONTH(invoice_date)')
|
|
->get()
|
|
->keyBy('month');
|
|
|
|
// Build a full 12-month array (0 for missing months)
|
|
$monthlyFull = [];
|
|
for ($m = 1; $m <= 12; $m++) {
|
|
$row = $monthly->get($m);
|
|
$monthlyFull[] = [
|
|
'month' => $m,
|
|
'total_ttc' => $row ? (float) $row->total_ttc : 0.0,
|
|
'total_ht' => $row ? (float) $row->total_ht : 0.0,
|
|
'count' => $row ? (int) $row->count : 0,
|
|
];
|
|
}
|
|
|
|
// Annual CA — current and previous year for comparison
|
|
$annualRows = DB::table('invoices')
|
|
->selectRaw('YEAR(invoice_date) as year, SUM(total_ttc) as total_ttc, SUM(total_ht) as total_ht, COUNT(*) as count')
|
|
->whereIn('status', self::REVENUE_STATUSES)
|
|
->whereYear('invoice_date', '>=', $currentYear - 1)
|
|
->groupByRaw('YEAR(invoice_date)')
|
|
->orderByRaw('YEAR(invoice_date)')
|
|
->get()
|
|
->keyBy('year');
|
|
|
|
$yearCurrent = $annualRows->get($currentYear);
|
|
$yearPrevious = $annualRows->get($currentYear - 1);
|
|
|
|
$annualCurrentTtc = $yearCurrent ? (float) $yearCurrent->total_ttc : 0.0;
|
|
$annualPreviousTtc = $yearPrevious ? (float) $yearPrevious->total_ttc : 0.0;
|
|
|
|
$annualGrowthPct = $annualPreviousTtc > 0
|
|
? round((($annualCurrentTtc - $annualPreviousTtc) / $annualPreviousTtc) * 100, 2)
|
|
: null;
|
|
|
|
return [
|
|
'current_year' => $currentYear,
|
|
'annual_current' => [
|
|
'total_ttc' => $annualCurrentTtc,
|
|
'total_ht' => $yearCurrent ? (float) $yearCurrent->total_ht : 0.0,
|
|
'count' => $yearCurrent ? (int) $yearCurrent->count : 0,
|
|
],
|
|
'annual_previous' => [
|
|
'total_ttc' => $annualPreviousTtc,
|
|
'total_ht' => $yearPrevious ? (float) $yearPrevious->total_ht : 0.0,
|
|
'count' => $yearPrevious ? (int) $yearPrevious->count : 0,
|
|
],
|
|
'annual_growth_pct' => $annualGrowthPct,
|
|
'monthly' => $monthlyFull,
|
|
];
|
|
}
|
|
|
|
// ─── 2. Taux de conversion devis → facture ────────────────────────────────
|
|
|
|
private function quoteConversionRate(): array
|
|
{
|
|
$totalQuotes = (int) DB::table('quotes')
|
|
->whereNotIn('status', ['annule'])
|
|
->count();
|
|
|
|
// A quote is "converted" when at least one invoice references it
|
|
$convertedQuotes = (int) DB::table('quotes')
|
|
->join('invoices', 'invoices.source_quote_id', '=', 'quotes.id')
|
|
->whereNotIn('quotes.status', ['annule'])
|
|
->distinct('quotes.id')
|
|
->count('quotes.id');
|
|
|
|
$rate = $totalQuotes > 0
|
|
? round(($convertedQuotes / $totalQuotes) * 100, 2)
|
|
: 0.0;
|
|
|
|
// Breakdown by quote status
|
|
$byStatus = DB::table('quotes')
|
|
->selectRaw('status, COUNT(*) as total')
|
|
->groupBy('status')
|
|
->get();
|
|
|
|
return [
|
|
'total_quotes' => $totalQuotes,
|
|
'converted_quotes' => $convertedQuotes,
|
|
'conversion_rate' => $rate,
|
|
'by_status' => $byStatus,
|
|
];
|
|
}
|
|
|
|
// ─── 3. Montant moyen par dossier (panier moyen) ─────────────────────────
|
|
|
|
private function avgAmountPerCase(): array
|
|
{
|
|
$row = DB::table('invoices')
|
|
->selectRaw('AVG(total_ttc) as avg_ttc, AVG(total_ht) as avg_ht, COUNT(*) as total_count, SUM(total_ttc) as sum_ttc')
|
|
->whereIn('status', self::REVENUE_STATUSES)
|
|
->first();
|
|
|
|
return [
|
|
'avg_ttc' => $row && $row->avg_ttc !== null ? round((float) $row->avg_ttc, 2) : null,
|
|
'avg_ht' => $row && $row->avg_ht !== null ? round((float) $row->avg_ht, 2) : null,
|
|
'total_count' => $row ? (int) $row->total_count : 0,
|
|
'total_ttc' => $row ? (float) $row->sum_ttc : 0.0,
|
|
];
|
|
}
|
|
|
|
// ─── 4. Délai moyen de paiement ───────────────────────────────────────────
|
|
|
|
private function avgPaymentDelay(): array
|
|
{
|
|
// Use document_status_history to find the exact moment status became 'payee'
|
|
$avgFromHistory = DB::table('invoices')
|
|
->join('document_status_history as dsh', function ($join) {
|
|
$join->on('dsh.document_id', '=', 'invoices.id')
|
|
->where('dsh.document_type', '=', 'invoice')
|
|
->where('dsh.new_status', '=', 'payee');
|
|
})
|
|
->selectRaw('AVG(DATEDIFF(dsh.changed_at, invoices.invoice_date)) as avg_days')
|
|
->where('invoices.status', 'payee')
|
|
->value('avg_days');
|
|
|
|
// Fallback: avg delay between invoice_date and due_date for paid invoices
|
|
$avgFromDueDate = DB::table('invoices')
|
|
->selectRaw('AVG(DATEDIFF(due_date, invoice_date)) as avg_days')
|
|
->where('status', 'payee')
|
|
->whereNotNull('due_date')
|
|
->value('avg_days');
|
|
|
|
// Count overdue invoices (echue = past due_date, still unpaid)
|
|
$overdueCount = (int) DB::table('invoices')
|
|
->where('status', 'echue')
|
|
->count();
|
|
|
|
$overdueTotal = (float) DB::table('invoices')
|
|
->where('status', 'echue')
|
|
->sum('total_ttc');
|
|
|
|
return [
|
|
'avg_days_to_payment' => $avgFromHistory !== null
|
|
? round((float) $avgFromHistory, 1)
|
|
: ($avgFromDueDate !== null ? round((float) $avgFromDueDate, 1) : null),
|
|
'avg_days_invoice_to_due' => $avgFromDueDate !== null
|
|
? round((float) $avgFromDueDate, 1)
|
|
: null,
|
|
'overdue_invoices_count' => $overdueCount,
|
|
'overdue_invoices_total_ttc' => $overdueTotal,
|
|
];
|
|
}
|
|
|
|
// ─── 5. Volume d'avoirs émis ──────────────────────────────────────────────
|
|
|
|
private function avoirsStats(): array
|
|
{
|
|
$totals = DB::table('avoirs')
|
|
->selectRaw('COUNT(*) as count, SUM(total_ttc) as total_ttc, SUM(total_ht) as total_ht')
|
|
->whereIn('status', self::AVOIR_EMITTED_STATUSES)
|
|
->first();
|
|
|
|
// Breakdown by reason type
|
|
$byReason = DB::table('avoirs')
|
|
->selectRaw('reason_type, COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
|
->whereIn('status', self::AVOIR_EMITTED_STATUSES)
|
|
->groupBy('reason_type')
|
|
->orderByDesc('count')
|
|
->get();
|
|
|
|
// Monthly trend (current year)
|
|
$monthlyTrend = DB::table('avoirs')
|
|
->selectRaw('MONTH(avoir_date) as month, COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
|
->whereIn('status', self::AVOIR_EMITTED_STATUSES)
|
|
->whereYear('avoir_date', now()->year)
|
|
->groupByRaw('MONTH(avoir_date)')
|
|
->orderByRaw('MONTH(avoir_date)')
|
|
->get();
|
|
|
|
return [
|
|
'total_count' => $totals ? (int) $totals->count : 0,
|
|
'total_ttc' => $totals ? (float) $totals->total_ttc : 0.0,
|
|
'total_ht' => $totals ? (float) $totals->total_ht : 0.0,
|
|
'by_reason' => $byReason,
|
|
'monthly_trend' => $monthlyTrend,
|
|
];
|
|
}
|
|
|
|
// ─── 6. Créances en cours (relances prioritaires) ─────────────────────────
|
|
|
|
private function receivablesStats(): array
|
|
{
|
|
// Global totals
|
|
$totals = DB::table('invoices')
|
|
->selectRaw('COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
|
->whereIn('status', self::RECEIVABLE_STATUSES)
|
|
->first();
|
|
|
|
// Breakdown by status
|
|
$byStatus = DB::table('invoices')
|
|
->selectRaw('status, COUNT(*) as count, SUM(total_ttc) as total_ttc')
|
|
->whereIn('status', self::RECEIVABLE_STATUSES)
|
|
->groupBy('status')
|
|
->get();
|
|
|
|
// Top 10 clients with highest outstanding balance
|
|
$topDebtors = DB::table('invoices')
|
|
->join('clients', 'invoices.client_id', '=', 'clients.id')
|
|
->select(
|
|
'clients.id',
|
|
'clients.name',
|
|
DB::raw('COUNT(invoices.id) as invoice_count'),
|
|
DB::raw('SUM(invoices.total_ttc) as total_outstanding')
|
|
)
|
|
->whereIn('invoices.status', self::RECEIVABLE_STATUSES)
|
|
->groupBy('clients.id', 'clients.name')
|
|
->orderByDesc('total_outstanding')
|
|
->limit(10)
|
|
->get();
|
|
|
|
// Overdue (echue) + near-due (due within 7 days) detail
|
|
$criticalInvoices = DB::table('invoices')
|
|
->join('clients', 'invoices.client_id', '=', 'clients.id')
|
|
->select(
|
|
'invoices.id',
|
|
'invoices.invoice_number',
|
|
'invoices.status',
|
|
'invoices.due_date',
|
|
'invoices.total_ttc',
|
|
'clients.name as client_name',
|
|
'clients.id as client_id',
|
|
DB::raw('DATEDIFF(NOW(), invoices.due_date) as days_overdue')
|
|
)
|
|
->where(function ($q) {
|
|
$q->where('invoices.status', 'echue')
|
|
->orWhere(function ($q2) {
|
|
$q2->whereIn('invoices.status', ['emise', 'envoyee', 'partiellement_payee'])
|
|
->whereRaw('invoices.due_date <= DATE_ADD(NOW(), INTERVAL 7 DAY)')
|
|
->whereNotNull('invoices.due_date');
|
|
});
|
|
})
|
|
->orderByRaw('invoices.due_date ASC')
|
|
->limit(20)
|
|
->get();
|
|
|
|
return [
|
|
'total_count' => $totals ? (int) $totals->count : 0,
|
|
'total_outstanding' => $totals ? (float) $totals->total_ttc : 0.0,
|
|
'by_status' => $byStatus,
|
|
'top_debtors' => $topDebtors,
|
|
'critical_invoices' => $criticalInvoices,
|
|
];
|
|
}
|
|
}
|