src/CompanyGroupBundle/Controller/SuperAdminDashboardController.php line 260

Open in your IDE?
  1. <?php
  2. namespace CompanyGroupBundle\Controller;
  3. use ApplicationBundle\Constants\ModuleConstant;
  4. use ApplicationBundle\Modules\System\MiscActions;
  5. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  6. use ApplicationBundle\Command\Support\FleetHealthVerdict;
  7. use ApplicationBundle\Helper\EbBridgeConfig;
  8. use ApplicationBundle\Modules\Api\Support\EbBridgeVerifier;
  9. use CompanyGroupBundle\Entity\EnergyBridgeSiteLink;
  10. use Symfony\Bundle\FrameworkBundle\Controller\Controller;
  11. use Symfony\Component\HttpFoundation\Request;
  12. use Symfony\Component\HttpFoundation\JsonResponse;
  13. class SuperAdminDashboardController extends Controller
  14. {
  15.     public function indexAction(Request $request)
  16.     {
  17.         if (!$this->canAccessSuperAdminDashboard($request)) {
  18.             return $this->redirectToRoute('dashboard');
  19.         }
  20.         $service $this->get('app.admin_dashboard_service');
  21.         $metrics $service->getMetrics();
  22.         $companies $service->listCompanies(120, []);
  23.         $usageSummary $service->getUsageSummary(30);
  24.         $alerts $service->getAlerts();
  25.         $chartData $this->buildChartData($usageSummary);
  26.         return $this->render('@CompanyGroup/pages/super_admin_command_center.html.twig', [
  27.             'page_title' => 'Super Admin Command Center',
  28.             'metrics' => $metrics,
  29.             'companies' => $companies,
  30.             'usage_summary' => $usageSummary,
  31.             'alerts' => $alerts,
  32.             'chart_data' => $chartData,
  33.         ]);
  34.     }
  35.     public function companyListAction(Request $request)
  36.     {
  37.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  38.         if ($systemType !== '_CENTRAL_') {
  39.             return $this->redirectToRoute('dashboard');
  40.         }
  41.         if (!$this->canAccessSuperAdminDashboard($request)) {
  42.             return $this->redirectToRoute('dashboard');
  43.         }
  44.         $page max(1, (int)$request->query->get('page'1));
  45.         $limit 25;
  46.         $offset = ($page 1) * $limit;
  47.         $filters = array(
  48.             'search' => trim((string)$request->query->get('q''')),
  49.             'status' => trim((string)$request->query->get('status''')),
  50.         );
  51.         $service $this->get('app.admin_dashboard_service');
  52.         $companies $service->listCompanies($limit$offset$filters);
  53.         $total $this->countCompaniesForAdminList($filters);
  54.         $totalPages max(1, (int)ceil($total $limit));
  55.         $summary $this->getCompanyListSummary();
  56.         return $this->render('@CompanyGroup/pages/admin/companies/list_companies.html.twig', array(
  57.             'page_title' => 'Companies',
  58.             'companies' => $companies,
  59.             'filters' => $filters,
  60.             'summary' => $summary,
  61.             'total' => $total,
  62.             'currentPage' => $page,
  63.             'totalPages' => $totalPages,
  64.         ));
  65.     }
  66.     /**
  67.      * L4 — Fleet health dashboard: 100 tenants at a glance. Reads the central snapshots the
  68.      * collector wrote (never live-queries tenant DBs) and renders one green/amber/red row per
  69.      * tenant, red-first. Every central read is guarded so the page renders even before the
  70.      * fleet_health_snapshot / tenant_schema_state / cron_cycle_run tables are migrated.
  71.      */
  72.     public function fleetHealthAction(Request $request)
  73.     {
  74.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  75.         if ($systemType !== '_CENTRAL_') {
  76.             return $this->redirectToRoute('dashboard');
  77.         }
  78.         if (!$this->canAccessSuperAdminDashboard($request)) {
  79.             return $this->redirectToRoute('dashboard');
  80.         }
  81.         $cem $this->getDoctrine()->getManager('company_group');
  82.         $now time();
  83.         $graceDays $this->container->hasParameter('subscription_grace_days')
  84.             ? (int) $this->container->getParameter('subscription_grace_days') : 7;
  85.         $tenants = array();
  86.         try {
  87.             foreach ($cem->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')->findBy(array('active' => 1)) as $g) {
  88.                 if (!$g->getDbName()) { continue; }
  89.                 $tenants[(int) $g->getAppId()] = array('appId' => (int) $g->getAppId(), 'name' => $g->getName(), 'dbName' => $g->getDbName());
  90.             }
  91.         } catch (\Throwable $e) { /* central unreachable */ }
  92.         $snaps = array();
  93.         try { foreach ($cem->getRepository('CompanyGroupBundle\\Entity\\FleetHealthSnapshot')->findAll() as $s) { $snaps[(int) $s->getAppId()] = $s; } } catch (\Throwable $e) {}
  94.         $schema = array();
  95.         try { foreach ($cem->getRepository('CompanyGroupBundle\\Entity\\TenantSchemaState')->findAll() as $s) { $schema[(int) $s->getAppId()] = $s; } } catch (\Throwable $e) {}
  96.         // Latest fan-out cron cycle (fleet-wide), latest fleet-migrate report, recent alerts.
  97.         $lastCron null$lastMigrate null$alerts = array();
  98.         try {
  99.             foreach ($cem->getRepository('CompanyGroupBundle\\Entity\\CronCycleRun')->findBy(array(), array('finishedAt' => 'DESC'), 30) as $c) {
  100.                 if ($c->getCommand() === 'fleet-migrate') { if ($lastMigrate === null) { $lastMigrate $c; } }
  101.                 elseif ($lastCron === null) { $lastCron $c; }
  102.                 if ($c->getAlert() && count($alerts) < 8) { $alerts[] = $c; }
  103.             }
  104.         } catch (\Throwable $e) {}
  105.         $cronAgeSec null$cronLastFailed false;
  106.         if ($lastCron && $lastCron->getFinishedAt() instanceof \DateTimeInterface) {
  107.             $cronAgeSec $now - (int) $lastCron->getFinishedAt()->format('U');
  108.             $cronLastFailed = ((int) $lastCron->getFailedCount() + (int) $lastCron->getTimedOutCount()) > 0;
  109.         }
  110.         // FE1 — recent red-finding alerts (pre-schema-safe: table missing → empty panel).
  111.         $fleetAlerts = array();
  112.         try {
  113.             $fleetAlerts $cem->getRepository('CompanyGroupBundle\\Entity\\PlatformFleetAlert')
  114.                 ->findBy(array(), array('id' => 'DESC'), 10);
  115.         } catch (\Throwable $e) {}
  116.         $rows = array();
  117.         $counts = array('ok' => 0'warn' => 0'fail' => 0'unknown' => 0);
  118.         $oldest null;
  119.         foreach ($tenants as $appId => $t) {
  120.             $s = isset($snaps[$appId]) ? $snaps[$appId] : null;
  121.             $sc = isset($schema[$appId]) ? $schema[$appId] : null;
  122.             $collectedTs = ($s && $s->getCollectedAt() instanceof \DateTimeInterface) ? (int) $s->getCollectedAt()->format('U') : 0;
  123.             $facts = array(
  124.                 'collectedAtTs' => $collectedTs'collectError' => $s $s->getCollectError() : null,
  125.                 'tbBalanced' => $s $s->getTbBalanced() : null'tbDiff' => $s $s->getTbDiff() : null,
  126.                 'auditChainOk' => $s $s->getAuditChainOk() : null'auditChainBreaks' => $s ? (int) $s->getAuditChainBreaks() : 0,
  127.                 'unbalancedVouchers' => $s $s->getUnbalancedVouchers() : null,
  128.                 'markerUncovered' => $s $s->getMarkerUncovered() : null,
  129.                 'schemaStatus' => $sc $sc->getStatus() : null,
  130.                 'cronAgeSec' => $cronAgeSec'cronLastFailed' => $cronLastFailed,
  131.                 'subscriptionExpiryTs' => $s ? (int) $s->getSubscriptionExpiryTs() : 0'graceDays' => $graceDays,
  132.                 'activityLogRows' => $s $s->getActivityLogRows() : null,
  133.                 'jsErrorsToday' => $s $s->getJsErrorsToday() : null,
  134.                 'smokeChecked' => $s $s->getSmokeChecked() : null,
  135.                 'smokeFailed' => $s $s->getSmokeFailed() : null,
  136.                 'smokeAssertFail' => $s $s->getSmokeAssertFail() : null,
  137.             );
  138.             $v FleetHealthVerdict::evaluate($facts$now);
  139.             $counts[$v['overall']] = (isset($counts[$v['overall']]) ? $counts[$v['overall']] : 0) + 1;
  140.             if ($collectedTs && ($oldest === null || $collectedTs $oldest)) { $oldest $collectedTs; }
  141.             // FE3 — 7-day JS-error trend (collector-computed JSON; null pre-migration).
  142.             $jsTrend null;
  143.             if ($s && $s->getJsErrors7d()) {
  144.                 $decoded json_decode((string) $s->getJsErrors7d(), true);
  145.                 if (is_array($decoded) && isset($decoded['c']) && is_array($decoded['c']) && !empty($decoded['c'])) {
  146.                     $trendCounts array_map('intval'$decoded['c']);
  147.                     $jsTrend = array('counts' => $trendCounts,
  148.                         'dates' => isset($decoded['d']) && is_array($decoded['d']) ? $decoded['d'] : array(),
  149.                         'max' => max(1max($trendCounts)));
  150.                 }
  151.             }
  152.             $rows[] = array('t' => $t'verdict' => $v'dbSizeMb' => $s $s->getDbSizeMb() : null,
  153.                 'collectedTs' => $collectedTs'schemaVersion' => $sc substr((string) $sc->getCurrentVersion(), 010) : null,
  154.                 'jsTrend' => $jsTrend'jsToday' => $s $s->getJsErrorsToday() : null);
  155.         }
  156.         // Red-first: fail > warn > unknown > ok.
  157.         $rank = array('fail' => 0'warn' => 1'unknown' => 2'ok' => 3);
  158.         usort($rows, function ($a$b) use ($rank) {
  159.             $ra = isset($rank[$a['verdict']['overall']]) ? $rank[$a['verdict']['overall']] : 9;
  160.             $rb = isset($rank[$b['verdict']['overall']]) ? $rank[$b['verdict']['overall']] : 9;
  161.             if ($ra !== $rb) { return $ra $rb; }
  162.             return $a['t']['appId'] - $b['t']['appId'];
  163.         });
  164.         return $this->render('@CompanyGroup/pages/admin/fleet_health.html.twig', array(
  165.             'page_title' => 'Fleet Health',
  166.             'rows' => $rows,
  167.             'counts' => $counts,
  168.             'tenantCount' => count($tenants),
  169.             'oldestAgeSec' => $oldest ? ($now $oldest) : null,
  170.             'lastCron' => $lastCron,
  171.             'lastMigrate' => $lastMigrate,
  172.             'alerts' => $alerts,
  173.             'fleetAlerts' => $fleetAlerts,
  174.             'checkKeys' => array('tb' => 'TB''chain' => 'Chain''vouchers' => 'Vouchers''markers' => 'Markers',
  175.                 'schema' => 'Schema''cron' => 'Cron''subscription' => 'Subscription''smoke' => 'Smoke',
  176.                 'jserrors' => 'JS Err''activity' => 'Activity''snapshot' => 'Snapshot'),
  177.         ));
  178.     }
  179.     /** L4 — re-collect ONE tenant on demand (out-of-process, so the web connection is untouched). */
  180.     public function fleetHealthRefreshAction(Request $request$appId)
  181.     {
  182.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  183.         if ($systemType !== '_CENTRAL_' || !$this->canAccessSuperAdminDashboard($request)) {
  184.             return $this->redirectToRoute('dashboard');
  185.         }
  186.         try {
  187.             $projectDir $this->container->hasParameter('kernel.project_dir')
  188.                 ? $this->container->getParameter('kernel.project_dir') : dirname($this->container->getParameter('kernel.root_dir'));
  189.             $env $this->container->getParameter('kernel.environment');
  190.             $p = new \Symfony\Component\Process\Process(array(
  191.                 PHP_BINARY$projectDir '/bin/console''inno:for-each-tenant''inno:fleet-health-collect',
  192.                 '--only=' . (int) $appId'--env=' $env'--no-interaction'), $projectDirnullnull120);
  193.             $p->run();
  194.             $this->addFlash('success''Re-collected tenant ' . (int) $appId '.');
  195.         } catch (\Throwable $e) {
  196.             $this->addFlash('error''Refresh failed: ' $e->getMessage());
  197.         }
  198.         return $this->redirectToRoute('fleet_health');
  199.     }
  200.     public function companyViewAction(Request $request$appId)
  201.     {
  202.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  203.         if ($systemType !== '_CENTRAL_') {
  204.             return $this->redirectToRoute('dashboard');
  205.         }
  206.         if (!$this->canAccessSuperAdminDashboard($request)) {
  207.             return $this->redirectToRoute('dashboard');
  208.         }
  209.         $service $this->get('app.admin_dashboard_service');
  210.         $company $service->getCompanyByAppId((int)$appId);
  211.         if (!$company) {
  212.             throw $this->createNotFoundException('Company appId #' . (int)$appId ' not found.');
  213.         }
  214.         $em $this->getDoctrine()->getManager('company_group');
  215.         $companyEntity $em->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')->findOneBy(array(
  216.             'appId' => (int)$appId,
  217.         ));
  218.         $enabledModuleIds $companyEntity $this->parseCompanyModuleIdList($companyEntity->getEnabledModuleIdList()) : array();
  219.         if (empty($enabledModuleIds)) {
  220.             $enabledModuleIds $this->getDefaultEnabledCompanyModuleIds();
  221.         }
  222.         return $this->render('@CompanyGroup/pages/admin/companies/view_company.html.twig', array(
  223.             'page_title' => 'Company Details',
  224.             'company' => $company,
  225.             'company_entity' => $companyEntity,
  226.             'enabled_count' => count($enabledModuleIds),
  227.             'module_count' => count(ModuleConstant::$moduleList),
  228.         ));
  229.     }
  230.     private function canAccessSuperAdminDashboard(Request $request)
  231.     {
  232.         $session $request->getSession();
  233.         $userId = (int) $session->get(UserConstants::USER_ID0);
  234.         if ($userId <= 0) {
  235.             return false;
  236.         }
  237.         $userType = (int) $session->get(UserConstants::USER_TYPE0);
  238.         $isBuddybeeAdmin = (int) $session->get(UserConstants::IS_BUDDYBEE_ADMIN0);
  239.         $allModuleAccess = (int) $session->get(UserConstants::ALL_MODULE_ACCESS_FLAG0);
  240.         $allowedTypes = [
  241.             UserConstants::USER_TYPE_SYSTEM,
  242.             UserConstants::USER_TYPE_MANAGEMENT_USER,
  243.             UserConstants::USER_TYPE_GENERAL,
  244.         ];
  245.         return $isBuddybeeAdmin === || $allModuleAccess === || in_array($userType$allowedTypestrue);
  246.     }
  247.     private function countCompaniesForAdminList(array $filters)
  248.     {
  249.         $conn $this->getDoctrine()->getManager('company_group')->getConnection();
  250.         $where = array();
  251.         $params = array();
  252.         if (!empty($filters['search'])) {
  253.             $where[] = '(name LIKE :search OR CAST(app_id AS CHAR) LIKE :search OR email LIKE :search)';
  254.             $params['search'] = '%' $filters['search'] . '%';
  255.         }
  256.         if (!empty($filters['status'])) {
  257.             $where[] = 'company_status = :companyStatus';
  258.             $params['companyStatus'] = $filters['status'];
  259.         }
  260.         $sql 'SELECT COUNT(*) FROM company_group';
  261.         if (!empty($where)) {
  262.             $sql .= ' WHERE ' implode(' AND '$where);
  263.         }
  264.         return (int)$conn->fetchOne($sql$params);
  265.     }
  266.     private function getCompanyListSummary()
  267.     {
  268.         $conn $this->getDoctrine()->getManager('company_group')->getConnection();
  269.         return array(
  270.             'all' => (int)$conn->fetchOne('SELECT COUNT(*) FROM company_group'),
  271.             'active' => (int)$conn->fetchOne("SELECT COUNT(*) FROM company_group WHERE company_status = 'active'"),
  272.             'trial' => (int)$conn->fetchOne("SELECT COUNT(*) FROM company_group WHERE company_status = 'trial'"),
  273.             'suspended' => (int)$conn->fetchOne("SELECT COUNT(*) FROM company_group WHERE company_status = 'suspended'"),
  274.             'expired' => (int)$conn->fetchOne("SELECT COUNT(*) FROM company_group WHERE company_status = 'expired'"),
  275.             'enabled' => (int)$conn->fetchOne('SELECT COUNT(*) FROM company_group WHERE active = 1'),
  276.             'disabled' => (int)$conn->fetchOne('SELECT COUNT(*) FROM company_group WHERE active = 0'),
  277.         );
  278.     }
  279.     public function companySettingsAction(Request $request$appId)
  280.     {
  281.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  282.         if ($systemType !== '_CENTRAL_') {
  283.             return $this->redirectToRoute('dashboard');
  284.         }
  285.         if (!$this->canAccessSuperAdminDashboard($request)) {
  286.             return $this->redirectToRoute('dashboard');
  287.         }
  288.         $appId = (int)$appId;
  289.         $em $this->getDoctrine()->getManager('company_group');
  290.         $company $em->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')->findOneBy(array(
  291.             'appId' => $appId,
  292.         ));
  293.         if (!$company) {
  294.             throw $this->createNotFoundException('Company appId #' $appId ' not found.');
  295.         }
  296.         if ($request->isMethod('POST')) {
  297.             $company->setName($request->request->get('name'$company->getName()));
  298.             $company->setAddress($request->request->get('address'$company->getAddress()));
  299.             $company->setShippingAddress($request->request->get('shippingAddress'$company->getShippingAddress()));
  300.             $company->setBillingAddress($request->request->get('billingAddress'$company->getBillingAddress()));
  301.             $company->setMotto($request->request->get('motto'$company->getMotto()));
  302.             $company->setInvoiceFooter($request->request->get('invoiceFooter'$company->getInvoiceFooter()));
  303.             $company->setGeneralFooter($request->request->get('generalFooter'$company->getGeneralFooter()));
  304.             $company->setCompanyDescription($request->request->get('companyDescription'$company->getCompanyDescription()));
  305.             $company->setCompanyStatus($request->request->get('companyStatus'$company->getCompanyStatus()));
  306.             $company->setPackageType($request->request->get('packageType'$company->getPackageType()));
  307.             $company->setActive((int)$request->request->get('active'0));
  308.             $company->setReadOnlyMode((int)$request->request->get('readOnlyMode'0));
  309.             $company->setAdminUserAllowed((int)$request->request->get('adminUserAllowed'0));
  310.             $company->setUserAllowed((int)$request->request->get('userAllowed'0));
  311.             $company->setSubscriptionMonth((int)$request->request->get('subscriptionMonth'0));
  312.             $company->setCurrentSubscriptionPackageId((int)$request->request->get('currentSubscriptionPackageId'0));
  313.             $company->setBillingAmount((int)$request->request->get('billingAmount'0));
  314.             $usageValidUptoDate $this->dateFromForm($request->request->get('usageValidUptoDate'''));
  315.             $company->setUsageValidUptoDate($usageValidUptoDate);
  316.             $company->setUsageValidUptoDateTs($usageValidUptoDate $usageValidUptoDate->format('U') : 0);
  317.             $subscriptionExpiry $this->dateFromForm($request->request->get('subscriptionExpiry'''));
  318.             $company->setSubscriptionExpiry($subscriptionExpiry);
  319.             $moduleIds $request->request->get('moduleIds', array());
  320.             if (!is_array($moduleIds)) {
  321.                 $moduleIds = array();
  322.             }
  323.             $validModuleIds = array();
  324.             foreach (ModuleConstant::$moduleList as $module) {
  325.                 $validModuleIds[(int)$module['id']] = true;
  326.             }
  327.             $enabledModuleIds = array();
  328.             foreach ($moduleIds as $moduleId) {
  329.                 $moduleId = (int)$moduleId;
  330.                 if ($moduleId && isset($validModuleIds[$moduleId])) {
  331.                     $enabledModuleIds[$moduleId] = $moduleId;
  332.                 }
  333.             }
  334.             ksort($enabledModuleIds);
  335.             $company->setEnabledModuleIdList(implode(','array_values($enabledModuleIds)));
  336.             $em->flush();
  337.             $companySyncResult $this->syncCompanySettingsToErp($em$company);
  338.             $syncResult $this->forceCompanyRouteSync($company);
  339.             if ($companySyncResult['success'] && $syncResult['success']) {
  340.                 $this->addFlash('success''Company settings were saved and synced to ERP.');
  341.             } else {
  342.                 $this->addFlash('warning''Company settings were saved, but ERP sync needs attention. Company sync: ' $companySyncResult['message'] . ' Route sync: ' $syncResult['message']);
  343.             }
  344.             return $this->redirectToRoute('admin_company_settings', array(
  345.                 'appId' => $appId,
  346.             ));
  347.         }
  348.         $enabledModuleIds $this->parseCompanyModuleIdList($company->getEnabledModuleIdList());
  349.         if (empty($enabledModuleIds)) {
  350.             $enabledModuleIds $this->getDefaultEnabledCompanyModuleIds();
  351.         }
  352.         $enabledLookup array_fill_keys($enabledModuleIdstrue);
  353.         $groupedModules $this->buildGroupedModuleList();
  354.         return $this->render('@CompanyGroup/pages/admin/companies/module_settings.html.twig', array(
  355.             'page_title' => 'Company Settings',
  356.             'company' => $company,
  357.             'grouped_modules' => $groupedModules,
  358.             'enabled_lookup' => $enabledLookup,
  359.             'enabled_count' => count($enabledLookup),
  360.             'module_count' => count(ModuleConstant::$moduleList),
  361.         ));
  362.     }
  363.     public function companyModuleSettingsAction(Request $request$appId)
  364.     {
  365.         return $this->companySettingsAction($request$appId);
  366.     }
  367.     private function dateFromForm($value)
  368.     {
  369.         $value trim((string)$value);
  370.         if ($value === '') {
  371.             return null;
  372.         }
  373.         try {
  374.             return new \DateTime($value);
  375.         } catch (\Exception $e) {
  376.             return null;
  377.         }
  378.     }
  379.     private function syncCompanySettingsToErp($em$company)
  380.     {
  381.         $response MiscActions::updateCompanyToErpServer($em, (int)$company->getAppId(), $this->container->getParameter('kernel.root_dir'));
  382.         if (isset($response['success']) && $response['success'] === true) {
  383.             return array(
  384.                 'success' => true,
  385.                 'message' => isset($response['message']) ? $response['message'] : 'Synced.',
  386.             );
  387.         }
  388.         return array(
  389.             'success' => false,
  390.             'message' => isset($response['message']) ? $response['message'] : 'Company metadata sync was not confirmed.',
  391.         );
  392.     }
  393.     private function parseCompanyModuleIdList($moduleIdList)
  394.     {
  395.         $moduleIdList trim((string)$moduleIdList);
  396.         if ($moduleIdList === '') {
  397.             return array();
  398.         }
  399.         $decoded json_decode($moduleIdListtrue);
  400.         $rawList is_array($decoded) ? $decoded explode(','$moduleIdList);
  401.         $cleanList = array();
  402.         foreach ($rawList as $moduleId) {
  403.             $moduleId = (int)$moduleId;
  404.             if ($moduleId 0) {
  405.                 $cleanList[$moduleId] = $moduleId;
  406.             }
  407.         }
  408.         return array_values($cleanList);
  409.     }
  410.     private function getDefaultEnabledCompanyModuleIds()
  411.     {
  412.         $moduleIds = array();
  413.         foreach (ModuleConstant::$moduleList as $module) {
  414.             if ((int)(isset($module['defaultEnabledForCompany']) ? $module['defaultEnabledForCompany'] : 0) === 1) {
  415.                 $moduleIds[] = (int)$module['id'];
  416.             }
  417.         }
  418.         return $moduleIds;
  419.     }
  420.     private function buildGroupedModuleList()
  421.     {
  422.         $groups = array();
  423.         foreach (ModuleConstant::$parentModuleList as $parentModule) {
  424.             $groups[(int)$parentModule['id']] = array(
  425.                 'parent' => $parentModule,
  426.                 'modules' => array(),
  427.             );
  428.         }
  429.         foreach (ModuleConstant::$moduleList as $module) {
  430.             $parentId = (int)$module['parentId'];
  431.             if (!isset($groups[$parentId])) {
  432.                 $groups[$parentId] = array(
  433.                     'parent' => array(
  434.                         'id' => $parentId,
  435.                         'name' => 'Other',
  436.                     ),
  437.                     'modules' => array(),
  438.                 );
  439.             }
  440.             $groups[$parentId]['modules'][] = $module;
  441.         }
  442.         foreach ($groups as $parentId => $group) {
  443.             if (empty($group['modules'])) {
  444.                 unset($groups[$parentId]);
  445.             }
  446.         }
  447.         return $groups;
  448.     }
  449.     private function forceCompanyRouteSync($company)
  450.     {
  451.         $serverAddress rtrim((string)$company->getCompanyGroupServerAddress(), '/');
  452.         if ($serverAddress === '') {
  453.             return array(
  454.                 'success' => false,
  455.                 'message' => 'ERP server address is not configured.',
  456.             );
  457.         }
  458.         $curl curl_init();
  459.         curl_setopt_array($curl, array(
  460.             CURLOPT_RETURNTRANSFER => 1,
  461.             CURLOPT_POST => 1,
  462.             CURLOPT_URL => $serverAddress '/update_route_company_wise',
  463.             CURLOPT_CONNECTTIMEOUT => 10,
  464.             CURLOPT_SSL_VERIFYPEER => false,
  465.             CURLOPT_SSL_VERIFYHOST => false,
  466.             CURLOPT_POSTFIELDS => http_build_query(array(
  467.                 'appId' => (int)$company->getAppId(),
  468.             )),
  469.         ));
  470.         $response curl_exec($curl);
  471.         $error curl_error($curl);
  472.         curl_close($curl);
  473.         if ($error) {
  474.             return array(
  475.                 'success' => false,
  476.                 'message' => $error,
  477.             );
  478.         }
  479.         return array(
  480.             'success' => true,
  481.             'message' => (string)$response,
  482.         );
  483.     }
  484.     private function buildChartData(array $usageSummary)
  485.     {
  486.         $activityTrend $usageSummary['activity_trend'] ?? [];
  487.         $usageTrend $usageSummary['usage_trend'] ?? [];
  488.         $revenueTrend $usageSummary['revenue_trend'] ?? [];
  489.         $activityByDay = [];
  490.         foreach ($activityTrend as $row) {
  491.             $day = (string) ($row['day'] ?? '');
  492.             if ($day === '') {
  493.                 continue;
  494.             }
  495.             if (!isset($activityByDay[$day])) {
  496.                 $activityByDay[$day] = 0;
  497.             }
  498.             $activityByDay[$day] += (int) ($row['total'] ?? 0);
  499.         }
  500.         $usageByDay = [];
  501.         foreach ($usageTrend as $row) {
  502.             $day = (string) ($row['day'] ?? '');
  503.             if ($day === '') {
  504.                 continue;
  505.             }
  506.             if (!isset($usageByDay[$day])) {
  507.                 $usageByDay[$day] = 0;
  508.             }
  509.             $usageByDay[$day] += (int) ($row['total'] ?? 0);
  510.         }
  511.         $revenueByDay = [];
  512.         foreach ($revenueTrend as $row) {
  513.             $day = (string) ($row['day'] ?? '');
  514.             if ($day === '') {
  515.                 continue;
  516.             }
  517.             $revenueByDay[$day] = (float) ($row['total'] ?? 0);
  518.         }
  519.         $labels array_values(array_unique(array_merge(
  520.             array_keys($activityByDay),
  521.             array_keys($usageByDay),
  522.             array_keys($revenueByDay)
  523.         )));
  524.         sort($labels);
  525.         $activitySeries = [];
  526.         $usageSeries = [];
  527.         $revenueSeries = [];
  528.         foreach ($labels as $label) {
  529.             $activitySeries[] = (int) ($activityByDay[$label] ?? 0);
  530.             $usageSeries[] = (int) ($usageByDay[$label] ?? 0);
  531.             $revenueSeries[] = (float) ($revenueByDay[$label] ?? 0);
  532.         }
  533.         return [
  534.             'labels' => $labels,
  535.             'activity_series' => $activitySeries,
  536.             'usage_series' => $usageSeries,
  537.             'revenue_series' => $revenueSeries,
  538.         ];
  539.     }
  540.     // ── EB0-UI — Energy⇄Business Bridge monitor + site-link admin ────────────────────────────────
  541.     // "My sites ↔ bridge status" for the owner: watch events land, see Own vs Customer, and map an
  542.     // unlinked site to a tenant + customer without hand-typing ids. Read-only over the CENTRAL bridge
  543.     // tables (+ a tenant-scoped AccClients search for the customer picker). No business action here.
  544.     public function energyBridgeAction(Request $request)
  545.     {
  546.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  547.         if ($systemType !== '_CENTRAL_') { return $this->redirectToRoute('dashboard'); }
  548.         if (!$this->canAccessSuperAdminDashboard($request)) { return $this->redirectToRoute('dashboard'); }
  549.         $cem $this->getDoctrine()->getManager('company_group');
  550.         // Tenant directory (appId → name) for display + the picker.
  551.         $tenants = array();
  552.         try {
  553.             foreach ($cem->getRepository('CompanyGroupBundle\\Entity\\CompanyGroup')->findBy(array('active' => 1)) as $g) {
  554.                 if (!$g->getDbName()) { continue; }
  555.                 $tenants[(int) $g->getAppId()] = $g->getName();
  556.             }
  557.         } catch (\Throwable $e) { /* central unreachable */ }
  558.         // Site-link map + recent events. Pre-schema-safe: if the tables aren't migrated yet, degrade
  559.         // to an empty view with a notice rather than 500 (same discipline as the fleet-health page).
  560.         $links = array(); $events = array(); $schemaReady true;
  561.         try {
  562.             foreach ($cem->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeSiteLink')->findBy(array(), array('siteUid' => 'ASC')) as $l) {
  563.                 $links[(string) $l->getSiteUid()] = array(
  564.                     'siteUid' => $l->getSiteUid(), 'appId' => (int) $l->getAppId(),
  565.                     'tenant' => isset($tenants[(int) $l->getAppId()]) ? $tenants[(int) $l->getAppId()] : ('app ' $l->getAppId()),
  566.                     'linkType' => $l->getLinkType() ?: 'self''customerId' => $l->getCustomerId(),
  567.                     'projectId' => $l->getProjectId(), 'note' => $l->getNote(),
  568.                 );
  569.             }
  570.             $qb $cem->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeEvent')
  571.                 ->createQueryBuilder('e')->orderBy('e.id''DESC')->setMaxResults(60);
  572.             foreach ($qb->getQuery()->getResult() as $e) {
  573.                 $events[] = array(
  574.                     'eventId' => $e->getEventId(), 'eventType' => $e->getEventType(),
  575.                     'siteUid' => $e->getSiteUid(), 'appId' => (int) $e->getAppId(),
  576.                     'tenant' => $e->getAppId() && isset($tenants[(int) $e->getAppId()]) ? $tenants[(int) $e->getAppId()] : null,
  577.                     'linkType' => $e->getLinkType(), 'status' => $e->getStatus(),
  578.                     'receivedAt' => $e->getReceivedAt(), 'correlationId' => $e->getCorrelationId(),
  579.                 );
  580.             }
  581.         } catch (\Throwable $e) { $schemaReady false; }
  582.         // KPIs.
  583.         $today = (new \DateTime())->format('Y-m-d');
  584.         $kpi = array('total' => count($events), 'unlinked' => 0'pending' => 0'today' => 0'lastReceived' => null'linkedSites' => count($links));
  585.         $unlinkedSites = array();
  586.         foreach ($events as $e) {
  587.             if ($e['status'] === 'unlinked') {
  588.                 $kpi['unlinked']++;
  589.                 if ($e['siteUid'] !== null && $e['siteUid'] !== '' && !isset($links[(string) $e['siteUid']])) {
  590.                     $unlinkedSites[(string) $e['siteUid']] = true;
  591.                 }
  592.             }
  593.             if ($e['status'] === 'pending') { $kpi['pending']++; }
  594.             if ($e['receivedAt'] instanceof \DateTime) {
  595.                 if ($kpi['lastReceived'] === null || $e['receivedAt'] > $kpi['lastReceived']) { $kpi['lastReceived'] = $e['receivedAt']; }
  596.                 if ($e['receivedAt']->format('Y-m-d') === $today) { $kpi['today']++; }
  597.             }
  598.         }
  599.         return $this->render('@CompanyGroup/pages/admin/energy_bridge.html.twig', array(
  600.             'configured' => EbBridgeConfig::isConfigured(),
  601.             'schemaReady' => $schemaReady,
  602.             'tenants' => $tenants,
  603.             'links' => array_values($links),
  604.             'events' => $events,
  605.             'kpi' => $kpi,
  606.             'unlinkedSites' => array_keys($unlinkedSites),
  607.         ));
  608.     }
  609.     /** Create/update a site-link from the admin form (mirrors inno:energy-site-link). */
  610.     public function energyBridgeLinkAction(Request $request)
  611.     {
  612.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  613.         if ($systemType !== '_CENTRAL_') { return $this->redirectToRoute('dashboard'); }
  614.         if (!$this->canAccessSuperAdminDashboard($request)) { return $this->redirectToRoute('dashboard'); }
  615.         $siteUid trim((string) $request->request->get('siteUid'''));
  616.         $appId = (int) $request->request->get('appId'0);
  617.         $customerId = (int) $request->request->get('customerId'0);
  618.         $projectId = (int) $request->request->get('projectId'0);
  619.         $note trim((string) $request->request->get('note'''));
  620.         if ($siteUid === '' || $appId <= 0) {
  621.             $this->addFlash('error''Site UID and tenant are required to map a site.');
  622.             return $this->redirectToRoute('energy_bridge_monitor');
  623.         }
  624.         try {
  625.             $cem $this->getDoctrine()->getManager('company_group');
  626.             $link $cem->getRepository('CompanyGroupBundle\\Entity\\EnergyBridgeSiteLink')->findOneBy(array('siteUid' => $siteUid));
  627.             $now = new \DateTime();
  628.             if ($link === null) {
  629.                 $link = new EnergyBridgeSiteLink();
  630.                 $link->setSiteUid($siteUid);
  631.                 $link->setCreatedAt($now);
  632.             }
  633.             $link->setAppId($appId);
  634.             $link->setCustomerId($customerId $customerId null);
  635.             $link->setProjectId($projectId $projectId null);
  636.             $link->setNote($note !== '' $note null);
  637.             // A customer chosen → this is a client PPA site; none → our own site.
  638.             $link->setLinkType(EbBridgeVerifier::linkTypeForCustomer($customerId));
  639.             $link->setUpdatedAt($now);
  640.             $cem->persist($link);
  641.             $cem->flush();
  642.             $this->addFlash('success'sprintf('Mapped site %s → app %d (%s).'$siteUid$appId,
  643.                 $link->getLinkType() === 'customer' 'customer' 'own site'));
  644.         } catch (\Throwable $e) {
  645.             $this->addFlash('error''Could not save the site link (is the central schema migrated?): ' $e->getMessage());
  646.         }
  647.         return $this->redirectToRoute('energy_bridge_monitor');
  648.     }
  649.     /**
  650.      * AJAX: search a specific tenant's customers (AccClients) so the owner never hand-types a
  651.      * customer id. Switches the default connection to that tenant for this request only.
  652.      */
  653.     public function energyBridgeCustomerSearchAction(Request $request)
  654.     {
  655.         $systemType $this->container->hasParameter('system_type') ? $this->container->getParameter('system_type') : '_ERP_';
  656.         if ($systemType !== '_CENTRAL_' || !$this->canAccessSuperAdminDashboard($request)) {
  657.             return new JsonResponse(array('results' => array(), 'error' => 'forbidden'), 403);
  658.         }
  659.         $appId = (int) $request->query->get('appId'0);
  660.         $q trim((string) $request->query->get('q'''));
  661.         if ($appId <= 0) { return new JsonResponse(array('results' => array())); }
  662.         try {
  663.             $cfg $this->get('app.tenant_db_config_resolver')->resolveByAppId($appId);
  664.             $this->get('application_connector')->resetConnection('default'$cfg['dbName'], $cfg['dbUser'], $cfg['dbPassword'], $cfg['dbHost'], true);
  665.             $conn $this->getDoctrine()->getManager()->getConnection();
  666.             $rows $conn->fetchAll(
  667.                 'SELECT client_id, client_name FROM acc_clients WHERE client_name LIKE :q ORDER BY client_name LIMIT 20',
  668.                 array('q' => '%' $q '%'));
  669.             $results = array();
  670.             foreach ($rows as $r) {
  671.                 $results[] = array('id' => (int) $r['client_id'], 'name' => (string) $r['client_name']);
  672.             }
  673.             return new JsonResponse(array('results' => $results));
  674.         } catch (\Throwable $e) {
  675.             return new JsonResponse(array('results' => array(), 'error' => 'tenant unreachable'));
  676.         }
  677.     }
  678. }