mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-04-18 12:10:50 +00:00
Project analytics
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user