Files
hub/app/Http/Controllers/Seller/Management/AdvancedAnalyticsController.php
kelly c091c3c168 feat: complete Management Suite implementation
- Add dashboard performance optimizations with caching
- Add InterBusiness settlement controller, service, and models
- Add Bank Accounts, Transfers, and Reconciliation
- Add Chart of Accounts management
- Add Accounting Periods with period lock enforcement
- Add Action Center for approvals
- Add Advanced Analytics dashboards
- Add Finance Roles and Permissions management
- Add Requisitions Approval workflow
- Add Forecasting and Cash Flow projections
- Add Inventory Valuation
- Add Operations dashboard
- Add Usage & Billing analytics
- Fix dashboard routing to use overview() method
- All naming conventions follow inter_business (not intercompany)
2025-12-07 10:56:26 -07:00

509 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Management;
use App\Http\Controllers\Controller;
use App\Models\Accounting\ApBill;
use App\Models\Accounting\ApPayment;
use App\Models\Accounting\ArInvoice;
use App\Models\Accounting\Expense;
use App\Models\Accounting\JournalEntry;
use App\Models\Accounting\JournalEntryLine;
use App\Models\Business;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
/**
* Advanced Analytics - Deep dive dashboards for financial analysis.
*
* Provides:
* - AR Analytics (aging, DSO, collection rate)
* - AP Analytics (payment timing, vendor analysis)
* - Cash Analytics (position, forecast, runway)
* - Expense Analytics (category breakdown, trends)
*/
class AdvancedAnalyticsController extends Controller
{
/**
* AR Analytics Dashboard.
*/
public function arAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Aging buckets
$aging = $this->calculateArAging($allBusinessIds);
// DSO (Days Sales Outstanding)
$dso = $this->calculateDSO($allBusinessIds, $startDate, $endDate);
// Collection rate (last 12 months)
$collectionRate = $this->calculateCollectionRate($allBusinessIds, $startDate, $endDate);
// Monthly AR trend
$monthlyTrend = $this->getArMonthlyTrend($allBusinessIds, 12);
// Top customers by AR balance
$topCustomers = ArInvoice::whereIn('business_id', $allBusinessIds)
->where('balance_due', '>', 0)
->selectRaw('customer_id, SUM(balance_due) as total_balance')
->groupBy('customer_id')
->with('customer')
->orderByDesc('total_balance')
->limit(10)
->get();
return view('seller.management.analytics.ar', compact(
'business',
'parentBusiness',
'aging',
'dso',
'collectionRate',
'monthlyTrend',
'topCustomers',
'startDate',
'endDate'
));
}
/**
* AP Analytics Dashboard.
*/
public function apAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Aging buckets
$aging = $this->calculateApAging($allBusinessIds);
// DPO (Days Payable Outstanding)
$dpo = $this->calculateDPO($allBusinessIds, $startDate, $endDate);
// Payment timing analysis
$paymentTiming = $this->analyzePaymentTiming($allBusinessIds, $startDate, $endDate);
// Top vendors by AP balance
$topVendors = ApBill::whereIn('business_id', $allBusinessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->selectRaw('vendor_id, SUM(total - paid_amount) as total_balance')
->groupBy('vendor_id')
->with('vendor')
->orderByDesc('total_balance')
->limit(10)
->get();
// Monthly AP trend
$monthlyTrend = $this->getApMonthlyTrend($allBusinessIds, 12);
return view('seller.management.analytics.ap', compact(
'business',
'parentBusiness',
'aging',
'dpo',
'paymentTiming',
'topVendors',
'monthlyTrend',
'startDate',
'endDate'
));
}
/**
* Cash Analytics Dashboard.
*/
public function cashAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
// Current cash position from GL
$cashPosition = $this->calculateCashPosition($parentBusiness);
// Cash flow by month (last 12 months)
$monthlyCashFlow = $this->getMonthlyCashFlow($parentBusiness, 12);
// Expected collections (upcoming AR)
$expectedCollections = ArInvoice::whereIn('business_id', $allBusinessIds)
->where('balance_due', '>', 0)
->where('due_date', '>=', now())
->where('due_date', '<=', now()->addDays(90))
->selectRaw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END as period,
SUM(balance_due) as total
")
->groupBy(DB::raw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END
"))
->get()
->pluck('total', 'period');
// Expected payments (upcoming AP)
$expectedPayments = ApBill::whereIn('business_id', $allBusinessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->where('due_date', '>=', now())
->where('due_date', '<=', now()->addDays(90))
->selectRaw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END as period,
SUM(total - paid_amount) as total
")
->groupBy(DB::raw("
CASE
WHEN due_date <= NOW() + INTERVAL '30 days' THEN '0-30 days'
WHEN due_date <= NOW() + INTERVAL '60 days' THEN '31-60 days'
ELSE '61-90 days'
END
"))
->get()
->pluck('total', 'period');
// Cash runway (months of runway based on avg monthly expenses)
$avgMonthlyExpenses = $this->getAverageMonthlyExpenses($allBusinessIds, 6);
$cashRunway = $avgMonthlyExpenses > 0 ? round($cashPosition / $avgMonthlyExpenses, 1) : null;
return view('seller.management.analytics.cash', compact(
'business',
'parentBusiness',
'cashPosition',
'monthlyCashFlow',
'expectedCollections',
'expectedPayments',
'avgMonthlyExpenses',
'cashRunway'
));
}
/**
* Expense Analytics Dashboard.
*/
public function expenseAnalytics(Request $request, Business $business): View
{
$parentBusiness = $business->parent ?? $business;
$divisionIds = Business::where('parent_id', $parentBusiness->id)->pluck('id')->toArray();
$allBusinessIds = array_merge([$parentBusiness->id], $divisionIds);
$endDate = Carbon::parse($request->get('end_date', now()));
$startDate = Carbon::parse($request->get('start_date', now()->subMonths(12)));
// Expenses by category
$byCategory = Expense::whereIn('business_id', $allBusinessIds)
->whereBetween('expense_date', [$startDate, $endDate])
->where('status', Expense::STATUS_APPROVED)
->selectRaw('gl_account_id, SUM(amount) as total')
->groupBy('gl_account_id')
->with('glAccount')
->orderByDesc('total')
->get();
// Expenses by division
$byDivision = Expense::whereIn('business_id', $allBusinessIds)
->whereBetween('expense_date', [$startDate, $endDate])
->where('status', Expense::STATUS_APPROVED)
->selectRaw('business_id, SUM(amount) as total')
->groupBy('business_id')
->with('business')
->orderByDesc('total')
->get();
// Monthly expense trend
$monthlyTrend = Expense::whereIn('business_id', $allBusinessIds)
->where('status', Expense::STATUS_APPROVED)
->where('expense_date', '>=', now()->subMonths(12))
->selectRaw("DATE_TRUNC('month', expense_date) as month, SUM(amount) as total")
->groupBy(DB::raw("DATE_TRUNC('month', expense_date)"))
->orderBy('month')
->get();
// Top expense categories (from GL)
$topCategories = JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness, $startDate, $endDate) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED)
->whereBetween('entry_date', [$startDate, $endDate]);
})
->whereHas('glAccount', function ($q) {
$q->where('account_type', 'expense');
})
->selectRaw('gl_account_id, SUM(debit_amount) as total_debit')
->groupBy('gl_account_id')
->with('glAccount')
->orderByDesc('total_debit')
->limit(15)
->get();
return view('seller.management.analytics.expense', compact(
'business',
'parentBusiness',
'byCategory',
'byDivision',
'monthlyTrend',
'topCategories',
'startDate',
'endDate'
));
}
// =========================================================================
// HELPER METHODS
// =========================================================================
protected function calculateArAging(array $businessIds): array
{
$buckets = [
'current' => 0,
'1_30' => 0,
'31_60' => 0,
'61_90' => 0,
'over_90' => 0,
];
$invoices = ArInvoice::whereIn('business_id', $businessIds)
->where('balance_due', '>', 0)
->get();
foreach ($invoices as $invoice) {
$daysOverdue = $invoice->due_date ? now()->diffInDays($invoice->due_date, false) : 0;
if ($daysOverdue <= 0) {
$buckets['current'] += $invoice->balance_due;
} elseif ($daysOverdue <= 30) {
$buckets['1_30'] += $invoice->balance_due;
} elseif ($daysOverdue <= 60) {
$buckets['31_60'] += $invoice->balance_due;
} elseif ($daysOverdue <= 90) {
$buckets['61_90'] += $invoice->balance_due;
} else {
$buckets['over_90'] += $invoice->balance_due;
}
}
$buckets['total'] = array_sum($buckets);
return $buckets;
}
protected function calculateApAging(array $businessIds): array
{
$buckets = [
'current' => 0,
'1_30' => 0,
'31_60' => 0,
'61_90' => 0,
'over_90' => 0,
];
$bills = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->get();
foreach ($bills as $bill) {
$balance = $bill->total - $bill->paid_amount;
$daysOverdue = $bill->due_date ? now()->diffInDays($bill->due_date, false) : 0;
if ($daysOverdue <= 0) {
$buckets['current'] += $balance;
} elseif ($daysOverdue <= 30) {
$buckets['1_30'] += $balance;
} elseif ($daysOverdue <= 60) {
$buckets['31_60'] += $balance;
} elseif ($daysOverdue <= 90) {
$buckets['61_90'] += $balance;
} else {
$buckets['over_90'] += $balance;
}
}
$buckets['total'] = array_sum($buckets);
return $buckets;
}
protected function calculateDSO(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalAR = ArInvoice::whereIn('business_id', $businessIds)
->where('balance_due', '>', 0)
->sum('balance_due');
$totalRevenue = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->sum('total');
$days = $startDate->diffInDays($endDate);
if ($totalRevenue > 0 && $days > 0) {
$avgDailyRevenue = $totalRevenue / $days;
return round($totalAR / $avgDailyRevenue, 1);
}
return 0;
}
protected function calculateDPO(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalAP = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_PENDING, ApBill::STATUS_APPROVED])
->selectRaw('SUM(total - paid_amount) as balance')
->value('balance') ?? 0;
$totalPurchases = ApBill::whereIn('business_id', $businessIds)
->whereBetween('bill_date', [$startDate, $endDate])
->sum('total');
$days = $startDate->diffInDays($endDate);
if ($totalPurchases > 0 && $days > 0) {
$avgDailyPurchases = $totalPurchases / $days;
return round($totalAP / $avgDailyPurchases, 1);
}
return 0;
}
protected function calculateCollectionRate(array $businessIds, Carbon $startDate, Carbon $endDate): float
{
$totalInvoiced = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->sum('total');
$totalCollected = ArInvoice::whereIn('business_id', $businessIds)
->whereBetween('invoice_date', [$startDate, $endDate])
->selectRaw('SUM(total - balance_due) as collected')
->value('collected') ?? 0;
if ($totalInvoiced > 0) {
return round(($totalCollected / $totalInvoiced) * 100, 1);
}
return 0;
}
protected function analyzePaymentTiming(array $businessIds, Carbon $startDate, Carbon $endDate): array
{
$payments = ApPayment::whereIn('business_id', $businessIds)
->whereBetween('payment_date', [$startDate, $endDate])
->with('bill')
->get();
$early = 0;
$onTime = 0;
$late = 0;
foreach ($payments as $payment) {
if (! $payment->bill?->due_date) {
continue;
}
$daysDiff = $payment->payment_date->diffInDays($payment->bill->due_date, false);
if ($daysDiff > 5) {
$early++;
} elseif ($daysDiff >= -5) {
$onTime++;
} else {
$late++;
}
}
$total = $early + $onTime + $late;
return [
'early' => $early,
'on_time' => $onTime,
'late' => $late,
'early_pct' => $total > 0 ? round(($early / $total) * 100, 1) : 0,
'on_time_pct' => $total > 0 ? round(($onTime / $total) * 100, 1) : 0,
'late_pct' => $total > 0 ? round(($late / $total) * 100, 1) : 0,
];
}
protected function getArMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
{
return ArInvoice::whereIn('business_id', $businessIds)
->where('invoice_date', '>=', now()->subMonths($months))
->selectRaw("DATE_TRUNC('month', invoice_date) as month, SUM(total) as invoiced, SUM(total - balance_due) as collected")
->groupBy(DB::raw("DATE_TRUNC('month', invoice_date)"))
->orderBy('month')
->get();
}
protected function getApMonthlyTrend(array $businessIds, int $months): \Illuminate\Support\Collection
{
return ApBill::whereIn('business_id', $businessIds)
->where('bill_date', '>=', now()->subMonths($months))
->selectRaw("DATE_TRUNC('month', bill_date) as month, SUM(total) as billed, SUM(paid_amount) as paid")
->groupBy(DB::raw("DATE_TRUNC('month', bill_date)"))
->orderBy('month')
->get();
}
protected function calculateCashPosition(Business $parentBusiness): float
{
// Sum of all cash accounts (1000-1099 range)
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED);
})
->whereHas('glAccount', function ($q) {
$q->where('account_number', '>=', '1000')
->where('account_number', '<', '1100');
})
->selectRaw('SUM(debit_amount - credit_amount) as balance')
->value('balance') ?? 0;
}
protected function getMonthlyCashFlow(Business $parentBusiness, int $months): \Illuminate\Support\Collection
{
return JournalEntryLine::whereHas('journalEntry', function ($q) use ($parentBusiness) {
$q->where('business_id', $parentBusiness->id)
->where('status', JournalEntry::STATUS_POSTED)
->where('entry_date', '>=', now()->subMonths($months));
})
->whereHas('glAccount', function ($q) {
$q->where('account_number', '>=', '1000')
->where('account_number', '<', '1100');
})
->join('journal_entries', 'journal_entry_lines.journal_entry_id', '=', 'journal_entries.id')
->selectRaw("DATE_TRUNC('month', journal_entries.entry_date) as month, SUM(debit_amount) as inflows, SUM(credit_amount) as outflows")
->groupBy(DB::raw("DATE_TRUNC('month', journal_entries.entry_date)"))
->orderBy('month')
->get();
}
protected function getAverageMonthlyExpenses(array $businessIds, int $months): float
{
$total = Expense::whereIn('business_id', $businessIds)
->where('status', Expense::STATUS_APPROVED)
->where('expense_date', '>=', now()->subMonths($months))
->sum('amount');
return $total / max($months, 1);
}
}