ChartController for analytics

This commit is contained in:
David Bomba
2026-03-30 11:43:03 +11:00
parent 57aa3e763e
commit 60d3cf74f2
8 changed files with 726 additions and 6 deletions

View File

@@ -14,7 +14,9 @@ namespace App\Http\Controllers;
use App\Services\Chart\ChartService;
use App\Http\Requests\Chart\ShowChartRequest;
use App\Http\Requests\Chart\ShowForecastRequest;
use App\Http\Requests\Chart\ShowCalculatedFieldRequest;
use Illuminate\Support\Facades\Cache;
class ChartController extends BaseController
{
@@ -81,9 +83,16 @@ class ChartController extends BaseController
$user = auth()->user();
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
$start = $request->input('start_date');
$end = $request->input('end_date');
$cacheKey = "analytics_summary:{$user->company()->id}:{$user->id}:{$start}:{$end}";
return response()->json($cs->analytics_summary($request->input('start_date'), $request->input('end_date')), 200);
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions, $start, $end) {
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
return $cs->analytics_summary($start, $end);
});
return response()->json($data, 200);
}
public function analytics_totals(ShowChartRequest $request)
@@ -92,9 +101,51 @@ class ChartController extends BaseController
$user = auth()->user();
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
$start = $request->input('start_date');
$end = $request->input('end_date');
$cacheKey = "analytics_totals:{$user->company()->id}:{$user->id}:{$start}:{$end}";
return response()->json($cs->analytics_totals($request->input('start_date'), $request->input('end_date')), 200);
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions, $start, $end) {
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
return $cs->analytics_totals($start, $end);
});
return response()->json($data, 200);
}
public function cashflow_forecast(ShowForecastRequest $request)
{
/** @var \App\Models\User auth()->user() */
$user = auth()->user();
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
$start = $request->input('start_date');
$end = $request->input('end_date');
$bucket = $request->input('bucket_type', 'monthly');
$cacheKey = "cashflow_forecast:{$user->company()->id}:{$user->id}:{$start}:{$end}:{$bucket}";
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions, $start, $end, $bucket) {
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
return $cs->cashflow_forecast($start, $end, $bucket);
});
return response()->json($data, 200);
}
public function client_payment_analytics(ShowChartRequest $request)
{
/** @var \App\Models\User auth()->user() */
$user = auth()->user();
$admin_equivalent_permissions = $user->isAdmin() || $user->hasExactPermissionAndAll('view_all') || $user->hasExactPermissionAndAll('edit_all');
$cacheKey = "client_payment_analytics:{$user->company()->id}:{$user->id}";
$data = Cache::remember($cacheKey, now()->addMinutes(15), function () use ($user, $admin_equivalent_permissions) {
$cs = new ChartService($user->company(), $user, $admin_equivalent_permissions);
return $cs->client_payment_analytics();
});
return response()->json($data, 200);
}
public function calculatedFields(ShowCalculatedFieldRequest $request)

View File

@@ -0,0 +1,53 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Chart;
class ShowForecastRequest extends ShowChartRequest
{
public function rules()
{
return array_merge(parent::rules(), [
'bucket_type' => 'bail|sometimes|string|in:weekly,monthly',
]);
}
public function prepareForValidation()
{
/** @var \App\Models\User auth()->user */
$user = auth()->user();
$input = $this->all();
$input['include_drafts'] = filter_var($input['include_drafts'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (isset($input['date_range'])) {
$dates = $this->calculateStartAndEndDates($input, $user->company());
$input['start_date'] = $dates[0];
$input['end_date'] = $dates[1];
}
if (! isset($input['start_date'])) {
$input['start_date'] = now()->format('Y-m-d');
}
if (! isset($input['end_date'])) {
$input['end_date'] = now()->addMonths(6)->format('Y-m-d');
}
if (! isset($input['bucket_type'])) {
$input['bucket_type'] = 'monthly';
}
$this->replace($input);
}
}

View File

@@ -143,7 +143,12 @@ class InvoicePay extends Component
public function payableAmount($payable_amount)
{
$invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id);
$this->setContext($invite->key, 'amount', $payable_amount);
// $this->setContext($invite->key, 'amount', $payable_amount);
$this->bulkSetContext($invite->key, [
'amount' => $payable_amount,
'payment_processed' => null,
]);
$this->under_over_payment = false;
}
@@ -158,6 +163,7 @@ class InvoicePay extends Component
'amount' => $amount,
'pre_payment' => false,
'is_recurring' => false,
'payment_processed' => null,
]);

View File

@@ -36,9 +36,18 @@ class ProcessPayment extends Component
MultiDB::setDb($this->getContext($this->_key)['db']);
$_context = $this->getContext($this->_key);
if (isset($_context['payment_processed'])) {
$this->payment_view = $_context['payment_processed']['payment_view'];
$this->payment_data_payload = $_context['payment_processed']['payment_data_payload'];
$this->isLoading = false;
return;
}
$invitation = InvoiceInvitation::find($this->getContext($this->_key)['invitation_id']);
$_context = $this->getContext($this->_key);
// $_context = $this->getContext($this->_key);
$data = [
'company_gateway_id' => $_context['company_gateway_id'],
@@ -91,6 +100,11 @@ class ProcessPayment extends Component
);
}
$this->setContext($this->_key, 'payment_processed', [
'payment_view' => $this->payment_view,
'payment_data_payload' => $this->payment_data_payload,
]);
$this->isLoading = false;
}

View File

@@ -0,0 +1,412 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Chart;
use App\Models\Company;
use App\Models\RecurringInvoice;
use Carbon\Carbon;
class CashFlowForecastService
{
private const MAX_DAILY_ITERATIONS = 365;
private const FREQUENCY_INTERVALS = [
RecurringInvoice::FREQUENCY_DAILY => ['addDay', 1],
RecurringInvoice::FREQUENCY_WEEKLY => ['addWeek', 1],
RecurringInvoice::FREQUENCY_TWO_WEEKS => ['addWeeks', 2],
RecurringInvoice::FREQUENCY_FOUR_WEEKS => ['addWeeks', 4],
RecurringInvoice::FREQUENCY_MONTHLY => ['addMonthNoOverflow', 1],
RecurringInvoice::FREQUENCY_TWO_MONTHS => ['addMonthsNoOverflow', 2],
RecurringInvoice::FREQUENCY_THREE_MONTHS => ['addMonthsNoOverflow', 3],
RecurringInvoice::FREQUENCY_FOUR_MONTHS => ['addMonthsNoOverflow', 4],
RecurringInvoice::FREQUENCY_SIX_MONTHS => ['addMonthsNoOverflow', 6],
RecurringInvoice::FREQUENCY_ANNUALLY => ['addYear', 1],
RecurringInvoice::FREQUENCY_TWO_YEARS => ['addYears', 2],
RecurringInvoice::FREQUENCY_THREE_YEARS => ['addYears', 3],
];
private const WEIGHT_AUTO_BILL = 0.95;
private const WEIGHT_NO_AUTO_BILL = 0.75;
private const DEFAULT_PAYMENT_DAYS = 14;
private const FALLBACK_CONFIDENCE_MULTIPLIER = 0.5;
/** @var array<string, array<string, mixed>> */
private array $buckets = [];
public function __construct(
private Company $company,
private string $start_date,
private string $end_date,
private string $bucket_type = 'monthly',
) {}
/**
* @param array<int, \stdClass> $outstandingInvoices
* @param array<int, \stdClass> $recurringInvoices
* @param array<int, \stdClass> $recurringExpenses
* @param array<int, \stdClass> $upcomingExpenses
* @param array<int, \stdClass> $openQuotes
* @param array<int, \stdClass> $companyPaymentSummary
* @return array<string, mixed>
*/
public function generate(
array $outstandingInvoices,
array $recurringInvoices,
array $recurringExpenses,
array $upcomingExpenses,
array $openQuotes,
array $companyPaymentSummary,
): array {
$this->buildBucketGrid();
$companyFallback = ! empty($companyPaymentSummary) ? $companyPaymentSummary[0] : null;
$this->projectOutstandingInvoices($outstandingInvoices, $companyFallback);
$this->projectRecurringInvoices($recurringInvoices);
$this->projectRecurringExpenses($recurringExpenses);
$this->projectUpcomingExpenses($upcomingExpenses);
$this->projectOpenQuotes($openQuotes);
return $this->buildResponse();
}
private function buildBucketGrid(): void
{
$current = Carbon::parse($this->start_date);
$end = Carbon::parse($this->end_date);
while ($current->lte($end)) {
if ($this->bucket_type === 'weekly') {
$key = $current->format('o-\\WW');
$periodStart = $current->copy()->startOfWeek()->format('Y-m-d');
$periodEnd = $current->copy()->endOfWeek()->format('Y-m-d');
$current->addWeek();
} else {
$key = $current->format('Y-m');
$periodStart = $current->copy()->startOfMonth()->format('Y-m-d');
$periodEnd = $current->copy()->endOfMonth()->format('Y-m-d');
$current->addMonthNoOverflow();
}
$this->buckets[$key] = [
'period' => $key,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'inflows' => [
'outstanding_invoices' => ['amount' => 0.0, 'count' => 0, 'weighted_amount' => 0.0],
'recurring_invoices' => ['amount' => 0.0, 'count' => 0, 'weighted_amount' => 0.0],
'quote_pipeline' => ['amount' => 0.0, 'count' => 0, 'weighted_amount' => 0.0],
'total' => 0.0,
'weighted_total' => 0.0,
],
'outflows' => [
'recurring_expenses' => ['amount' => 0.0, 'count' => 0],
'one_off_expenses' => ['amount' => 0.0, 'count' => 0],
'total' => 0.0,
],
'net' => 0.0,
'weighted_net' => 0.0,
'confidence' => 0.0,
'_weights' => [],
];
}
}
private function dateToBucketKey(string $date): ?string
{
$d = Carbon::parse($date);
$start = Carbon::parse($this->start_date);
$end = Carbon::parse($this->end_date);
if ($d->lt($start) || $d->gt($end)) {
return null;
}
if ($this->bucket_type === 'weekly') {
return $d->format('o-\\WW');
}
return $d->format('Y-m');
}
private function normalizeToCompanyCurrency(float $amount, float $exchangeRate): float
{
$rate = ($exchangeRate == 0) ? 1.0 : $exchangeRate;
return $amount / $rate;
}
/**
* @param array<int, \stdClass> $invoices
*/
private function projectOutstandingInvoices(array $invoices, ?\stdClass $companyFallback): void
{
$companyAvgDays = $companyFallback->avg_payment_days ?? self::DEFAULT_PAYMENT_DAYS;
foreach ($invoices as $invoice) {
$avgDays = $invoice->client_avg_payment_days;
$lateRatio = $invoice->client_late_ratio ?? 0;
$dataPoints = $invoice->client_data_points ?? 0;
if ($avgDays === null) {
$avgDays = $companyAvgDays;
$weight = self::FALLBACK_CONFIDENCE_MULTIPLIER;
} else {
$weight = (1 - $lateRatio) * min($dataPoints / 30, 1.0);
}
$expectedDate = Carbon::parse($invoice->invoice_date)->addDays((int) round($avgDays))->format('Y-m-d');
$bucketKey = $this->dateToBucketKey($expectedDate);
if ($bucketKey === null || ! isset($this->buckets[$bucketKey])) {
continue;
}
$amount = $this->normalizeToCompanyCurrency((float) $invoice->balance, (float) ($invoice->exchange_rate ?? 1));
$this->buckets[$bucketKey]['inflows']['outstanding_invoices']['amount'] += $amount;
$this->buckets[$bucketKey]['inflows']['outstanding_invoices']['count']++;
$this->buckets[$bucketKey]['inflows']['outstanding_invoices']['weighted_amount'] += $amount * $weight;
$this->buckets[$bucketKey]['_weights'][] = ['amount' => $amount, 'weight' => $weight];
}
}
/**
* @param array<int, \stdClass> $recurringInvoices
*/
private function projectRecurringInvoices(array $recurringInvoices): void
{
foreach ($recurringInvoices as $ri) {
$weight = $ri->auto_bill_enabled ? self::WEIGHT_AUTO_BILL : self::WEIGHT_NO_AUTO_BILL;
$amount = $this->normalizeToCompanyCurrency((float) $ri->amount, (float) ($ri->exchange_rate ?? 1));
$remainingCycles = (int) $ri->remaining_cycles;
$frequencyId = (int) $ri->frequency_id;
$date = Carbon::parse($ri->next_send_date);
$endDate = Carbon::parse($this->end_date);
$iterations = 0;
$maxIterations = ($frequencyId === RecurringInvoice::FREQUENCY_DAILY) ? self::MAX_DAILY_ITERATIONS : 1000;
while ($date->lte($endDate) && $iterations < $maxIterations) {
if ($remainingCycles !== -1 && $iterations >= $remainingCycles) {
break;
}
$bucketKey = $this->dateToBucketKey($date->format('Y-m-d'));
if ($bucketKey !== null && isset($this->buckets[$bucketKey])) {
$this->buckets[$bucketKey]['inflows']['recurring_invoices']['amount'] += $amount;
$this->buckets[$bucketKey]['inflows']['recurring_invoices']['count']++;
$this->buckets[$bucketKey]['inflows']['recurring_invoices']['weighted_amount'] += $amount * $weight;
$this->buckets[$bucketKey]['_weights'][] = ['amount' => $amount, 'weight' => $weight];
}
$date = self::advanceDateByFrequency($date, $frequencyId);
if ($date === null) {
break;
}
$iterations++;
}
}
}
/**
* @param array<int, \stdClass> $recurringExpenses
*/
private function projectRecurringExpenses(array $recurringExpenses): void
{
foreach ($recurringExpenses as $re) {
$amount = $this->normalizeToCompanyCurrency(
$this->computeExpenseWithTax($re),
(float) ($re->exchange_rate ?? 1)
);
$remainingCycles = (int) $re->remaining_cycles;
$frequencyId = (int) $re->frequency_id;
$date = Carbon::parse($re->next_send_date);
$endDate = Carbon::parse($this->end_date);
$iterations = 0;
$maxIterations = ($frequencyId === RecurringInvoice::FREQUENCY_DAILY) ? self::MAX_DAILY_ITERATIONS : 1000;
while ($date->lte($endDate) && $iterations < $maxIterations) {
if ($remainingCycles !== -1 && $iterations >= $remainingCycles) {
break;
}
$bucketKey = $this->dateToBucketKey($date->format('Y-m-d'));
if ($bucketKey !== null && isset($this->buckets[$bucketKey])) {
$this->buckets[$bucketKey]['outflows']['recurring_expenses']['amount'] += $amount;
$this->buckets[$bucketKey]['outflows']['recurring_expenses']['count']++;
}
$date = self::advanceDateByFrequency($date, $frequencyId);
if ($date === null) {
break;
}
$iterations++;
}
}
}
/**
* @param array<int, \stdClass> $expenses
*/
private function projectUpcomingExpenses(array $expenses): void
{
foreach ($expenses as $expense) {
$bucketKey = $this->dateToBucketKey($expense->date);
if ($bucketKey === null || ! isset($this->buckets[$bucketKey])) {
continue;
}
$amount = $this->normalizeToCompanyCurrency((float) $expense->amount, (float) ($expense->exchange_rate ?? 1));
$this->buckets[$bucketKey]['outflows']['one_off_expenses']['amount'] += $amount;
$this->buckets[$bucketKey]['outflows']['one_off_expenses']['count']++;
}
}
/**
* @param array<int, \stdClass> $quotes
*/
private function projectOpenQuotes(array $quotes): void
{
foreach ($quotes as $quote) {
$conversionRate = (float) ($quote->client_conversion_rate ?? 0);
$avgConversionDays = (float) ($quote->client_avg_conversion_days ?? 0);
if ($conversionRate <= 0) {
continue;
}
$expectedDate = Carbon::parse($quote->date)->addDays((int) round($avgConversionDays))->format('Y-m-d');
$bucketKey = $this->dateToBucketKey($expectedDate);
if ($bucketKey === null || ! isset($this->buckets[$bucketKey])) {
continue;
}
$amount = $this->normalizeToCompanyCurrency((float) $quote->amount, (float) ($quote->exchange_rate ?? 1));
$this->buckets[$bucketKey]['inflows']['quote_pipeline']['amount'] += $amount;
$this->buckets[$bucketKey]['inflows']['quote_pipeline']['count']++;
$this->buckets[$bucketKey]['inflows']['quote_pipeline']['weighted_amount'] += $amount * $conversionRate;
$this->buckets[$bucketKey]['_weights'][] = ['amount' => $amount, 'weight' => $conversionRate];
}
}
private static function advanceDateByFrequency(Carbon $date, int $frequencyId): ?Carbon
{
if (! isset(self::FREQUENCY_INTERVALS[$frequencyId])) {
return null;
}
[$method, $value] = self::FREQUENCY_INTERVALS[$frequencyId];
return $date->copy()->{$method}($value);
}
private function computeExpenseWithTax(\stdClass $expense): float
{
$amount = (float) $expense->amount;
if ($expense->uses_inclusive_taxes) {
return $amount;
}
$taxFromRates = $amount * (((float) ($expense->tax_rate1 ?? 0) + (float) ($expense->tax_rate2 ?? 0) + (float) ($expense->tax_rate3 ?? 0)) / 100);
$fixedTax = (float) ($expense->tax_amount1 ?? 0) + (float) ($expense->tax_amount2 ?? 0) + (float) ($expense->tax_amount3 ?? 0);
return $amount + $taxFromRates + $fixedTax;
}
private function calculateBucketConfidence(array $weights): float
{
if (empty($weights)) {
return 0.0;
}
$totalAmount = 0.0;
$weightedSum = 0.0;
foreach ($weights as $w) {
$totalAmount += abs($w['amount']);
$weightedSum += abs($w['amount']) * $w['weight'];
}
if ($totalAmount == 0) {
return 0.0;
}
return round($weightedSum / $totalAmount, 4);
}
/**
* @return array<string, mixed>
*/
private function buildResponse(): array
{
$totalInflows = 0.0;
$weightedInflows = 0.0;
$totalOutflows = 0.0;
$resultBuckets = [];
foreach ($this->buckets as $key => &$bucket) {
$inflowTotal = $bucket['inflows']['outstanding_invoices']['amount']
+ $bucket['inflows']['recurring_invoices']['amount']
+ $bucket['inflows']['quote_pipeline']['amount'];
$inflowWeighted = $bucket['inflows']['outstanding_invoices']['weighted_amount']
+ $bucket['inflows']['recurring_invoices']['weighted_amount']
+ $bucket['inflows']['quote_pipeline']['weighted_amount'];
$outflowTotal = $bucket['outflows']['recurring_expenses']['amount']
+ $bucket['outflows']['one_off_expenses']['amount'];
$bucket['inflows']['total'] = round($inflowTotal, 2);
$bucket['inflows']['weighted_total'] = round($inflowWeighted, 2);
$bucket['outflows']['total'] = round($outflowTotal, 2);
$bucket['net'] = round($inflowTotal - $outflowTotal, 2);
$bucket['weighted_net'] = round($inflowWeighted - $outflowTotal, 2);
$bucket['confidence'] = $this->calculateBucketConfidence($bucket['_weights']);
$totalInflows += $inflowTotal;
$weightedInflows += $inflowWeighted;
$totalOutflows += $outflowTotal;
unset($bucket['_weights']);
$resultBuckets[] = $bucket;
}
return [
'start_date' => $this->start_date,
'end_date' => $this->end_date,
'bucket_type' => $this->bucket_type,
'buckets' => $resultBuckets,
'totals' => [
'total_inflows' => round($totalInflows, 2),
'weighted_inflows' => round($weightedInflows, 2),
'total_outflows' => round($totalOutflows, 2),
'net' => round($totalInflows - $totalOutflows, 2),
'weighted_net' => round($weightedInflows - $totalOutflows, 2),
],
];
}
}

View File

@@ -20,6 +20,8 @@ use App\Models\Payment;
use App\Models\Quote;
use App\Models\Task;
use App\Models\User;
use App\Services\Chart\CashFlowForecastService;
use App\Services\Chart\ClientPaymentAnalyticsService;
use Illuminate\Support\Facades\Cache;
class ChartService
@@ -276,6 +278,36 @@ class ChartService
return $data;
}
/**
* Cash flow forecast — time-bucketed inflow/outflow projection.
*/
public function cashflow_forecast(string $start_date, string $end_date, string $bucket_type = 'monthly'): array
{
$forecast = new CashFlowForecastService($this->company, $start_date, $end_date, $bucket_type);
return $forecast->generate(
$this->getOutstandingInvoicesForForecasting(),
$this->getRecurringInvoiceProjections(),
$this->getRecurringExpenseProjections(),
$this->getUpcomingExpenses($start_date, $end_date),
$this->getOpenQuotesForForecasting(),
$this->getCompanyPaymentSummary()
);
}
/**
* Client payment analytics — scorecards with risk scoring.
*/
public function client_payment_analytics(): array
{
$analytics = new ClientPaymentAnalyticsService($this->company);
return $analytics->generate(
$this->getClientPaymentSummary(null),
$this->getCompanyPaymentSummary()
);
}
/* Analytics */
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -0,0 +1,150 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Chart;
use App\Models\Company;
class ClientPaymentAnalyticsService
{
private const THRESHOLDS = [
'avg_days' => ['green' => 15, 'yellow' => 30],
'stddev' => ['green' => 5, 'yellow' => 15],
'late_rate' => ['green' => 0.10, 'yellow' => 0.25],
'data_points' => ['green' => 30, 'yellow' => 10],
];
private const RISK_WEIGHTS = [
'avg_days' => 0.35,
'late_rate' => 0.30,
'stddev' => 0.20,
'confidence' => 0.15,
];
public function __construct(private Company $company) {}
/**
* @param array<int, \stdClass> $clientSummaries
* @param array<int, \stdClass> $companyPaymentSummary
* @return array<string, mixed>
*/
public function generate(array $clientSummaries, array $companyPaymentSummary): array
{
$companySummary = ! empty($companyPaymentSummary) ? $companyPaymentSummary[0] : null;
$clients = [];
foreach ($clientSummaries as $client) {
$indicators = [
'avg_days' => $this->classifyMetric('avg_days', (float) ($client->avg_payment_days ?? 0)),
'stddev' => $this->classifyMetric('stddev', (float) ($client->stddev_payment_days ?? 0)),
'late_rate' => $this->classifyMetric('late_rate', (float) ($client->late_payment_ratio ?? 0)),
'data_points' => $this->classifyMetric('data_points', (int) ($client->total_invoices ?? 0)),
];
$riskScore = $this->calculateRiskScore($client);
$clients[] = [
'client_id' => (int) $client->client_id,
'currency_id' => (int) $client->currency_id,
'avg_payment_days' => round((float) ($client->avg_payment_days ?? 0), 2),
'stddev_payment_days' => round((float) ($client->stddev_payment_days ?? 0), 2),
'total_invoices' => (int) ($client->total_invoices ?? 0),
'late_invoices' => (int) ($client->late_invoices ?? 0),
'late_payment_ratio' => round((float) ($client->late_payment_ratio ?? 0), 4),
'risk_score' => $riskScore,
'risk_level' => $this->riskLevel($riskScore),
'indicators' => $indicators,
];
}
usort($clients, fn ($a, $b) => $b['risk_score'] <=> $a['risk_score']);
return [
'company_summary' => $companySummary ? [
'avg_payment_days' => round((float) ($companySummary->avg_payment_days ?? 0), 2),
'stddev_payment_days' => round((float) ($companySummary->stddev_payment_days ?? 0), 2),
'total_invoices' => (int) ($companySummary->total_invoices ?? 0),
'late_payment_ratio' => round((float) ($companySummary->late_payment_ratio ?? 0), 4),
] : [
'avg_payment_days' => 0,
'stddev_payment_days' => 0,
'total_invoices' => 0,
'late_payment_ratio' => 0,
],
'thresholds' => self::THRESHOLDS,
'clients' => $clients,
];
}
private function classifyMetric(string $metric, float $value): string
{
$thresholds = self::THRESHOLDS[$metric];
if ($metric === 'data_points') {
if ($value >= $thresholds['green']) {
return 'green';
}
if ($value >= $thresholds['yellow']) {
return 'yellow';
}
return 'red';
}
if ($value < $thresholds['green']) {
return 'green';
}
if ($value <= $thresholds['yellow']) {
return 'yellow';
}
return 'red';
}
private function calculateRiskScore(\stdClass $client): float
{
$avgDays = (float) ($client->avg_payment_days ?? 0);
$avgDaysScore = min($avgDays / 45, 1.0) * 100;
$lateRate = (float) ($client->late_payment_ratio ?? 0);
$lateRateScore = min($lateRate / 0.5, 1.0) * 100;
$stddev = (float) ($client->stddev_payment_days ?? 0);
$stddevScore = min($stddev / 25, 1.0) * 100;
$dataPoints = (int) ($client->total_invoices ?? 0);
$confidencePenalty = $dataPoints >= 30 ? 0 : (1 - ($dataPoints / 30)) * 100;
$score = ($avgDaysScore * self::RISK_WEIGHTS['avg_days'])
+ ($lateRateScore * self::RISK_WEIGHTS['late_rate'])
+ ($stddevScore * self::RISK_WEIGHTS['stddev'])
+ ($confidencePenalty * self::RISK_WEIGHTS['confidence']);
return round(min(max($score, 0), 100), 2);
}
private function riskLevel(float $score): string
{
if ($score < 33) {
return 'low';
}
if ($score <= 66) {
return 'medium';
}
return 'high';
}
}

View File

@@ -183,6 +183,8 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
Route::post('charts/calculated_fields', [ChartController::class, 'calculatedFields'])->name('chart.calculated_fields');
Route::post('charts/analytics_summary', [ChartController::class, 'analytics_summary'])->name('chart.analytics_summary');
Route::post('charts/analytics_totals', [ChartController::class, 'analytics_totals'])->name('chart.analytics_totals');
Route::post('charts/cashflow_forecast', [ChartController::class, 'cashflow_forecast'])->name('chart.cashflow_forecast');
Route::post('charts/client_payment_analytics', [ChartController::class, 'client_payment_analytics'])->name('chart.client_payment_analytics');
Route::post('claim_license', [LicenseController::class, 'index'])->name('license.index');
Route::post('check_license', [LicenseController::class, 'check'])->name('license.check');