New-Thanasoft/thanasoft-back/app/Repositories/FinancialStatisticsRepository.php
2026-05-11 13:30:24 +03:00

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,
];
}
}