ultimatepos/Modules/ManagementTools/Services/ExecutiveCockpitService.php

2422 lines
100 KiB
PHP

<?php
namespace Modules\ManagementTools\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Modules\ManagementTools\Entities\MtExecutiveNote;
use Modules\ManagementTools\Entities\MtExecutiveTarget;
use Modules\ManagementTools\Services\ExecutiveCockpit\CockpitMetricsCache;
use Modules\ManagementTools\Services\ExecutiveCockpit\HoldingComparisonService;
use Modules\ManagementTools\Services\ExecutiveCockpit\ModuleStatusResolver;
use Modules\ManagementTools\Services\ExecutiveCockpit\TransactionMetricsQuery;
class ExecutiveCockpitService
{
protected ?array $requestKpiCache = null;
public function __construct(
protected ModuleStatusResolver $moduleStatusResolver,
protected CockpitMetricsCache $metricsCache,
protected HoldingComparisonService $holdingComparisonService,
protected TransactionMetricsQuery $transactionMetrics
) {}
public function build(int $businessId, Carbon $startDate, Carbon $endDate, ?string $onlyDrillTab = null): array
{
$this->requestKpiCache = [];
$kpis = $this->cachedKpis($businessId, $startDate, $endDate);
$alerts = $this->alerts($businessId);
$targets = $this->targets($businessId);
$moduleCockpit = $this->moduleCockpit($businessId);
$systemHealth = $this->systemHealth($alerts);
[$prevStartDate, $prevEndDate] = $this->previousPeriodRange($startDate, $endDate);
$prevKpis = $this->cachedKpis($businessId, $prevStartDate, $prevEndDate);
$drilldown = $this->drilldown($businessId, $startDate, $endDate, $kpis, $prevKpis, $prevStartDate, $prevEndDate, $onlyDrillTab);
$payload = [
'range' => ['start' => $startDate->copy(), 'end' => $endDate->copy()],
'previous_range' => ['start' => $prevStartDate->copy(), 'end' => $prevEndDate->copy()],
'kpis' => $kpis,
'alerts' => $alerts,
'focus_items' => $this->focusItems($businessId),
'trends' => $this->trends($businessId, $startDate, $endDate),
'risk_score' => $this->riskScore($kpis, $alerts),
'action_center' => $this->actionCenter($businessId, $kpis, $alerts),
'targets' => $targets,
'variance' => $this->variance($kpis, $targets),
'notes' => $this->latestNotes($businessId),
'module_cockpit' => $moduleCockpit,
'system_health' => $systemHealth,
'summary' => [
'total_modules' => count($moduleCockpit),
'enabled_modules' => count(array_filter($moduleCockpit, fn ($m) => $m['enabled'])),
'critical_alerts' => count($alerts),
'system_score' => $systemHealth['score'],
],
'drilldown' => $this->filterDrilldownByEnabledModules($drilldown),
'drilldown_labels' => $this->drilldownTabLabels(),
'trend_month_labels' => $this->monthlyTrendLabels($endDate),
'kpi_comparisons' => $this->kpiPeriodComparisons($kpis, $prevKpis),
'holding_comparison' => $this->holdingEntityComparison($businessId, $startDate, $endDate),
];
$payload['executive_summary'] = $this->executiveSummary($payload);
return $payload;
}
protected function executiveSummary(array $payload): array
{
$comparisons = $payload['kpi_comparisons'] ?? [];
$alerts = $payload['alerts'] ?? [];
$variance = $payload['variance'] ?? [];
$riskScore = (int) ($payload['risk_score'] ?? 0);
$systemHealth = $payload['system_health'] ?? [];
$range = $payload['range'] ?? [];
$prevRange = $payload['previous_range'] ?? [];
$highlights = [];
$concerns = [];
$watchItems = [];
foreach (['sales_this_month', 'gross_margin_estimate', 'workforce_productivity'] as $metric) {
if (! isset($comparisons[$metric])) {
continue;
}
$comparison = $comparisons[$metric];
if (($comparison['direction'] ?? 'flat') === 'flat') {
continue;
}
$label = __('managementtools::lang.'.$metric);
$pct = abs((float) ($comparison['delta_pct'] ?? 0));
if (! empty($comparison['favorable'])) {
$highlights[] = __('managementtools::lang.summary_metric_improved', ['metric' => $label, 'pct' => $pct]);
} else {
$concerns[] = __('managementtools::lang.summary_metric_declined', ['metric' => $label, 'pct' => $pct]);
}
}
if ($riskScore >= 75) {
$highlights[] = __('managementtools::lang.summary_risk_stable', ['score' => $riskScore]);
} elseif ($riskScore < 55) {
$concerns[] = __('managementtools::lang.summary_risk_critical', ['score' => $riskScore]);
} else {
$watchItems[] = __('managementtools::lang.summary_risk_watch', ['score' => $riskScore]);
}
if (count($alerts) > 0) {
$concerns[] = __('managementtools::lang.summary_alerts_count', ['count' => count($alerts)]);
} else {
$highlights[] = __('managementtools::lang.summary_no_critical_alerts');
}
foreach (['sales_this_month', 'gross_margin_estimate', 'workforce_productivity'] as $metric) {
if (! isset($variance[$metric]) || ($variance[$metric]['delta'] ?? 0) >= 0) {
continue;
}
$watchItems[] = __('managementtools::lang.summary_below_target', [
'metric' => __('managementtools::lang.'.$metric),
'pct' => abs((float) ($variance[$metric]['delta_pct'] ?? 0)),
]);
}
$periodStart = $range['start'] ?? Carbon::today();
$periodEnd = $range['end'] ?? Carbon::today();
$prevStart = $prevRange['start'] ?? null;
$prevEnd = $prevRange['end'] ?? null;
return [
'headline' => $this->executiveHeadline($highlights, $concerns, $riskScore),
'opening' => __('managementtools::lang.summary_opening', [
'start' => $periodStart->format('Y-m-d'),
'end' => $periodEnd->format('Y-m-d'),
'health' => $systemHealth['score'] ?? 0,
]),
'highlights' => $highlights,
'concerns' => $concerns,
'watch_items' => $watchItems,
'period_label' => $periodStart->format('Y-m-d').' — '.$periodEnd->format('Y-m-d'),
'previous_period_label' => ($prevStart && $prevEnd)
? $prevStart->format('Y-m-d').' — '.$prevEnd->format('Y-m-d')
: '',
];
}
protected function executiveHeadline(array $highlights, array $concerns, int $riskScore): string
{
if (count($concerns) >= 2 || $riskScore < 55) {
return __('managementtools::lang.summary_headline_action_required');
}
if (count($highlights) >= 2 && count($concerns) === 0) {
return __('managementtools::lang.summary_headline_strong_performance');
}
return __('managementtools::lang.summary_headline_mixed');
}
protected function previousPeriodRange(Carbon $startDate, Carbon $endDate): array
{
$days = $startDate->diffInDays($endDate) + 1;
$prevEnd = $startDate->copy()->subDay()->endOfDay();
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
return [$prevStart, $prevEnd];
}
protected function periodComparison(float $current, float $previous, bool $lowerIsBetter = false): array
{
$delta = $current - $previous;
$deltaPct = $previous != 0
? round(($delta / abs($previous)) * 100, 1)
: ($current != 0 ? 100.0 : 0.0);
$direction = abs($delta) < 0.0001 ? 'flat' : ($delta > 0 ? 'up' : 'down');
return [
'previous' => $previous,
'delta' => $delta,
'delta_pct' => $deltaPct,
'direction' => $direction,
'favorable' => $lowerIsBetter ? ($delta <= 0) : ($delta >= 0),
];
}
protected function enrichKpiWithComparison(array $kpi, ?array $comparison): array
{
if ($comparison !== null) {
$kpi['comparison'] = $comparison;
if (! empty($kpi['currency'])) {
$kpi['comparison']['currency'] = true;
}
}
return $kpi;
}
protected function kpiPeriodComparisons(array $kpis, array $prevKpis): array
{
$map = [
'sales_this_month' => false,
'gross_margin_estimate' => false,
'purchase_this_month' => true,
'expense_this_month' => true,
'workforce_productivity' => false,
'mt_reports_submitted' => false,
'open_work_orders' => true,
'open_tickets' => true,
'active_projects' => true,
];
$comparisons = [];
foreach ($map as $metric => $lowerIsBetter) {
$current = (float) ($kpis[$metric] ?? 0);
$previous = (float) ($prevKpis[$metric] ?? 0);
$comparisons[$metric] = $this->periodComparison($current, $previous, $lowerIsBetter);
}
return $comparisons;
}
protected function drilldownPeriodMetrics(int $businessId, Carbon $startDate, Carbon $endDate): array
{
$metrics = [
'sales_count' => 0,
'average_sale' => 0,
'projects_completed_in_period' => 0,
'crm_new_leads' => 0,
'production_count' => 0,
'production_value' => 0,
'journal_entry_count' => 0,
'ie_mrp_runs' => 0,
];
if (Schema::hasTable('transactions')) {
$salesCount = (int) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'sell')
->where('status', 'final')
->whereBetween(DB::raw('date(transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
$salesTotal = (float) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'sell')
->where('status', 'final')
->whereBetween(DB::raw('date(transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->sum('final_total');
$metrics['sales_count'] = $salesCount;
$metrics['average_sale'] = $salesCount > 0 ? round($salesTotal / $salesCount, 2) : 0;
}
if (Schema::hasTable('pp_projects')) {
$metrics['projects_completed_in_period'] = (int) DB::table('pp_projects')
->where('business_id', $businessId)
->where('status', 'completed')
->whereBetween(DB::raw('date(updated_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
}
if (Schema::hasTable('contacts')) {
$metrics['crm_new_leads'] = (int) DB::table('contacts')
->where('business_id', $businessId)
->where('type', 'lead')
->whereBetween(DB::raw('date(created_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
}
if (Schema::hasTable('transactions')) {
$metrics['production_count'] = (int) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'production_purchase')
->whereBetween(DB::raw('date(transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
$metrics['production_value'] = (float) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'production_purchase')
->whereBetween(DB::raw('date(transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->sum('final_total');
}
if (Schema::hasTable('accounting_accounts_transactions') && Schema::hasTable('accounting_accounts')) {
$dateColumn = Schema::hasColumn('accounting_accounts_transactions', 'operation_date') ? 'operation_date' : 'created_at';
$metrics['journal_entry_count'] = (int) DB::table('accounting_accounts_transactions as aat')
->join('accounting_accounts as aa', 'aa.id', '=', 'aat.accounting_account_id')
->where('aa.business_id', $businessId)
->whereBetween(DB::raw('date(aat.'.$dateColumn.')'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
}
if (Schema::hasTable('ie_mrp_runs')) {
$metrics['ie_mrp_runs'] = (int) DB::table('ie_mrp_runs')
->where('business_id', $businessId)
->whereBetween(DB::raw('date(created_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
}
return $metrics;
}
public function buildForViewer(int $businessId, Carbon $startDate, Carbon $endDate, $user = null, bool $lazyDrillTabs = false, ?string $activeTab = null): array
{
$onlyTab = ($lazyDrillTabs && $activeTab) ? $activeTab : null;
$data = $this->build($businessId, $startDate, $endDate, $onlyTab);
if ($user) {
$data['drilldown'] = $this->filterDrilldownForUser($data['drilldown'], $user);
$data['focus_items'] = $this->filterFocusItemsForUser($data['focus_items'], $user);
}
return $data;
}
public function buildDrilldownTabForViewer(int $businessId, Carbon $startDate, Carbon $endDate, string $tabKey, $user = null): ?array
{
$labels = $this->drilldownTabLabels();
if (! array_key_exists($tabKey, $labels)) {
return null;
}
[$prevStartDate, $prevEndDate] = $this->previousPeriodRange($startDate, $endDate);
$kpis = $this->cachedKpis($businessId, $startDate, $endDate);
$prevKpis = $this->cachedKpis($businessId, $prevStartDate, $prevEndDate);
$sections = $this->filterDrilldownByEnabledModules(
$this->drilldown($businessId, $startDate, $endDate, $kpis, $prevKpis, $prevStartDate, $prevEndDate, $tabKey)
);
if (! isset($sections[$tabKey])) {
return null;
}
$section = $sections[$tabKey];
if ($user) {
$section['top_items'] = array_values(array_filter(array_map(function ($item) use ($user) {
return $this->filterPermissionedItem($item, $user);
}, $section['top_items'] ?? [])));
}
return [
'tab_key' => $tabKey,
'section' => $section,
'trend_month_labels' => $this->monthlyTrendLabels($endDate),
'drilldown_labels' => $labels,
];
}
protected function drilldownTabLabels(): array
{
$labels = [
'finance' => __('managementtools::lang.drilldown_finance'),
'sales' => __('managementtools::lang.drilldown_sales'),
'maintenance' => __('managementtools::lang.drilldown_maintenance'),
'projects' => __('managementtools::lang.drilldown_projects'),
'workforce' => __('managementtools::lang.drilldown_workforce'),
];
if ($this->isModuleEnabled('Crm')) {
$labels['crm'] = __('managementtools::lang.drilldown_crm');
}
if ($this->isModuleEnabled('Accounting')) {
$labels['accounting'] = __('managementtools::lang.drilldown_accounting');
}
if ($this->isModuleEnabled('Manufacturing')) {
$labels['manufacturing'] = __('managementtools::lang.drilldown_manufacturing');
}
if ($this->isModuleEnabled('IndustrialEngineering')) {
$labels['ie'] = __('managementtools::lang.drilldown_ie');
}
if ($this->isModuleEnabled('QualityManagement')) {
$labels['quality'] = __('managementtools::lang.drilldown_quality');
}
if ($this->isModuleEnabled('ShopFloor')) {
$labels['shopfloor'] = __('managementtools::lang.drilldown_shopfloor');
}
if ($this->isModuleEnabled('SupplyChain')) {
$labels['supplychain'] = __('managementtools::lang.drilldown_supplychain');
}
if ($this->isModuleEnabled('AdvancedPlanning')) {
$labels['planning'] = __('managementtools::lang.drilldown_planning');
}
return $labels;
}
protected function moduleStatuses(): array
{
return $this->moduleStatusResolver->all();
}
protected function isModuleEnabled(string $moduleKey): bool
{
return $this->moduleStatusResolver->isEnabled($moduleKey);
}
protected function cachedKpis(int $businessId, Carbon $startDate, Carbon $endDate): array
{
$key = $startDate->toDateString().':'.$endDate->toDateString();
if (is_array($this->requestKpiCache) && array_key_exists($key, $this->requestKpiCache)) {
return $this->requestKpiCache[$key];
}
$kpis = $this->metricsCache->rememberKpis(
$businessId,
$startDate,
$endDate,
fn () => $this->kpis($businessId, $startDate, $endDate)
);
if (is_array($this->requestKpiCache)) {
$this->requestKpiCache[$key] = $kpis;
}
return $kpis;
}
protected function filterDrilldownByEnabledModules(array $drilldown): array
{
$moduleMap = [
'crm' => 'Crm',
'accounting' => 'Accounting',
'manufacturing' => 'Manufacturing',
'ie' => 'IndustrialEngineering',
'quality' => 'QualityManagement',
'shopfloor' => 'ShopFloor',
'supplychain' => 'SupplyChain',
'planning' => 'AdvancedPlanning',
];
foreach ($moduleMap as $tab => $moduleKey) {
if (! $this->isModuleEnabled($moduleKey)) {
unset($drilldown[$tab]);
}
}
return $drilldown;
}
protected function monthlyTrendLabels(Carbon $endDate): array
{
$labels = [];
for ($i = 5; $i >= 0; $i--) {
$labels[] = $endDate->copy()->startOfMonth()->subMonths($i)->format('Y/m');
}
return $labels;
}
public function filterDrilldownForUser(array $drilldown, $user): array
{
foreach ($drilldown as $key => $section) {
$drilldown[$key]['top_items'] = array_values(array_filter(array_map(function ($item) use ($user) {
return $this->filterPermissionedItem($item, $user);
}, $section['top_items'] ?? [])));
}
return $drilldown;
}
public function filterFocusItemsForUser(array $items, $user): array
{
return array_values(array_filter(array_map(function ($item) use ($user) {
return $this->filterPermissionedItem($item, $user);
}, $items)));
}
protected function filterPermissionedItem(array $item, $user): ?array
{
if (! $this->userCanAny($user, $item['permissions'] ?? [])) {
return null;
}
$item['actions'] = array_values(array_filter($item['actions'] ?? [], function ($action) use ($user) {
return $this->userCanAny($user, $action['permissions'] ?? []);
}));
return $item;
}
protected function userCanAny($user, array $permissions): bool
{
if (empty($permissions)) {
return true;
}
if (! $user) {
return false;
}
foreach ($permissions as $permission) {
if ($user->can($permission)) {
return true;
}
}
return false;
}
protected function contactViewPermissions(): array
{
return ['customer.view', 'customer.view_own', 'supplier.view', 'supplier.view_own'];
}
protected function contactActions(int $contactId): array
{
$contactUrl = $contactId > 0 ? url('/contacts/'.$contactId) : url('/contacts/customers');
$paymentsUrl = $contactId > 0 ? url('/contacts/payments/'.$contactId) : url('/contacts/customers');
$permissions = $this->contactViewPermissions();
return [
'url' => $contactUrl,
'permissions' => $permissions,
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => $contactUrl,
'permissions' => $permissions,
],
[
'label' => __('managementtools::lang.open_payments'),
'url' => $paymentsUrl,
'permissions' => $permissions,
],
],
];
}
protected function kpis(int $businessId, Carbon $startDate, Carbon $endDate): array
{
$sales = 0;
$purchase = 0;
$expense = 0;
$customerDue = 0;
$supplierDue = 0;
if (Schema::hasTable('transactions')) {
$sales = $this->transactionMetrics->sumFinalSells($businessId, $startDate, $endDate);
$purchase = (float) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'purchase')
->where('transaction_date', '>=', $startDate->copy()->startOfDay())
->where('transaction_date', '<=', $endDate->copy()->endOfDay())
->sum('final_total');
$expense = (float) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'expense')
->where('transaction_date', '>=', $startDate->copy()->startOfDay())
->where('transaction_date', '<=', $endDate->copy()->endOfDay())
->sum('final_total');
}
if (Schema::hasTable('contacts')) {
$customerDue = (float) DB::table('contacts')
->where('business_id', $businessId)
->whereIn('type', ['customer', 'both'])
->where('balance', '>', 0)
->sum('balance');
$supplierDue = (float) DB::table('contacts')
->where('business_id', $businessId)
->whereIn('type', ['supplier', 'both'])
->where('balance', '>', 0)
->sum('balance');
}
$openTickets = 0;
if (Schema::hasTable('mt_user_tickets')) {
$openTickets = (int) DB::table('mt_user_tickets')
->where('business_id', $businessId)
->whereIn('status', [0, 1])
->count();
}
$openWorkOrders = 0;
if (Schema::hasTable('maintenance_work_orders')) {
$openWorkOrders = (int) DB::table('maintenance_work_orders')
->where('business_id', $businessId)
->whereIn('status', ['draft', 'open', 'in_progress', 'on_hold'])
->count();
}
$activeProjects = 0;
if (Schema::hasTable('pp_projects')) {
$activeProjects = (int) DB::table('pp_projects')
->where('business_id', $businessId)
->whereIn('status', ['not_started', 'in_progress', 'on_hold'])
->where('is_archived', false)
->count();
}
$overdueInvoices = 0;
if (Schema::hasTable('transactions') && Schema::hasColumn('transactions', 'due_date')) {
$overdueInvoices = (int) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'sell')
->where('status', 'final')
->where('payment_status', '!=', 'paid')
->whereNotNull('due_date')
->whereDate('due_date', '<', Carbon::today()->toDateString())
->count();
}
return [
'sales_this_month' => $sales,
'purchase_this_month' => $purchase,
'expense_this_month' => $expense,
'gross_margin_estimate' => $sales - $purchase - $expense,
'customer_due' => $customerDue,
'supplier_due' => $supplierDue,
'open_tickets' => $openTickets,
'open_work_orders' => $openWorkOrders,
'active_projects' => $activeProjects,
'overdue_invoices' => $overdueInvoices,
'mt_reports_submitted' => $this->submittedReportsCount($businessId, $startDate, $endDate),
'workforce_productivity' => $this->workforceProductivity($businessId, $startDate, $endDate),
'open_ncrs' => $this->openNcrCount($businessId),
'avg_oee_30d' => $this->avgOeeLast30Days($businessId),
'open_iot_alerts' => $this->openIotAlertCount($businessId),
];
}
protected function openNcrCount(int $businessId): int
{
if (! Schema::hasTable('qms_ncr_reports')) {
return 0;
}
return (int) DB::table('qms_ncr_reports')
->where('business_id', $businessId)
->whereIn('status', ['open', 'investigating', 'containment'])
->count();
}
protected function avgOeeLast30Days(int $businessId): float
{
if (! Schema::hasTable('sf_oee_snapshots')) {
return 0;
}
return round((float) DB::table('sf_oee_snapshots')
->where('business_id', $businessId)
->where('snapshot_date', '>=', now()->subDays(30)->toDateString())
->avg('oee_pct'), 2);
}
protected function openIotAlertCount(int $businessId): int
{
if (! Schema::hasTable('mt_iot_alerts')) {
return 0;
}
return (int) DB::table('mt_iot_alerts')
->where('business_id', $businessId)
->where('status', 'open')
->count();
}
protected function submittedReportsCount(int $businessId, Carbon $startDate, Carbon $endDate): int
{
if (! Schema::hasTable('mt_daily_reports')) {
return 0;
}
return (int) DB::table('mt_daily_reports')
->where('business_id', $businessId)
->whereBetween(DB::raw('date(report_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
}
protected function workforceProductivity(int $businessId, Carbon $startDate, Carbon $endDate): float
{
if (! Schema::hasTable('mt_user_tasks')) {
return 0;
}
$total = (int) DB::table('mt_user_tasks')
->where('business_id', $businessId)
->whereBetween(DB::raw('date(created_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
if ($total === 0) {
return 0;
}
$completed = (int) DB::table('mt_user_tasks')
->where('business_id', $businessId)
->whereBetween(DB::raw('date(created_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->where('task_status', 1)
->count();
return round(($completed / $total) * 100, 2);
}
protected function alerts(int $businessId): array
{
$items = [];
if (Schema::hasTable('transactions') && Schema::hasColumn('transactions', 'due_date')) {
$overdueInvoices = (int) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'sell')
->where('status', 'final')
->where('payment_status', '!=', 'paid')
->whereNotNull('due_date')
->whereDate('due_date', '<', Carbon::today()->toDateString())
->count();
if ($overdueInvoices > 0) {
$items[] = [
'level' => 'danger',
'title' => __('managementtools::lang.alert_overdue_invoices'),
'value' => $overdueInvoices,
'url' => url('/reports/sell-payment-report'),
];
}
}
if (Schema::hasTable('maintenance_preventive_schedules')) {
$upcomingPm = (int) DB::table('maintenance_preventive_schedules')
->where('business_id', $businessId)
->where('is_active', true)
->whereNotNull('next_due_at')
->whereDate('next_due_at', '<=', Carbon::today()->addDays(7)->toDateString())
->count();
if ($upcomingPm > 0) {
$items[] = [
'level' => 'warning',
'title' => __('managementtools::lang.alert_pm_due'),
'value' => $upcomingPm,
'url' => url('/maintenance/dashboard'),
];
}
}
if (Schema::hasTable('mt_user_tickets')) {
$urgentTickets = (int) DB::table('mt_user_tickets')
->where('business_id', $businessId)
->where('priority', 'urgent')
->whereIn('status', [0, 1])
->count();
if ($urgentTickets > 0) {
$items[] = [
'level' => 'danger',
'title' => __('managementtools::lang.alert_urgent_tickets'),
'value' => $urgentTickets,
'url' => url('/management-tools/tickets'),
];
}
}
if (Schema::hasTable('accounting_installment_schedule') && Schema::hasTable('accounting_installment_plans')) {
$overdueInstallments = (int) DB::table('accounting_installment_schedule as s')
->join('accounting_installment_plans as p', 'p.id', '=', 's.installment_plan_id')
->where('p.business_id', $businessId)
->where('s.status', 'overdue')
->count();
if ($overdueInstallments > 0) {
$overdueAmount = (float) DB::table('accounting_installment_schedule as s')
->join('accounting_installment_plans as p', 'p.id', '=', 's.installment_plan_id')
->where('p.business_id', $businessId)
->where('s.status', 'overdue')
->selectRaw('SUM(s.amount - COALESCE(s.paid_amount, 0)) as remaining')
->value('remaining');
$items[] = [
'level' => 'danger',
'title' => __('managementtools::lang.alert_overdue_installments'),
'value' => $overdueInstallments,
'subtitle' => __('managementtools::lang.alert_overdue_installments_amount', [
'amount' => number_format($overdueAmount, 2),
]),
'url' => $this->routeIfExists('accounting.installments'),
];
}
}
if (Schema::hasTable('accounting_cheques')) {
$pendingCheques = (int) DB::table('accounting_cheques')
->where('business_id', $businessId)
->whereIn('status', ['pending', 'deposited'])
->count();
if ($pendingCheques > 0) {
$items[] = [
'level' => 'warning',
'title' => __('managementtools::lang.alert_pending_cheques'),
'value' => $pendingCheques,
'url' => $this->routeIfExists('accounting.cheques'),
];
}
}
if (class_exists(\Modules\Accounting\Services\DeferredDocumentsService::class)) {
$deferredSummary = app(\Modules\Accounting\Services\DeferredDocumentsService::class)->summary($businessId);
if ($deferredSummary['total'] > 0) {
$items[] = [
'level' => 'danger',
'title' => __('managementtools::lang.alert_deferred_documents'),
'value' => $deferredSummary['total'],
'subtitle' => __('managementtools::lang.alert_deferred_documents_breakdown', [
'cheques' => $deferredSummary['cheques'],
'installments' => $deferredSummary['installments'],
'invoices' => $deferredSummary['invoices'],
'journals' => $deferredSummary['journals'],
]),
'url' => $this->routeIfExists('accounting.deferredDocuments'),
];
}
}
if (class_exists(\Modules\Accounting\Services\ExchangeRateService::class) && Schema::hasTable('accounting_exchange_rates')) {
$fxSummary = app(\Modules\Accounting\Services\ExchangeRateService::class)->staleRateSummary($businessId, 2);
if (($fxSummary['total_issues'] ?? (($fxSummary['stale_count'] ?? 0) + ($fxSummary['missing_count'] ?? 0))) > 0) {
$items[] = [
'level' => 'warning',
'title' => __('managementtools::lang.alert_stale_exchange_rates'),
'value' => $fxSummary['total_issues'] ?? (($fxSummary['stale_count'] ?? 0) + ($fxSummary['missing_count'] ?? 0)),
'subtitle' => __('managementtools::lang.alert_stale_exchange_rates_hint', [
'stale' => $fxSummary['stale_count'] ?? 0,
'missing' => $fxSummary['missing_count'] ?? 0,
'pairs' => implode(', ', array_slice($fxSummary['pairs'] ?? [], 0, 4)),
]),
'url' => $this->routeIfExists('accounting.exchangeRates'),
];
}
}
if (class_exists(\Modules\Accounting\Services\PaymentAccountGlLinkService::class) && Schema::hasColumn('accounts', 'accounting_account_id')) {
$paymentGl = app(\Modules\Accounting\Services\PaymentAccountGlLinkService::class)->summary($businessId);
if (($paymentGl['unlinked'] ?? 0) > 0) {
$items[] = [
'level' => 'warning',
'title' => __('managementtools::lang.alert_unlinked_payment_accounts'),
'value' => $paymentGl['unlinked'],
'subtitle' => __('managementtools::lang.alert_unlinked_payment_accounts_hint', [
'linked' => $paymentGl['linked'],
'total' => $paymentGl['total'],
]),
'url' => url('/accounting/settings').'#payment_gl_tab',
];
}
}
if (Schema::hasTable('qms_ncr_reports')) {
$openNcrs = (int) DB::table('qms_ncr_reports')
->where('business_id', $businessId)
->whereIn('status', ['open', 'investigating', 'containment'])
->count();
if ($openNcrs > 0) {
$items[] = [
'level' => 'warning',
'title' => __('managementtools::lang.alert_open_ncrs'),
'value' => $openNcrs,
'url' => url('/qualitymanagement/ncr'),
];
}
}
if (Schema::hasTable('mt_iot_alerts')) {
$iotAlerts = (int) DB::table('mt_iot_alerts')
->where('business_id', $businessId)
->where('status', 'open')
->count();
if ($iotAlerts > 0) {
$items[] = [
'level' => 'danger',
'title' => __('managementtools::lang.alert_iot_predictive'),
'value' => $iotAlerts,
'url' => url('/maintenance/iot'),
];
}
}
return $items;
}
protected function focusItems(int $businessId): array
{
$items = [];
if (Schema::hasTable('contacts')) {
$topDebtors = DB::table('contacts')
->where('business_id', $businessId)
->whereIn('type', ['customer', 'both'])
->where('balance', '>', 0)
->orderByDesc('balance')
->limit(5)
->get(['id', 'name', 'balance']);
foreach ($topDebtors as $debtor) {
$contactMeta = $this->contactActions((int) $debtor->id);
$items[] = array_merge([
'type' => 'customer_due',
'label' => (string) ($debtor->name ?: '-'),
'amount' => (float) $debtor->balance,
'currency' => true,
], $contactMeta);
}
}
return $items;
}
protected function trends(int $businessId, Carbon $startDate, Carbon $endDate): array
{
$labels = [];
$salesSeries = [];
$purchaseSeries = [];
$salesByDate = [];
$purchaseByDate = [];
if (Schema::hasTable('transactions')) {
$salesRows = DB::table('transactions')
->selectRaw('DATE(transaction_date) as d, SUM(final_total) as total')
->where('business_id', $businessId)
->where('type', 'sell')
->where('status', 'final')
->whereBetween(DB::raw('date(transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->groupBy('d')
->pluck('total', 'd')
->toArray();
$purchaseRows = DB::table('transactions')
->selectRaw('DATE(transaction_date) as d, SUM(final_total) as total')
->where('business_id', $businessId)
->where('type', 'purchase')
->whereBetween(DB::raw('date(transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->groupBy('d')
->pluck('total', 'd')
->toArray();
$salesByDate = array_map('floatval', $salesRows);
$purchaseByDate = array_map('floatval', $purchaseRows);
}
$cursor = $startDate->copy()->startOfDay();
while ($cursor->lte($endDate)) {
$key = $cursor->toDateString();
$labels[] = $cursor->format('m/d');
$salesSeries[] = $salesByDate[$key] ?? 0;
$purchaseSeries[] = $purchaseByDate[$key] ?? 0;
$cursor->addDay();
}
return [
'labels' => $labels,
'sales' => $salesSeries,
'purchase' => $purchaseSeries,
];
}
protected function riskScore(array $kpis, array $alerts): int
{
$score = 100;
$score -= min(40, count($alerts) * 12);
$score -= min(20, (int) (($kpis['overdue_invoices'] ?? 0) * 1.5));
$score -= min(20, max(0, 70 - (int) ($kpis['workforce_productivity'] ?? 0)));
$score -= min(20, ((int) ($kpis['open_tickets'] ?? 0) > 15) ? 20 : (int) ($kpis['open_tickets'] ?? 0));
return max(0, $score);
}
protected function actionCenter(int $businessId, array $kpis, array $alerts): array
{
$actions = [];
if (($kpis['overdue_invoices'] ?? 0) > 0) {
$actions[] = [
'priority' => 'high',
'title' => __('managementtools::lang.action_collect_overdue'),
'description' => __('managementtools::lang.action_collect_overdue_hint'),
'url' => url('/reports/sell-payment-report'),
'drill_tab' => 'finance',
];
}
if (($kpis['open_work_orders'] ?? 0) > 10) {
$actions[] = [
'priority' => 'medium',
'title' => __('managementtools::lang.action_stabilize_maintenance'),
'description' => __('managementtools::lang.action_stabilize_maintenance_hint'),
'url' => url('/maintenance/work-orders'),
'drill_tab' => 'maintenance',
];
}
if (($kpis['workforce_productivity'] ?? 0) < 65) {
$actions[] = [
'priority' => 'medium',
'title' => __('managementtools::lang.action_productivity_review'),
'description' => __('managementtools::lang.action_productivity_review_hint'),
'url' => url('/management-tools/daily-reports'),
'drill_tab' => 'workforce',
];
}
if (class_exists(\Modules\Accounting\Services\ExchangeRateService::class) && Schema::hasTable('accounting_exchange_rates')) {
$fxSummary = app(\Modules\Accounting\Services\ExchangeRateService::class)->staleRateSummary($businessId, 2);
if (($fxSummary['total_issues'] ?? 0) > 0) {
$actions[] = [
'priority' => 'medium',
'title' => __('managementtools::lang.action_refresh_exchange_rates'),
'description' => __('managementtools::lang.action_refresh_exchange_rates_hint'),
'url' => $this->routeIfExists('accounting.exchangeRates') ?: url('/accounting/exchange-rates'),
'drill_tab' => 'finance',
];
}
}
if (empty($actions) && empty($alerts)) {
$actions[] = [
'priority' => 'low',
'title' => __('managementtools::lang.action_keep_monitoring'),
'description' => __('managementtools::lang.action_keep_monitoring_hint'),
'url' => url('/management-tools/executive-cockpit'),
'drill_tab' => 'finance',
];
}
return $actions;
}
protected function targets(int $businessId): array
{
if (! Schema::hasTable('mt_executive_targets')) {
return [];
}
$today = Carbon::today()->toDateString();
$rows = MtExecutiveTarget::query()
->where('business_id', $businessId)
->where(function ($q) use ($today) {
$q->whereNull('effective_from')->orWhere('effective_from', '<=', $today);
})
->where(function ($q) use ($today) {
$q->whereNull('effective_to')->orWhere('effective_to', '>=', $today);
})
->orderByDesc('effective_from')
->get();
$targets = [];
foreach ($rows as $row) {
if (! array_key_exists($row->metric_key, $targets)) {
$targets[$row->metric_key] = (float) $row->target_value;
}
}
return $targets;
}
protected function variance(array $kpis, array $targets): array
{
$metrics = ['sales_this_month', 'gross_margin_estimate', 'workforce_productivity'];
$result = [];
foreach ($metrics as $metric) {
if (! isset($targets[$metric])) {
continue;
}
$actual = (float) ($kpis[$metric] ?? 0);
$target = (float) $targets[$metric];
$delta = $actual - $target;
$pct = $target != 0 ? round(($delta / $target) * 100, 2) : 0;
$result[$metric] = [
'actual' => $actual,
'target' => $target,
'delta' => $delta,
'delta_pct' => $pct,
];
}
return $result;
}
protected function latestNotes(int $businessId): array
{
if (! Schema::hasTable('mt_executive_notes')) {
return [];
}
$query = MtExecutiveNote::query()
->where('mt_executive_notes.business_id', $businessId)
->orderByDesc('mt_executive_notes.note_date')
->orderByDesc('mt_executive_notes.id')
->limit(8);
if (Schema::hasTable('users')) {
$query->leftJoin('users', 'users.id', '=', 'mt_executive_notes.user_id')
->select([
'mt_executive_notes.title',
'mt_executive_notes.note',
'mt_executive_notes.note_date',
'mt_executive_notes.visibility',
'users.first_name',
'users.last_name',
'users.surname',
]);
} else {
$query->select(['title', 'note', 'note_date', 'visibility']);
}
return $query->get()->map(function ($n) {
$author = trim(implode(' ', array_filter([
$n->first_name ?? '',
$n->last_name ?? '',
$n->surname ?? '',
])));
return [
'title' => $n->title,
'note' => $n->note,
'note_date' => $n->note_date,
'author' => $author !== '' ? $author : null,
'visibility' => $n->visibility ?? 'leadership',
];
})->toArray();
}
protected function moduleCockpit(int $businessId): array
{
$statuses = $this->moduleStatuses();
$modules = [
'Crm' => ['label' => 'CRM', 'table' => 'contacts', 'url' => url('/crm/dashboard'), 'query' => fn () => DB::table('contacts')->where('business_id', $businessId)->count()],
'Maintenance' => ['label' => 'Maintenance', 'table' => 'maintenance_work_orders', 'url' => url('/maintenance/dashboard'), 'query' => fn () => DB::table('maintenance_work_orders')->where('business_id', $businessId)->count()],
'ProfessionalProject' => ['label' => 'Project', 'table' => 'pp_projects', 'url' => url('/management-tools/project-categories'), 'query' => fn () => DB::table('pp_projects')->where('business_id', $businessId)->count()],
'Manufacturing' => ['label' => 'Manufacturing', 'table' => 'mfg_recipes', 'url' => url('/manufacturing/settings'), 'query' => fn () => $this->countManufacturingRecipes($businessId)],
'Accounting' => [
'label' => 'Accounting',
'table' => 'accounting_accounts_transactions',
'url' => url('/accounting/dashboard'),
'query' => function () use ($businessId) {
return DB::table('accounting_accounts_transactions as aat')
->join('accounting_accounts as aa', 'aa.id', '=', 'aat.accounting_account_id')
->where('aa.business_id', $businessId)
->count();
},
],
'InventoryManagement' => [
'label' => 'Inventory',
'table' => 'variation_location_details',
'url' => url('/reports/stock-report'),
'query' => function () use ($businessId) {
return DB::table('variation_location_details as vld')
->join('variations as v', 'v.id', '=', 'vld.variation_id')
->join('products as p', 'p.id', '=', 'v.product_id')
->where('p.business_id', $businessId)
->count();
},
],
'ManagementTools' => ['label' => 'Management Tools', 'table' => 'mt_daily_reports', 'url' => url('/management-tools/executive-cockpit'), 'query' => fn () => DB::table('mt_daily_reports')->where('business_id', $businessId)->count()],
'IndustrialEngineering' => ['label' => 'Industrial Engineering', 'table' => 'ie_item_masters', 'url' => url('/industrial-engineering/dashboard'), 'query' => fn () => DB::table('ie_item_masters')->where('business_id', $businessId)->count()],
'QualityManagement' => ['label' => 'Quality (QMS)', 'table' => 'qms_ncr_reports', 'url' => url('/qualitymanagement'), 'query' => fn () => DB::table('qms_ncr_reports')->where('business_id', $businessId)->count()],
'ShopFloor' => ['label' => 'Shop Floor / MES', 'table' => 'sf_work_centers', 'url' => url('/shopfloor'), 'query' => fn () => DB::table('sf_work_centers')->where('business_id', $businessId)->count()],
'SupplyChain' => ['label' => 'Supply Chain', 'table' => 'sc_supplier_scorecards', 'url' => url('/supplychain/dashboard'), 'query' => fn () => DB::table('sc_supplier_scorecards')->where('business_id', $businessId)->count()],
'AdvancedPlanning' => ['label' => __('advancedplanning::lang.module_name'), 'table' => 'ap_capacity_resources', 'url' => url('/advanced-planning/dashboard'), 'query' => fn () => DB::table('ap_capacity_resources')->where('business_id', $businessId)->count()],
'IntegrationHub' => ['label' => __('integrationhub::lang.module_name'), 'table' => 'ih_webhook_endpoints', 'url' => url('/integrationhub/dashboard'), 'query' => fn () => DB::table('ih_webhook_endpoints')->where('business_id', $businessId)->count()],
];
$result = [];
foreach ($modules as $key => $meta) {
$enabled = (bool) ($statuses[$key] ?? false);
$tableExists = Schema::hasTable($meta['table']);
$count = 0;
if ($enabled && $tableExists) {
try {
$count = (int) $meta['query']();
} catch (\Throwable $e) {
report($e);
$count = 0;
}
}
$score = 0;
if (! $enabled) {
$score = 100;
} else {
$score += 50;
if ($tableExists) {
$score += 25;
}
if ($count > 0) {
$score += 25;
}
}
$result[] = [
'key' => $key,
'label' => $meta['label'],
'enabled' => $enabled,
'table_exists' => $tableExists,
'records' => $count,
'score' => $score,
'url' => $meta['url'],
];
}
return $result;
}
protected function systemHealth(array $alerts): array
{
$severity = count($alerts) === 0 ? 'good' : (count($alerts) <= 2 ? 'watch' : 'critical');
return [
'status' => $severity,
'score' => max(0, 100 - (count($alerts) * 18)),
];
}
protected function drilldown(int $businessId, Carbon $startDate, Carbon $endDate, array $kpis, array $prevKpis, Carbon $prevStartDate, Carbon $prevEndDate, ?string $onlyTab = null): array
{
$tabsNeedingPeriodMetrics = ['sales', 'projects', 'crm', 'accounting', 'manufacturing', 'ie'];
$needsPeriodMetrics = $onlyTab === null || in_array($onlyTab, $tabsNeedingPeriodMetrics, true);
$ctx = [
'businessId' => $businessId,
'startDate' => $startDate,
'endDate' => $endDate,
'prevStartDate' => $prevStartDate,
'prevEndDate' => $prevEndDate,
'kpis' => $kpis,
'prevKpis' => $prevKpis,
'periodMetrics' => $needsPeriodMetrics
? $this->drilldownPeriodMetrics($businessId, $startDate, $endDate)
: [],
'prevPeriodMetrics' => $needsPeriodMetrics
? $this->drilldownPeriodMetrics($businessId, $prevStartDate, $prevEndDate)
: [],
];
$tabs = $onlyTab ? [$onlyTab] : $this->coreDrilldownTabKeys();
$sections = [];
foreach ($tabs as $tabKey) {
$section = $this->buildDrilldownTabSection($ctx, $tabKey);
if ($section !== null) {
$sections[$tabKey] = $section;
}
}
return $sections;
}
protected function coreDrilldownTabKeys(): array
{
return ['finance', 'sales', 'maintenance', 'projects', 'workforce', 'crm', 'accounting', 'manufacturing', 'ie', 'quality', 'shopfloor', 'supplychain', 'planning'];
}
protected function buildDrilldownTabSection(array $ctx, string $tabKey): ?array
{
$businessId = $ctx['businessId'];
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$kpis = $ctx['kpis'];
$prevKpis = $ctx['prevKpis'];
$periodMetrics = $ctx['periodMetrics'];
$prevPeriodMetrics = $ctx['prevPeriodMetrics'];
return match ($tabKey) {
'finance' => [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.customer_due'), 'value' => $kpis['customer_due'] ?? 0, 'currency' => true],
null
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.supplier_due'), 'value' => $kpis['supplier_due'] ?? 0, 'currency' => true],
null
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.gross_margin_estimate'), 'value' => $kpis['gross_margin_estimate'] ?? 0, 'currency' => true],
$this->periodComparison(
(float) ($kpis['gross_margin_estimate'] ?? 0),
(float) ($prevKpis['gross_margin_estimate'] ?? 0)
)
),
],
'trend' => $this->monthlySalesSeries($businessId, $endDate),
'top_items' => $this->topDebtors($businessId),
],
'sales' => [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.sales_this_month'), 'value' => $kpis['sales_this_month'] ?? 0, 'currency' => true],
$this->periodComparison(
(float) ($kpis['sales_this_month'] ?? 0),
(float) ($prevKpis['sales_this_month'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.sales_count'), 'value' => $periodMetrics['sales_count']],
$this->periodComparison(
(float) $periodMetrics['sales_count'],
(float) ($prevPeriodMetrics['sales_count'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.average_sale'), 'value' => $periodMetrics['average_sale'], 'currency' => true],
$this->periodComparison(
(float) $periodMetrics['average_sale'],
(float) ($prevPeriodMetrics['average_sale'] ?? 0)
)
),
],
'trend' => $this->monthlySalesSeries($businessId, $endDate),
'top_items' => $this->topCustomersBySales($businessId, $startDate, $endDate),
],
'maintenance' => $this->buildMaintenanceDrilldownSection($ctx),
'projects' => $this->buildProjectsDrilldownSection($ctx),
'workforce' => $this->buildWorkforceDrilldownSection($ctx),
'crm' => $this->buildCrmDrilldownSection($ctx),
'accounting' => $this->buildAccountingDrilldownSection($ctx),
'manufacturing' => $this->buildManufacturingDrilldownSection($ctx),
'ie' => $this->buildIeDrilldownSection($ctx),
'quality' => $this->buildQualityDrilldownSection($ctx),
'shopfloor' => $this->buildShopFloorDrilldownSection($ctx),
'supplychain' => $this->buildSupplyChainDrilldownSection($ctx),
'planning' => $this->buildPlanningDrilldownSection($ctx),
default => null,
};
}
protected function buildMaintenanceDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$endDate = $ctx['endDate'];
$kpis = $ctx['kpis'];
$prevKpis = $ctx['prevKpis'];
$draftWorkOrders = 0;
$inProgressWorkOrders = 0;
if (Schema::hasTable('maintenance_work_orders')) {
$draftWorkOrders = (int) DB::table('maintenance_work_orders')
->where('business_id', $businessId)
->where('status', 'draft')
->count();
$inProgressWorkOrders = (int) DB::table('maintenance_work_orders')
->where('business_id', $businessId)
->where('status', 'in_progress')
->count();
}
return [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.open_work_orders'), 'value' => $kpis['open_work_orders'] ?? 0],
$this->periodComparison(
(float) ($kpis['open_work_orders'] ?? 0),
(float) ($prevKpis['open_work_orders'] ?? 0),
true
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.work_orders_draft'), 'value' => $draftWorkOrders],
null
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.work_orders_in_progress'), 'value' => $inProgressWorkOrders],
null
),
],
'trend' => $this->monthlyCountSeries($businessId, 'maintenance_work_orders', 'created_at', $endDate),
'top_items' => $this->topMaintenanceBacklog($businessId),
];
}
protected function buildProjectsDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$endDate = $ctx['endDate'];
$kpis = $ctx['kpis'];
$prevKpis = $ctx['prevKpis'];
$periodMetrics = $ctx['periodMetrics'];
$prevPeriodMetrics = $ctx['prevPeriodMetrics'];
$inProgressProjects = 0;
if (Schema::hasTable('pp_projects')) {
$inProgressProjects = (int) DB::table('pp_projects')
->where('business_id', $businessId)
->where('status', 'in_progress')
->count();
}
return [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.active_projects'), 'value' => $kpis['active_projects'] ?? 0],
$this->periodComparison(
(float) ($kpis['active_projects'] ?? 0),
(float) ($prevKpis['active_projects'] ?? 0),
true
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.projects_in_progress'), 'value' => $inProgressProjects],
null
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.projects_completed_in_period'), 'value' => $periodMetrics['projects_completed_in_period']],
$this->periodComparison(
(float) $periodMetrics['projects_completed_in_period'],
(float) ($prevPeriodMetrics['projects_completed_in_period'] ?? 0)
)
),
],
'trend' => $this->monthlyCountSeries($businessId, 'pp_projects', 'created_at', $endDate),
'top_items' => $this->topProjectRisks($businessId),
];
}
protected function buildWorkforceDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$kpis = $ctx['kpis'];
$prevKpis = $ctx['prevKpis'];
$activeEmployees = 0;
$submittedReports = 0;
if (Schema::hasTable('users')) {
$activeEmployees = (int) DB::table('users')
->where('business_id', $businessId)
->where('allow_login', 1)
->where('user_type', 'user')
->count();
}
if (Schema::hasTable('mt_daily_reports')) {
$submittedReports = (int) DB::table('mt_daily_reports')
->where('business_id', $businessId)
->whereBetween(DB::raw('date(report_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
}
return [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.active_employees'), 'value' => $activeEmployees],
null
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.mt_reports_submitted'), 'value' => $submittedReports],
$this->periodComparison(
(float) $submittedReports,
(float) ($prevKpis['mt_reports_submitted'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.workforce_productivity'), 'value' => ($kpis['workforce_productivity'] ?? 0).'%'],
$this->periodComparison(
(float) ($kpis['workforce_productivity'] ?? 0),
(float) ($prevKpis['workforce_productivity'] ?? 0)
)
),
],
'trend' => $this->monthlyCountSeries($businessId, 'mt_daily_reports', 'report_date', $endDate),
'top_items' => $this->topReporters($businessId, $startDate, $endDate),
];
}
protected function buildCrmDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$prevPeriodMetrics = $ctx['prevPeriodMetrics'];
$crmMetrics = $this->crmDrilldownMetrics($businessId, $startDate, $endDate);
return [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.crm_total_leads'), 'value' => $crmMetrics['total_leads']],
null
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.crm_new_leads'), 'value' => $crmMetrics['new_leads']],
$this->periodComparison(
(float) $crmMetrics['new_leads'],
(float) ($prevPeriodMetrics['crm_new_leads'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.crm_pipeline_value'), 'value' => $crmMetrics['pipeline_value'], 'currency' => true],
null
),
],
'trend' => $this->monthlyLeadsSeries($businessId, $endDate),
'top_items' => $this->topCrmLeads($businessId),
];
}
protected function buildAccountingDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$endDate = $ctx['endDate'];
$prevPeriodMetrics = $ctx['prevPeriodMetrics'];
$accountingMetrics = $this->accountingDrilldownMetrics($businessId, $ctx['startDate'], $endDate);
return [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.accounting_journal_entries'), 'value' => $accountingMetrics['journal_entries']],
$this->periodComparison(
(float) $accountingMetrics['journal_entries'],
(float) ($prevPeriodMetrics['journal_entry_count'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.accounting_pending_cheques'), 'value' => $accountingMetrics['pending_cheques']],
$this->periodComparison(
(float) $accountingMetrics['pending_cheques'],
(float) ($accountingMetrics['prev_pending_cheques'] ?? 0),
true
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.accounting_overdue_installments'), 'value' => $accountingMetrics['overdue_installments']],
null
),
],
'trend' => $this->monthlyAccountingJournalSeries($businessId, $endDate),
'top_items' => $this->topAccountingExposure($businessId),
];
}
protected function buildManufacturingDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$periodMetrics = $ctx['periodMetrics'];
$prevPeriodMetrics = $ctx['prevPeriodMetrics'];
$manufacturingMetrics = $this->manufacturingDrilldownMetrics($businessId, $startDate, $endDate, $periodMetrics, $prevPeriodMetrics);
return [
'kpis' => $manufacturingMetrics['kpis'],
'trend' => $this->monthlyProductionCountSeries($businessId, $endDate),
'top_items' => $this->topProductions($businessId, $startDate, $endDate),
];
}
protected function buildIeDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$periodMetrics = $ctx['periodMetrics'];
$prevPeriodMetrics = $ctx['prevPeriodMetrics'];
$ieMetrics = $this->ieDrilldownMetrics($businessId, $startDate, $endDate, $periodMetrics, $prevPeriodMetrics);
return [
'kpis' => $ieMetrics['kpis'],
'trend' => $this->monthlyMrpRunsSeries($businessId, $endDate),
'top_items' => $this->topIeExposure($businessId, $startDate, $endDate),
];
}
protected function buildQualityDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$openNcrs = Schema::hasTable('qms_ncr_reports')
? (int) DB::table('qms_ncr_reports')->where('business_id', $businessId)->whereNotIn('status', ['closed', 'cancelled'])->count()
: 0;
$openCapas = Schema::hasTable('qms_capa_actions')
? (int) DB::table('qms_capa_actions')->where('business_id', $businessId)->whereNotIn('status', ['closed', 'cancelled'])->count()
: 0;
$spcAlerts = Schema::hasTable('qms_spc_measurements')
? (int) DB::table('qms_spc_measurements')->where('business_id', $businessId)->where('in_control', false)->where('measured_at', '>=', now()->subDays(30))->count()
: 0;
return [
'kpis' => [
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.open_ncrs'), 'value' => $openNcrs], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.open_capas'), 'value' => $openCapas], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.spc_out_of_control'), 'value' => $spcAlerts], null),
],
'trend' => $this->monthlyCountSeries($businessId, 'qms_ncr_reports', 'created_at', $ctx['endDate']),
'top_items' => [],
];
}
protected function buildShopFloorDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$avgOee = Schema::hasTable('sf_oee_snapshots')
? (float) DB::table('sf_oee_snapshots')->where('business_id', $businessId)->where('snapshot_date', '>=', $ctx['startDate']->toDateString())->avg('oee_pct')
: 0;
$events = Schema::hasTable('sf_production_events')
? (int) DB::table('sf_production_events')->where('business_id', $businessId)->whereBetween('recorded_at', [$ctx['startDate'], $ctx['endDate']])->count()
: 0;
$downtime = Schema::hasTable('sf_downtime_events')
? (int) DB::table('sf_downtime_events')->where('business_id', $businessId)->whereNull('ended_at')->count()
: 0;
return [
'kpis' => [
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.avg_oee'), 'value' => round($avgOee, 1)], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.production_events'), 'value' => $events], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.active_downtime'), 'value' => $downtime], null),
],
'trend' => $this->monthlyCountSeries($businessId, 'sf_production_events', 'recorded_at', $ctx['endDate']),
'top_items' => [],
];
}
protected function buildSupplyChainDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$avgScore = Schema::hasTable('sc_supplier_scorecards')
? (float) DB::table('sc_supplier_scorecards')->where('business_id', $businessId)->avg('overall_score')
: 0;
$forecasts = Schema::hasTable('sc_demand_forecasts')
? (int) DB::table('sc_demand_forecasts')->where('business_id', $businessId)->count()
: 0;
$openRfq = Schema::hasTable('sc_rfq_requests')
? (int) DB::table('sc_rfq_requests')->where('business_id', $businessId)->whereIn('status', ['draft', 'open'])->count()
: 0;
return [
'kpis' => [
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.supplier_score_avg'), 'value' => round($avgScore, 1)], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.demand_forecasts'), 'value' => $forecasts], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.open_rfqs'), 'value' => $openRfq], null),
],
'trend' => $this->monthlyCountSeries($businessId, 'sc_rfq_requests', 'created_at', $ctx['endDate']),
'top_items' => [],
];
}
protected function buildPlanningDrilldownSection(array $ctx): array
{
$businessId = $ctx['businessId'];
$resources = Schema::hasTable('ap_capacity_resources')
? (int) DB::table('ap_capacity_resources')->where('business_id', $businessId)->count()
: 0;
$schedules = Schema::hasTable('ap_schedule_runs')
? (int) DB::table('ap_schedule_runs')->where('business_id', $businessId)->where('status', 'completed')->count()
: 0;
$sopDrafts = Schema::hasTable('ap_sop_cycles')
? (int) DB::table('ap_sop_cycles')->where('business_id', $businessId)->where('status', 'draft')->count()
: 0;
return [
'kpis' => [
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.capacity_resources'), 'value' => $resources], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.completed_schedules'), 'value' => $schedules], null),
$this->enrichKpiWithComparison(['label' => __('managementtools::lang.sop_draft_cycles'), 'value' => $sopDrafts], null),
],
'trend' => $this->monthlyCountSeries($businessId, 'ap_schedule_runs', 'created_at', $ctx['endDate']),
'top_items' => [],
];
}
public function holdingEntityComparison(int $businessId, Carbon $startDate, Carbon $endDate): array
{
return $this->metricsCache->rememberHoldingComparison(
$businessId,
$startDate,
$endDate,
fn () => $this->holdingComparisonService->compare(
$businessId,
$startDate,
$endDate,
fn ($current, $previous) => $this->periodComparison($current, $previous),
fn ($start, $end) => $this->previousPeriodRange($start, $end)
)
);
}
protected function entityPeriodSales(int $businessId, int $entityId, int $locationId, Carbon $startDate, Carbon $endDate): float
{
$batch = $this->transactionMetrics->entitySalesBatch(
$businessId,
[$entityId],
[$entityId => $locationId],
$startDate,
$endDate
);
return (float) ($batch[$entityId] ?? 0);
}
protected function monthlySalesSeries(int $businessId, Carbon $endDate): array
{
return $this->transactionMetrics->monthlySalesSeries($businessId, $endDate);
}
protected function monthlyCountSeries(int $businessId, string $table, string $dateColumn, Carbon $endDate): array
{
return $this->transactionMetrics->monthlyCountSeries($businessId, $table, $dateColumn, $endDate);
}
protected function topDebtors(int $businessId): array
{
if (! Schema::hasTable('contacts')) {
return [];
}
return DB::table('contacts')
->where('business_id', $businessId)
->whereIn('type', ['customer', 'both'])
->where('balance', '>', 0)
->orderByDesc('balance')
->limit(5)
->get(['id', 'name', 'balance'])
->map(function ($row) {
$contactMeta = $this->contactActions((int) $row->id);
return array_merge([
'label' => (string) ($row->name ?: '-'),
'value' => (float) $row->balance,
'currency' => true,
], $contactMeta);
})
->toArray();
}
protected function topCustomersBySales(int $businessId, Carbon $startDate, Carbon $endDate): array
{
if (! Schema::hasTable('transactions') || ! Schema::hasColumn('transactions', 'contact_id')) {
return [];
}
$query = DB::table('transactions as t')
->leftJoin('contacts as c', 'c.id', '=', 't.contact_id')
->where('t.business_id', $businessId)
->where('t.type', 'sell')
->where('t.status', 'final')
->whereBetween(DB::raw('date(t.transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->groupBy('t.contact_id', 'c.name')
->selectRaw('t.contact_id, COALESCE(c.name, ?) as label, SUM(t.final_total) as total', ['-'])
->orderByDesc('total')
->limit(5)
->get();
return $query->map(function ($row) {
$contactId = isset($row->contact_id) ? (int) $row->contact_id : 0;
$contactMeta = $this->contactActions($contactId);
return array_merge([
'label' => (string) $row->label,
'value' => (float) $row->total,
'currency' => true,
], $contactMeta);
})->toArray();
}
protected function topMaintenanceBacklog(int $businessId): array
{
if (! Schema::hasTable('maintenance_work_orders')) {
return [];
}
$rows = DB::table('maintenance_work_orders')
->where('business_id', $businessId)
->whereIn('status', ['draft', 'open', 'in_progress', 'on_hold'])
->orderByDesc('updated_at')
->limit(5)
->get(['id', 'status']);
return $rows->map(function ($row) {
return [
'label' => '#'.$row->id.' ('.$row->status.')',
'value' => 1,
'url' => url('/maintenance/work-orders/'.$row->id),
'permissions' => ['maintenance.view', 'maintenance.access'],
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => url('/maintenance/work-orders/'.$row->id),
'permissions' => ['maintenance.view', 'maintenance.access'],
],
[
'label' => __('managementtools::lang.open_maintenance_board'),
'url' => url('/maintenance/dashboard'),
'permissions' => ['maintenance.view', 'maintenance.access'],
],
],
];
})->toArray();
}
protected function topProjectRisks(int $businessId): array
{
if (! Schema::hasTable('pp_projects')) {
return [];
}
$query = DB::table('pp_projects')
->where('business_id', $businessId)
->whereIn('status', ['not_started', 'in_progress', 'on_hold'])
->orderByDesc('updated_at')
->limit(5)
->get(['id', 'name', 'status']);
return $query->map(function ($row) {
return [
'label' => (string) ($row->name ?: '-').' ('.$row->status.')',
'value' => 1,
'url' => url('/professional-project/projects/'.$row->id),
'permissions' => ['managementtools.board_view'],
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => url('/professional-project/projects/'.$row->id),
'permissions' => ['managementtools.board_view'],
],
[
'label' => __('managementtools::lang.open_project_center'),
'url' => url('/professional-project/projects?view=list'),
'permissions' => ['managementtools.board_view'],
],
],
];
})->toArray();
}
protected function topReporters(int $businessId, Carbon $startDate, Carbon $endDate): array
{
if (! Schema::hasTable('mt_daily_reports') || ! Schema::hasColumn('mt_daily_reports', 'user_id')) {
return [];
}
$query = DB::table('mt_daily_reports as r')
->leftJoin('users as u', 'u.id', '=', 'r.user_id')
->where('r.business_id', $businessId)
->whereBetween(DB::raw('date(r.report_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->groupBy('r.user_id', 'u.username')
->selectRaw('r.user_id, COALESCE(u.username, ?) as label, COUNT(*) as total', ['-'])
->orderByDesc('total')
->limit(5)
->get();
return $query->map(function ($row) {
$userId = isset($row->user_id) ? (int) $row->user_id : 0;
return [
'label' => (string) $row->label,
'value' => (int) $row->total,
'url' => $userId > 0 ? url('/users/'.$userId.'/edit') : url('/users'),
'permissions' => ['user.view', 'user.create', 'user.update'],
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => $userId > 0 ? url('/users/'.$userId.'/edit') : url('/users'),
'permissions' => ['user.view', 'user.create', 'user.update'],
],
[
'label' => __('managementtools::lang.open_daily_reports'),
'url' => url('/management-tools/daily-reports'),
'permissions' => ['managementtools.board_view'],
],
],
];
})->toArray();
}
protected function crmViewPermissions(): array
{
return ['crm.access_all_leads', 'crm.access_own_leads', 'managementtools.board_view'];
}
protected function accountingViewPermissions(): array
{
return ['accounting.access_accounting_module', 'accounting.view_reports', 'managementtools.board_view'];
}
protected function manufacturingViewPermissions(): array
{
return ['manufacturing.access_production', 'manufacturing.access_recipe', 'managementtools.board_view'];
}
protected function crmDrilldownMetrics(int $businessId, Carbon $startDate, Carbon $endDate): array
{
$metrics = [
'total_leads' => 0,
'new_leads' => 0,
'pipeline_value' => 0.0,
];
if (! Schema::hasTable('contacts')) {
return $metrics;
}
$leadQuery = DB::table('contacts')
->where('business_id', $businessId)
->where('type', 'lead');
if (Schema::hasColumn('contacts', 'contact_status')) {
$leadQuery->where('contact_status', 'active');
}
$metrics['total_leads'] = (int) (clone $leadQuery)->count();
$metrics['new_leads'] = (int) (clone $leadQuery)
->whereBetween(DB::raw('date(created_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
if (Schema::hasColumn('contacts', 'crm_lead_value')) {
$metrics['pipeline_value'] = (float) (clone $leadQuery)->sum('crm_lead_value');
}
return $metrics;
}
protected function accountingDrilldownMetrics(int $businessId, Carbon $startDate, Carbon $endDate): array
{
$metrics = [
'journal_entries' => 0,
'pending_cheques' => 0,
'prev_pending_cheques' => 0,
'overdue_installments' => 0,
];
if (Schema::hasTable('accounting_accounts_transactions') && Schema::hasTable('accounting_accounts')) {
$dateColumn = Schema::hasColumn('accounting_accounts_transactions', 'operation_date') ? 'operation_date' : 'created_at';
$metrics['journal_entries'] = (int) DB::table('accounting_accounts_transactions as aat')
->join('accounting_accounts as aa', 'aa.id', '=', 'aat.accounting_account_id')
->where('aa.business_id', $businessId)
->whereBetween(DB::raw('date(aat.'.$dateColumn.')'), [$startDate->toDateString(), $endDate->toDateString()])
->count();
}
if (Schema::hasTable('accounting_cheques')) {
$metrics['pending_cheques'] = (int) DB::table('accounting_cheques')
->where('business_id', $businessId)
->whereIn('status', ['pending', 'deposited'])
->count();
}
if (Schema::hasTable('accounting_installment_schedule') && Schema::hasTable('accounting_installment_plans')) {
$metrics['overdue_installments'] = (int) DB::table('accounting_installment_schedule as s')
->join('accounting_installment_plans as p', 'p.id', '=', 's.installment_plan_id')
->where('p.business_id', $businessId)
->where('s.status', 'overdue')
->count();
}
return $metrics;
}
protected function manufacturingDrilldownMetrics(int $businessId, Carbon $startDate, Carbon $endDate, array $periodMetrics, array $prevPeriodMetrics): array
{
$productionCount = (int) ($periodMetrics['production_count'] ?? 0);
$productionValue = (float) ($periodMetrics['production_value'] ?? 0);
$activeRecipes = 0;
if (Schema::hasTable('mfg_recipes')) {
$activeRecipes = $this->countManufacturingRecipes($businessId);
}
return [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.manufacturing_production_count'), 'value' => $productionCount],
$this->periodComparison(
(float) $productionCount,
(float) ($prevPeriodMetrics['production_count'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.manufacturing_production_value'), 'value' => $productionValue, 'currency' => true],
$this->periodComparison(
$productionValue,
(float) ($prevPeriodMetrics['production_value'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.manufacturing_active_recipes'), 'value' => $activeRecipes],
null
),
],
];
}
protected function monthlyLeadsSeries(int $businessId, Carbon $endDate): array
{
if (! Schema::hasTable('contacts')) {
return [0, 0, 0, 0, 0, 0];
}
$series = [];
for ($i = 5; $i >= 0; $i--) {
$monthStart = $endDate->copy()->startOfMonth()->subMonths($i);
$monthEnd = $monthStart->copy()->endOfMonth();
$query = DB::table('contacts')
->where('business_id', $businessId)
->where('type', 'lead')
->whereBetween(DB::raw('date(created_at)'), [$monthStart->toDateString(), $monthEnd->toDateString()]);
if (Schema::hasColumn('contacts', 'contact_status')) {
$query->where('contact_status', 'active');
}
$series[] = (int) $query->count();
}
return $series;
}
protected function monthlyAccountingJournalSeries(int $businessId, Carbon $endDate): array
{
if (! Schema::hasTable('accounting_accounts_transactions') || ! Schema::hasTable('accounting_accounts')) {
return [0, 0, 0, 0, 0, 0];
}
$dateColumn = Schema::hasColumn('accounting_accounts_transactions', 'operation_date') ? 'operation_date' : 'created_at';
$series = [];
for ($i = 5; $i >= 0; $i--) {
$monthStart = $endDate->copy()->startOfMonth()->subMonths($i);
$monthEnd = $monthStart->copy()->endOfMonth();
$series[] = (int) DB::table('accounting_accounts_transactions as aat')
->join('accounting_accounts as aa', 'aa.id', '=', 'aat.accounting_account_id')
->where('aa.business_id', $businessId)
->whereBetween(DB::raw('date(aat.'.$dateColumn.')'), [$monthStart->toDateString(), $monthEnd->toDateString()])
->count();
}
return $series;
}
protected function monthlyProductionCountSeries(int $businessId, Carbon $endDate): array
{
if (! Schema::hasTable('transactions')) {
return [0, 0, 0, 0, 0, 0];
}
$series = [];
for ($i = 5; $i >= 0; $i--) {
$monthStart = $endDate->copy()->startOfMonth()->subMonths($i);
$monthEnd = $monthStart->copy()->endOfMonth();
$series[] = (int) DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'production_purchase')
->whereBetween(DB::raw('date(transaction_date)'), [$monthStart->toDateString(), $monthEnd->toDateString()])
->count();
}
return $series;
}
protected function topCrmLeads(int $businessId): array
{
if (! Schema::hasTable('contacts')) {
return [];
}
$query = DB::table('contacts')
->where('business_id', $businessId)
->where('type', 'lead');
if (Schema::hasColumn('contacts', 'contact_status')) {
$query->where('contact_status', 'active');
}
if (Schema::hasColumn('contacts', 'crm_lead_value')) {
$query->orderByDesc('crm_lead_value');
} else {
$query->orderByDesc('created_at');
}
$permissions = $this->crmViewPermissions();
$hasLeadValue = Schema::hasColumn('contacts', 'crm_lead_value');
$columns = ['id', 'name'];
if (Schema::hasColumn('contacts', 'crm_lead_title')) {
$columns[] = 'crm_lead_title';
}
if ($hasLeadValue) {
$columns[] = 'crm_lead_value';
}
return $query->limit(5)->get($columns)
->map(function ($row) use ($permissions, $hasLeadValue) {
$label = (string) (($row->crm_lead_title ?? null) ?: $row->name ?: '-');
$value = $hasLeadValue ? (float) ($row->crm_lead_value ?? 0) : 0;
return [
'label' => $label,
'value' => $value,
'currency' => $hasLeadValue,
'url' => url('/contacts/'.$row->id),
'permissions' => $permissions,
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => url('/contacts/'.$row->id),
'permissions' => $permissions,
],
[
'label' => __('managementtools::lang.open_crm_board'),
'url' => url('/crm/dashboard'),
'permissions' => $permissions,
],
],
];
})->toArray();
}
protected function topAccountingExposure(int $businessId): array
{
$items = [];
$permissions = $this->accountingViewPermissions();
if (Schema::hasTable('accounting_cheques')) {
$cheques = DB::table('accounting_cheques')
->where('business_id', $businessId)
->whereIn('status', ['pending', 'deposited'])
->orderByDesc('amount')
->limit(5)
->get(['id', 'cheque_number', 'amount', 'status']);
foreach ($cheques as $cheque) {
$items[] = [
'label' => __('managementtools::lang.accounting_cheque_item', [
'number' => $cheque->cheque_number,
'status' => $cheque->status,
]),
'value' => (float) $cheque->amount,
'currency' => true,
'url' => $this->routeIfExists('accounting.cheques') ?: url('/accounting/cheques'),
'permissions' => $permissions,
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => $this->routeIfExists('accounting.cheques') ?: url('/accounting/cheques'),
'permissions' => $permissions,
],
[
'label' => __('managementtools::lang.open_accounting_dashboard'),
'url' => url('/accounting/dashboard'),
'permissions' => $permissions,
],
],
];
}
}
if (empty($items) && Schema::hasTable('accounting_accounts') && Schema::hasTable('accounting_accounts_transactions')) {
$accounts = DB::table('accounting_accounts as aa')
->leftJoin('accounting_accounts_transactions as aat', 'aat.accounting_account_id', '=', 'aa.id')
->where('aa.business_id', $businessId)
->groupBy('aa.id', 'aa.name')
->selectRaw('aa.id, aa.name as label, COUNT(aat.id) as total')
->orderByDesc('total')
->limit(5)
->get();
foreach ($accounts as $account) {
$items[] = [
'label' => (string) $account->label,
'value' => (int) $account->total,
'url' => $this->routeIfExists('accounting.ledger') ? route('accounting.ledger', ['id' => $account->id]) : url('/accounting/chart-of-accounts'),
'permissions' => $permissions,
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => $this->routeIfExists('accounting.ledger') ? route('accounting.ledger', ['id' => $account->id]) : url('/accounting/chart-of-accounts'),
'permissions' => $permissions,
],
],
];
}
}
return $items;
}
protected function topProductions(int $businessId, Carbon $startDate, Carbon $endDate): array
{
if (! Schema::hasTable('transactions')) {
return [];
}
$permissions = $this->manufacturingViewPermissions();
return DB::table('transactions')
->where('business_id', $businessId)
->where('type', 'production_purchase')
->whereBetween(DB::raw('date(transaction_date)'), [$startDate->toDateString(), $endDate->toDateString()])
->orderByDesc('final_total')
->limit(5)
->get(['id', 'ref_no', 'final_total', 'transaction_date'])
->map(function ($row) use ($permissions) {
$label = (string) ($row->ref_no ?: '#'.$row->id);
return [
'label' => $label,
'value' => (float) $row->final_total,
'currency' => true,
'url' => url('/manufacturing/production/'.$row->id),
'permissions' => $permissions,
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => url('/manufacturing/production/'.$row->id),
'permissions' => $permissions,
],
[
'label' => __('managementtools::lang.open_manufacturing_board'),
'url' => url('/manufacturing/production'),
'permissions' => $permissions,
],
],
];
})->toArray();
}
protected function ieViewPermissions(): array
{
return ['ie.view', 'ie.bom.view', 'ie.costing.view', 'managementtools.board_view'];
}
protected function ieDrilldownMetrics(int $businessId, Carbon $startDate, Carbon $endDate, array $periodMetrics, array $prevPeriodMetrics): array
{
$mrpRuns = (int) ($periodMetrics['ie_mrp_runs'] ?? 0);
$activeBoms = 0;
$productionLines = 0;
$releasedRevisions = 0;
$shortageRuns = 0;
if (Schema::hasTable('ie_boms')) {
$activeBoms = (int) DB::table('ie_boms')
->where('business_id', $businessId)
->whereNull('deleted_at')
->count();
}
if (Schema::hasTable('ie_line_templates')) {
$lineQuery = DB::table('ie_line_templates')
->where('business_id', $businessId)
->whereNull('deleted_at');
if (Schema::hasColumn('ie_line_templates', 'parent_template_id')) {
$lineQuery->whereNull('parent_template_id');
}
$productionLines = (int) $lineQuery->count();
}
if (Schema::hasTable('ie_line_revisions')) {
$releasedRevisions = (int) DB::table('ie_line_revisions')
->where('business_id', $businessId)
->where('status', 'released')
->whereNull('deleted_at')
->count();
}
if (Schema::hasTable('ie_mrp_runs')) {
$runs = DB::table('ie_mrp_runs')
->where('business_id', $businessId)
->whereBetween(DB::raw('date(created_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->get(['shortage_analysis']);
foreach ($runs as $run) {
$analysis = json_decode((string) ($run->shortage_analysis ?? ''), true);
if (! empty($analysis['shortages'])) {
$shortageRuns++;
}
}
}
return [
'kpis' => [
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.ie_mrp_runs'), 'value' => $mrpRuns],
$this->periodComparison(
(float) $mrpRuns,
(float) ($prevPeriodMetrics['ie_mrp_runs'] ?? 0)
)
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.ie_active_boms'), 'value' => $activeBoms],
null
),
$this->enrichKpiWithComparison(
['label' => __('managementtools::lang.ie_production_lines'), 'value' => $productionLines],
null
),
],
'released_revisions' => $releasedRevisions,
'shortage_runs' => $shortageRuns,
];
}
protected function monthlyMrpRunsSeries(int $businessId, Carbon $endDate): array
{
if (! Schema::hasTable('ie_mrp_runs')) {
return [0, 0, 0, 0, 0, 0];
}
$series = [];
for ($i = 5; $i >= 0; $i--) {
$monthStart = $endDate->copy()->startOfMonth()->subMonths($i);
$monthEnd = $monthStart->copy()->endOfMonth();
$series[] = (int) DB::table('ie_mrp_runs')
->where('business_id', $businessId)
->whereBetween(DB::raw('date(created_at)'), [$monthStart->toDateString(), $monthEnd->toDateString()])
->count();
}
return $series;
}
protected function topIeExposure(int $businessId, Carbon $startDate, Carbon $endDate): array
{
$items = [];
$permissions = $this->ieViewPermissions();
if (Schema::hasTable('ie_mrp_runs')) {
$runs = DB::table('ie_mrp_runs as r')
->leftJoin('ie_boms as b', 'b.id', '=', 'r.bom_id')
->where('r.business_id', $businessId)
->whereBetween(DB::raw('date(r.created_at)'), [$startDate->toDateString(), $endDate->toDateString()])
->orderByDesc('r.created_at')
->limit(5)
->get(['r.id', 'r.run_code', 'r.planned_quantity', 'r.shortage_analysis', 'b.name as bom_name']);
foreach ($runs as $run) {
$analysis = json_decode((string) ($run->shortage_analysis ?? ''), true);
$shortageCount = is_array($analysis['shortages'] ?? null) ? count($analysis['shortages']) : 0;
$shortageValue = (float) ($analysis['totals']['shortage_value'] ?? 0);
$label = (string) ($run->run_code ?: 'MRP #'.$run->id);
if (! empty($run->bom_name)) {
$label .= ' — '.$run->bom_name;
}
$items[] = [
'label' => $label,
'value' => $shortageValue > 0 ? $shortageValue : $shortageCount,
'currency' => $shortageValue > 0,
'url' => url('/industrial-engineering/mrp'),
'permissions' => $permissions,
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => url('/industrial-engineering/mrp'),
'permissions' => $permissions,
],
[
'label' => __('managementtools::lang.open_ie_dashboard'),
'url' => url('/industrial-engineering/dashboard'),
'permissions' => $permissions,
],
],
];
}
}
if (empty($items) && Schema::hasTable('ie_cost_scenarios')) {
$scenarios = DB::table('ie_cost_scenarios')
->where('business_id', $businessId)
->whereNull('deleted_at')
->orderByDesc('total_cost')
->limit(5)
->get(['id', 'scenario_code', 'name', 'total_cost']);
foreach ($scenarios as $scenario) {
$items[] = [
'label' => (string) (($scenario->name ?: $scenario->scenario_code) ?: '-'),
'value' => (float) $scenario->total_cost,
'currency' => true,
'url' => url('/industrial-engineering/dashboard'),
'permissions' => $permissions,
'actions' => [
[
'label' => __('managementtools::lang.view_details'),
'url' => url('/industrial-engineering/dashboard'),
'permissions' => $permissions,
],
],
];
}
}
return $items;
}
protected function countManufacturingRecipes(int $businessId): int
{
if (! Schema::hasTable('mfg_recipes')) {
return 0;
}
$query = DB::table('mfg_recipes');
if (Schema::hasColumn('mfg_recipes', 'business_id')) {
return (int) $query->where('business_id', $businessId)->count();
}
if (! Schema::hasTable('products') || ! Schema::hasColumn('products', 'business_id')) {
return (int) $query->count();
}
return (int) $query
->join('products as p', 'p.id', '=', 'mfg_recipes.product_id')
->where('p.business_id', $businessId)
->count();
}
protected function routeIfExists(string $name): ?string
{
return Route::has($name) ? route($name) : null;
}
}