- 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)
509 lines
18 KiB
PHP
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);
|
|
}
|
|
}
|