Project analytics

This commit is contained in:
David Bomba
2026-03-30 13:31:28 +11:00
parent 17ec04bda2
commit 6bc467cb12
4 changed files with 312 additions and 17 deletions

View File

@@ -148,6 +148,22 @@ class ChartController extends BaseController
return response()->json($data, 200);
}
public function project_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 = "project_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->project_analytics();
});
return response()->json($data, 200);
}
public function calculatedFields(ShowCalculatedFieldRequest $request)
{

View File

@@ -689,7 +689,7 @@ trait AnalyticsQueries
WHEN 1 THEN recurring_invoices.amount * 30.44
WHEN 2 THEN recurring_invoices.amount * 4.33
WHEN 3 THEN recurring_invoices.amount * 2.17
WHEN 4 THEN recurring_invoices.amount * 2.17
WHEN 4 THEN recurring_invoices.amount * 1.087
WHEN 5 THEN recurring_invoices.amount
WHEN 6 THEN recurring_invoices.amount / 2
WHEN 7 THEN recurring_invoices.amount / 3
@@ -706,7 +706,7 @@ trait AnalyticsQueries
WHEN 1 THEN recurring_invoices.amount * 30.44 * 12
WHEN 2 THEN recurring_invoices.amount * 4.33 * 12
WHEN 3 THEN recurring_invoices.amount * 2.17 * 12
WHEN 4 THEN recurring_invoices.amount * 2.17 * 12
WHEN 4 THEN recurring_invoices.amount * 1.087 * 12
WHEN 5 THEN recurring_invoices.amount * 12
WHEN 6 THEN recurring_invoices.amount * 6
WHEN 7 THEN recurring_invoices.amount * 4
@@ -752,7 +752,7 @@ trait AnalyticsQueries
WHEN 1 THEN recurring_invoices.amount * 30.44
WHEN 2 THEN recurring_invoices.amount * 4.33
WHEN 3 THEN recurring_invoices.amount * 2.17
WHEN 4 THEN recurring_invoices.amount * 2.17
WHEN 4 THEN recurring_invoices.amount * 1.087
WHEN 5 THEN recurring_invoices.amount
WHEN 6 THEN recurring_invoices.amount / 2
WHEN 7 THEN recurring_invoices.amount / 3
@@ -770,7 +770,7 @@ trait AnalyticsQueries
WHEN 1 THEN recurring_invoices.amount * 30.44 * 12
WHEN 2 THEN recurring_invoices.amount * 4.33 * 12
WHEN 3 THEN recurring_invoices.amount * 2.17 * 12
WHEN 4 THEN recurring_invoices.amount * 2.17 * 12
WHEN 4 THEN recurring_invoices.amount * 1.087 * 12
WHEN 5 THEN recurring_invoices.amount * 12
WHEN 6 THEN recurring_invoices.amount * 6
WHEN 7 THEN recurring_invoices.amount * 4
@@ -1104,12 +1104,12 @@ trait AnalyticsQueries
return DB::select("
SELECT
SUM(CASE WHEN invoices.due_date >= CURDATE() THEN invoices.balance ELSE 0 END) as current_amount,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE_SUB(CURDATE(), INTERVAL 1 DAY) THEN invoices.balance ELSE 0 END) as age_0_30,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 31 DAY) THEN invoices.balance ELSE 0 END) as age_31_60,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 90 DAY) AND DATE_SUB(CURDATE(), INTERVAL 61 DAY) THEN invoices.balance ELSE 0 END) as age_61_90,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 120 DAY) AND DATE_SUB(CURDATE(), INTERVAL 91 DAY) THEN invoices.balance ELSE 0 END) as age_91_120,
SUM(CASE WHEN invoices.due_date < DATE_SUB(CURDATE(), INTERVAL 120 DAY) THEN invoices.balance ELSE 0 END) as age_120_plus,
SUM(CASE WHEN invoices.due_date IS NULL OR invoices.due_date >= CURDATE() THEN invoices.balance ELSE 0 END) as current_amount,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE_SUB(CURDATE(), INTERVAL 1 DAY) THEN invoices.balance ELSE 0 END) as age_0_30,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 31 DAY) THEN invoices.balance ELSE 0 END) as age_31_60,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 90 DAY) AND DATE_SUB(CURDATE(), INTERVAL 61 DAY) THEN invoices.balance ELSE 0 END) as age_61_90,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 120 DAY) AND DATE_SUB(CURDATE(), INTERVAL 91 DAY) THEN invoices.balance ELSE 0 END) as age_91_120,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date < DATE_SUB(CURDATE(), INTERVAL 120 DAY) THEN invoices.balance ELSE 0 END) as age_120_plus,
IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(clients.settings, '$.currency_id')) AS SIGNED), :company_currency) AS currency_id
FROM invoices
JOIN clients
@@ -1140,12 +1140,12 @@ trait AnalyticsQueries
return DB::select("
SELECT
SUM(CASE WHEN invoices.due_date >= CURDATE() THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as current_amount,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE_SUB(CURDATE(), INTERVAL 1 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_0_30,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 31 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_31_60,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 90 DAY) AND DATE_SUB(CURDATE(), INTERVAL 61 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_61_90,
SUM(CASE WHEN invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 120 DAY) AND DATE_SUB(CURDATE(), INTERVAL 91 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_91_120,
SUM(CASE WHEN invoices.due_date < DATE_SUB(CURDATE(), INTERVAL 120 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_120_plus
SUM(CASE WHEN invoices.due_date IS NULL OR invoices.due_date >= CURDATE() THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as current_amount,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE_SUB(CURDATE(), INTERVAL 1 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_0_30,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 31 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_31_60,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 90 DAY) AND DATE_SUB(CURDATE(), INTERVAL 61 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_61_90,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date BETWEEN DATE_SUB(CURDATE(), INTERVAL 120 DAY) AND DATE_SUB(CURDATE(), INTERVAL 91 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_91_120,
SUM(CASE WHEN invoices.due_date IS NOT NULL AND invoices.due_date < DATE_SUB(CURDATE(), INTERVAL 120 DAY) THEN invoices.balance / COALESCE(NULLIF(invoices.exchange_rate, 0), 1) ELSE 0 END) as age_120_plus
FROM invoices
JOIN clients
ON clients.id = invoices.client_id
@@ -1159,4 +1159,267 @@ trait AnalyticsQueries
'company_id' => $this->company->id,
]);
}
// ─── Recurring Expense Analytics ────────────────────────────────
/**
* Recurring Expense Totals (per currency)
*
* Snapshot of current monthly recurring expenses based on active recurring expenses,
* with frequency normalization to monthly equivalent. Mirrors getMrrTotalQuery for outflows.
*
* @return array<int, \stdClass> Each row: monthly_total, annual_total, count, currency_id
*/
public function getRecurringExpenseTotalQuery(): array
{
$user_filter = $this->is_admin ? '' : 'AND recurring_expenses.user_id = ' . $this->user->id;
return DB::select("
SELECT
ROUND(SUM(
CASE recurring_expenses.frequency_id
WHEN 1 THEN recurring_expenses.amount * 30.44
WHEN 2 THEN recurring_expenses.amount * 4.33
WHEN 3 THEN recurring_expenses.amount * 2.17
WHEN 4 THEN recurring_expenses.amount * 1.087
WHEN 5 THEN recurring_expenses.amount
WHEN 6 THEN recurring_expenses.amount / 2
WHEN 7 THEN recurring_expenses.amount / 3
WHEN 8 THEN recurring_expenses.amount / 4
WHEN 9 THEN recurring_expenses.amount / 6
WHEN 10 THEN recurring_expenses.amount / 12
WHEN 11 THEN recurring_expenses.amount / 24
WHEN 12 THEN recurring_expenses.amount / 36
ELSE recurring_expenses.amount
END
), 2) as monthly_total,
ROUND(SUM(
CASE recurring_expenses.frequency_id
WHEN 1 THEN recurring_expenses.amount * 30.44 * 12
WHEN 2 THEN recurring_expenses.amount * 4.33 * 12
WHEN 3 THEN recurring_expenses.amount * 2.17 * 12
WHEN 4 THEN recurring_expenses.amount * 1.087 * 12
WHEN 5 THEN recurring_expenses.amount * 12
WHEN 6 THEN recurring_expenses.amount * 6
WHEN 7 THEN recurring_expenses.amount * 4
WHEN 8 THEN recurring_expenses.amount * 3
WHEN 9 THEN recurring_expenses.amount * 2
WHEN 10 THEN recurring_expenses.amount
WHEN 11 THEN recurring_expenses.amount / 2
WHEN 12 THEN recurring_expenses.amount / 3
ELSE recurring_expenses.amount * 12
END
), 2) as annual_total,
COUNT(*) as count,
IFNULL(recurring_expenses.currency_id, :company_currency) as currency_id
FROM recurring_expenses
WHERE recurring_expenses.company_id = :company_id
AND recurring_expenses.is_deleted = 0
AND recurring_expenses.status_id = 2
{$user_filter}
GROUP BY currency_id
", [
'company_currency' => $this->company->settings->currency_id,
'company_id' => $this->company->id,
]);
}
/**
* Recurring Expense Totals (aggregate)
*
* Company-wide monthly/annual recurring expense total in company currency.
*
* @return array<int, \stdClass> Single row: monthly_total, annual_total, count
*/
public function getAggregateRecurringExpenseTotalQuery(): array
{
$user_filter = $this->is_admin ? '' : 'AND recurring_expenses.user_id = ' . $this->user->id;
return DB::select("
SELECT
ROUND(SUM(
CASE recurring_expenses.frequency_id
WHEN 1 THEN recurring_expenses.amount * 30.44
WHEN 2 THEN recurring_expenses.amount * 4.33
WHEN 3 THEN recurring_expenses.amount * 2.17
WHEN 4 THEN recurring_expenses.amount * 1.087
WHEN 5 THEN recurring_expenses.amount
WHEN 6 THEN recurring_expenses.amount / 2
WHEN 7 THEN recurring_expenses.amount / 3
WHEN 8 THEN recurring_expenses.amount / 4
WHEN 9 THEN recurring_expenses.amount / 6
WHEN 10 THEN recurring_expenses.amount / 12
WHEN 11 THEN recurring_expenses.amount / 24
WHEN 12 THEN recurring_expenses.amount / 36
ELSE recurring_expenses.amount
END
/ COALESCE(NULLIF(recurring_expenses.exchange_rate, 0), 1)
), 2) as monthly_total,
ROUND(SUM(
CASE recurring_expenses.frequency_id
WHEN 1 THEN recurring_expenses.amount * 30.44 * 12
WHEN 2 THEN recurring_expenses.amount * 4.33 * 12
WHEN 3 THEN recurring_expenses.amount * 2.17 * 12
WHEN 4 THEN recurring_expenses.amount * 1.087 * 12
WHEN 5 THEN recurring_expenses.amount * 12
WHEN 6 THEN recurring_expenses.amount * 6
WHEN 7 THEN recurring_expenses.amount * 4
WHEN 8 THEN recurring_expenses.amount * 3
WHEN 9 THEN recurring_expenses.amount * 2
WHEN 10 THEN recurring_expenses.amount
WHEN 11 THEN recurring_expenses.amount / 2
WHEN 12 THEN recurring_expenses.amount / 3
ELSE recurring_expenses.amount * 12
END
/ COALESCE(NULLIF(recurring_expenses.exchange_rate, 0), 1)
), 2) as annual_total,
COUNT(*) as count
FROM recurring_expenses
WHERE recurring_expenses.company_id = :company_id
AND recurring_expenses.is_deleted = 0
AND recurring_expenses.status_id = 2
{$user_filter}
", [
'company_id' => $this->company->id,
]);
}
// ─── Project Analytics ──────────────────────────────────────────
/**
* Project Budget Summary
*
* Returns per-project budget utilization: budgeted hours, hours logged
* (from projects.current_hours which is pre-computed from task time_log),
* task counts by invoiced status, and budget utilization percentage.
*
* Note: Task hours are stored as JSON in tasks.time_log and computed via PHP.
* projects.current_hours is the pre-computed total maintained by TaskRepository.
*
* @return array<int, \stdClass>
*/
public function getProjectBudgetSummary(): array
{
$user_filter = $this->is_admin ? '' : 'AND projects.user_id = ' . $this->user->id;
$user_filter_tasks = $this->is_admin ? '' : 'AND tasks.user_id = ' . $this->user->id;
return DB::select("
SELECT
projects.id as project_id,
projects.name as project_name,
projects.client_id,
projects.budgeted_hours,
projects.current_hours,
projects.task_rate,
projects.due_date,
COALESCE(task_stats.total_tasks, 0) as total_tasks,
COALESCE(task_stats.invoiced_tasks, 0) as invoiced_tasks,
COALESCE(task_stats.uninvoiced_tasks, 0) as uninvoiced_tasks,
COALESCE(task_stats.running_tasks, 0) as running_tasks,
ROUND(
CASE WHEN projects.budgeted_hours > 0
THEN projects.current_hours / projects.budgeted_hours
ELSE 0
END, 4
) as utilization,
ROUND(
CASE WHEN projects.budgeted_hours > 0
THEN GREATEST(projects.budgeted_hours - projects.current_hours, 0)
ELSE 0
END, 2
) as hours_remaining,
IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(clients.settings, '$.currency_id')) AS SIGNED), :company_currency) AS currency_id
FROM projects
JOIN clients
ON clients.id = projects.client_id
AND clients.is_deleted = 0
LEFT JOIN (
SELECT
tasks.project_id,
COUNT(*) as total_tasks,
SUM(CASE WHEN tasks.invoice_id IS NOT NULL THEN 1 ELSE 0 END) as invoiced_tasks,
SUM(CASE WHEN tasks.invoice_id IS NULL THEN 1 ELSE 0 END) as uninvoiced_tasks,
SUM(CASE WHEN tasks.is_running = 1 THEN 1 ELSE 0 END) as running_tasks
FROM tasks
WHERE tasks.is_deleted = 0
{$user_filter_tasks}
GROUP BY tasks.project_id
) as task_stats
ON task_stats.project_id = projects.id
WHERE projects.company_id = :company_id
AND projects.is_deleted = 0
{$user_filter}
ORDER BY utilization DESC
", [
'company_currency' => $this->company->settings->currency_id,
'company_id' => $this->company->id,
]);
}
/**
* Project Profitability
*
* Returns per-project revenue vs cost: invoiced amount from tasks,
* expenses charged to the project, and net margin.
*
* @return array<int, \stdClass>
*/
public function getProjectProfitability(): array
{
$user_filter = $this->is_admin ? '' : 'AND projects.user_id = ' . $this->user->id;
$user_filter_inv = $this->is_admin ? '' : 'AND invoices.user_id = ' . $this->user->id;
$user_filter_exp = $this->is_admin ? '' : 'AND expenses.user_id = ' . $this->user->id;
return DB::select("
SELECT
projects.id as project_id,
projects.name as project_name,
projects.client_id,
COALESCE(inv_totals.invoiced_amount, 0) as invoiced_amount,
COALESCE(exp_totals.expense_amount, 0) as expense_amount,
ROUND(COALESCE(inv_totals.invoiced_amount, 0) - COALESCE(exp_totals.expense_amount, 0), 2) as net_margin,
ROUND(
CASE WHEN COALESCE(inv_totals.invoiced_amount, 0) > 0
THEN (COALESCE(inv_totals.invoiced_amount, 0) - COALESCE(exp_totals.expense_amount, 0))
/ inv_totals.invoiced_amount
ELSE 0
END, 4
) as margin_ratio,
IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(clients.settings, '$.currency_id')) AS SIGNED), :company_currency) AS currency_id
FROM projects
JOIN clients
ON clients.id = projects.client_id
AND clients.is_deleted = 0
LEFT JOIN (
SELECT
invoices.project_id,
SUM(invoices.amount) as invoiced_amount
FROM invoices
WHERE invoices.is_deleted = 0
AND invoices.status_id IN (2, 3, 4)
AND invoices.project_id IS NOT NULL
{$user_filter_inv}
GROUP BY invoices.project_id
) as inv_totals
ON inv_totals.project_id = projects.id
LEFT JOIN (
SELECT
expenses.project_id,
SUM(expenses.amount) as expense_amount
FROM expenses
WHERE expenses.is_deleted = 0
AND expenses.project_id IS NOT NULL
{$user_filter_exp}
GROUP BY expenses.project_id
) as exp_totals
ON exp_totals.project_id = projects.id
WHERE projects.company_id = :company_id
AND projects.is_deleted = 0
{$user_filter}
ORDER BY net_margin DESC
", [
'company_currency' => $this->company->settings->currency_id,
'company_id' => $this->company->id,
]);
}
}

View File

@@ -252,22 +252,26 @@ class ChartService
$mrr_totals = $this->getMrrTotalQuery();
$aging_totals = $this->getAgingBucketTotals();
$client_analytics = $this->getClientPaymentSummary();
$recurring_expense_totals = $this->getRecurringExpenseTotalQuery();
foreach ($data['currencies'] as $key => $value) {
$mrr_set = array_search($key, array_column($mrr_totals, 'currency_id'));
$aging_set = array_search($key, array_column($aging_totals, 'currency_id'));
$re_set = array_search($key, array_column($recurring_expense_totals, 'currency_id'));
$data[$key]['mrr'] = $mrr_set !== false ? $mrr_totals[$mrr_set] : new \stdClass();
$data[$key]['aging'] = $aging_set !== false ? $aging_totals[$aging_set] : new \stdClass();
$data[$key]['recurring_expenses'] = $re_set !== false ? $recurring_expense_totals[$re_set] : new \stdClass();
}
$aggregate_mrr = $this->getAggregateMrrTotalQuery();
$aggregate_aging = $this->getAggregateAgingBucketTotals();
$aggregate_recurring_expenses = $this->getAggregateRecurringExpenseTotalQuery();
$company_payment = $this->getCompanyPaymentSummary();
$data[999]['mrr'] = ! empty($aggregate_mrr) ? reset($aggregate_mrr) : new \stdClass();
$data[999]['aging'] = ! empty($aggregate_aging) ? reset($aggregate_aging) : new \stdClass();
$data[999]['recurring_expenses'] = ! empty($aggregate_recurring_expenses) ? reset($aggregate_recurring_expenses) : new \stdClass();
$data[999]['payment_analytics'] = ! empty($company_payment) ? reset($company_payment) : new \stdClass();
return $data;
@@ -303,6 +307,17 @@ class ChartService
);
}
/**
* Project analytics — budget utilization and profitability.
*/
public function project_analytics(): array
{
return [
'budget_summary' => $this->getProjectBudgetSummary(),
'profitability' => $this->getProjectProfitability(),
];
}
/* Analytics */
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -185,6 +185,7 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
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('charts/project_analytics', [ChartController::class, 'project_analytics'])->name('chart.project_analytics');
Route::post('claim_license', [LicenseController::class, 'index'])->name('license.index');
Route::post('check_license', [LicenseController::class, 'check'])->name('license.check');