src/ApplicationBundle/Modules/Readiness/Controller/CompanyReadinessController.php line 63

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Modules\Readiness\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Interfaces\SessionCheckInterface;
  5. use ApplicationBundle\Modules\Accounts\Service\ConfigReadinessService;
  6. use ApplicationBundle\Modules\Readiness\Service\ReadinessStatsService;
  7. use ApplicationBundle\Modules\Readiness\Support\ReadinessChecklistCore;
  8. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  9. use Symfony\Component\HttpFoundation\RedirectResponse;
  10. use Symfony\Component\HttpFoundation\Request;
  11. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  12. /**
  13.  * Company / Implementation Readiness — the "Migration Hub" command center (cp-shell).
  14.  * Screens are ported from the Stitch "Enterprise Success Command Center" design. One
  15.  * generic page action renders pages/<slug>.html.twig from the SCREENS whitelist; the
  16.  * shared cp-shell layout builds the grouped sidebar from this same manifest.
  17.  */
  18. class CompanyReadinessController extends GenericController implements SessionCheckInterface
  19. {
  20.     /** slug => [title, group, icon]. Order within a group = sidebar order. */
  21.     const SCREENS = [
  22.         // Journey
  23.         'command-center'      => ['Command Center',        'Journey''dashboard'],
  24.         'project-hub'         => ['Project Hub',           'Journey''account_tree'],
  25.         'project-timeline'    => ['Project Timeline',      'Journey''timeline'],
  26.         'rollout-journey'     => ['13-Stage Journey',      'Journey''map'],
  27.         'my-actions'          => ['My Actions',            'Journey''inbox'],
  28.         // Setup & Migration
  29.         'scope'               => ['Scope & Commercials',   'Setup & Migration''assignment'],
  30.         'master-data'         => ['Master Data Readiness''Setup & Migration''database'],
  31.         'ai-migration'        => ['AI / OCR Migration',    'Setup & Migration''document_scanner'],
  32.         'document-adoption'   => ['Document Adoption',     'Setup & Migration''description'],
  33.         'resource-vault'      => ['Resource Vault',        'Setup & Migration''folder_open'],
  34.         // Readiness
  35.         'go-live'             => ['Go-Live Readiness',     'Readiness''verified'],
  36.         'cutover'             => ['Cutover Checklist',     'Readiness''checklist'],
  37.         'uat-punch-list'      => ['UAT Punch List',        'Readiness''bug_report'],
  38.         'rollout-health'      => ['Dependency Health',     'Readiness''health_and_safety'],
  39.         'commercial-audit'    => ['Commercial Audit',      'Readiness''receipt_long'],
  40.         // Adoption
  41.         'hypercare'           => ['Hypercare & Adoption',  'Adoption''support_agent'],
  42.         'operational-adoption'=> ['Operational Adoption',  'Adoption''insights'],
  43.         // Enablement
  44.         'erp-mastery'         => ['ERP Mastery',           'Enablement''school'],
  45.         'module-mastery'      => ['Module Mastery',        'Enablement''grid_view'],
  46.         'workshops'           => ['Workshops & Support',   'Enablement''groups'],
  47.         'certification'       => ['Certification & Rewards','Enablement''workspace_premium'],
  48.         // Executive
  49.         'executive'           => ['Executive Center',      'Executive''leaderboard'],
  50.         'pmo'                 => ['Implementation PMO',     'Executive''engineering'],
  51.         'digital-maturity'    => ['Digital Maturity',      'Executive''trending_up'],
  52.         'closure'             => ['Closure & Sign-off',    'Executive''task_alt'],
  53.         'client-handover'     => ['Client Handover',       'Executive''handshake'],
  54.         // Admin
  55.         'team-access'         => ['Team Access & Roles',   'Admin''admin_panel_settings'],
  56.         // GA3 — the five-state capability map (derived on read, never persisted)
  57.         'capability-map'      => ['Capability Map',        'Executive''map'],
  58.     ];
  59.     public function indexAction(Request $request): RedirectResponse
  60.     {
  61.         return $this->redirect($this->generateUrl('company_readiness_page', ['slug' => 'command-center']));
  62.     }
  63.     /** Generic screen renderer. Extra live data is injected per-slug where it maps. */
  64.     public function pageAction(Request $requeststring $slug)
  65.     {
  66.         if (!isset(self::SCREENS[$slug])) {
  67.             throw new NotFoundHttpException('Unknown readiness screen.');
  68.         }
  69.         [$title$group] = self::SCREENS[$slug];
  70.         $extra = [];
  71.         if ($slug === 'capability-map') {
  72.             // GA3: real signals -> the five honest states; readiness gaps ride from GA1
  73.             $em $this->getDoctrine()->getManager();
  74.             $cm ReadinessStatsService::checklistMetrics($em);
  75.             $items ReadinessChecklistCore::derive($cm['metrics'], $cm['country']);
  76.             $rows = \ApplicationBundle\Modules\Readiness\Support\CapabilityMapCore::derive(
  77.                 ReadinessStatsService::capabilityMetrics($em), $items);
  78.             $extra = [
  79.                 'cap_rows' => $rows,
  80.                 'cap_groups' => array_reduce($rows, function ($acc$r) { $acc[$r['group']][] = $r; return $acc; }, []),
  81.                 'cap_summary' => \ApplicationBundle\Modules\Readiness\Support\CapabilityMapCore::summarize($rows),
  82.             ];
  83.         } elseif ($slug === 'go-live') {
  84.             // Wire the Go-Live readiness gauge + blockers to the real config audit.
  85.             $audit ConfigReadinessService::audit($this->getDoctrine()->getManager());
  86.             $total max(1count($audit['findings']));
  87.             $techCats = ['Control heads''Tax''Sales''Period close'];
  88.             $ok 0$tT 0$tO 0$okT 0$okO 0$blockers = [];
  89.             foreach ($audit['findings'] as $f) {
  90.                 $isTech in_array($f['category'], $techCatstrue);
  91.                 if ($isTech) { $tT++; } else { $tO++; }
  92.                 if ($f['status'] === 'ok') { $ok++; $isTech $okT++ : $okO++; }
  93.                 else { $blockers[] = ['label' => $f['label'], 'detail' => $f['blocks'], 'critical' => $f['severity'] === 'critical']; }
  94.             }
  95.             usort($blockers, function ($a$b) { return $b['critical'] <=> $a['critical']; });
  96.             // Per-area readiness (replaces the illustrative "7-day trend" with a real breakdown).
  97.             $byCat = [];
  98.             foreach ($audit['findings'] as $f) {
  99.                 $c $f['category'] ?: 'Other';
  100.                 if (!isset($byCat[$c])) { $byCat[$c] = ['ok' => 0'total' => 0]; }
  101.                 $byCat[$c]['total']++;
  102.                 if ($f['status'] === 'ok') { $byCat[$c]['ok']++; }
  103.             }
  104.             $areas = [];
  105.             foreach ($byCat as $c => $v) {
  106.                 $areas[] = ['name' => $c'pct' => $v['total'] ? (int) round($v['ok'] / $v['total'] * 100) : 100];
  107.             }
  108.             $extra = [
  109.                 'readinessIndex' => (int) round($ok $total 100),
  110.                 'technical'      => $tT ? (int) round($okT $tT 100) : 100,
  111.                 'operational'    => $tO ? (int) round($okO $tO 100) : 100,
  112.                 'blockers'       => $blockers,
  113.                 'critical'       => $audit['critical'],
  114.                 'areas'          => $areas,
  115.                 'findingCount'   => count($audit['findings']),
  116.                 'okCount'        => $ok,
  117.             ];
  118.         } elseif ($slug === 'closure' || $slug === 'project-timeline') {
  119.             // ERP-018/019 fix: these two screens had NO $extra branch, so their templates hit
  120.             // strict_variables undefined-var 500s (closure -> rd/ok/findings; project-timeline ->
  121.             // rd_index/rd_areas/rd_blockers). Wire them to the SAME real config audit that the
  122.             // go-live/command-center screens already use, in the same shapes.
  123.             $audit ConfigReadinessService::audit($this->getDoctrine()->getManager());
  124.             $findingsArr $audit['findings'];
  125.             $total max(1count($findingsArr));
  126.             $okCnt 0$blk = []; $byCat = [];
  127.             foreach ($findingsArr as $f) {
  128.                 $c $f['category'] ?: 'Other';
  129.                 if (!isset($byCat[$c])) { $byCat[$c] = ['ok' => 0'total' => 0]; }
  130.                 $byCat[$c]['total']++;
  131.                 if ($f['status'] === 'ok') { $okCnt++; $byCat[$c]['ok']++; }
  132.                 else { $blk[] = ['label' => $f['label'], 'detail' => $f['blocks'], 'critical' => $f['severity'] === 'critical']; }
  133.             }
  134.             usort($blk, function ($a$b) { return $b['critical'] <=> $a['critical']; });
  135.             $areas = [];
  136.             foreach ($byCat as $c => $v) { $areas[] = ['name' => $c'pct' => $v['total'] ? (int) round($v['ok'] / $v['total'] * 100) : 100]; }
  137.             $idx = (int) round($okCnt $total 100);
  138.             $fCnt count($findingsArr);
  139.             // closure.html.twig sets ok/findings/critical/openItems via {% set %} at the top, but
  140.             // those don't reach its insights section (separate Twig scope) — pass them explicitly.
  141.             $extra = [
  142.                 'rd'          => ['index' => $idx'ok' => $okCnt'findings' => $fCnt'critical' => $audit['critical'], 'blockers' => $blk'areas' => $areas],
  143.                 'ok'          => $okCnt,
  144.                 'findings'    => $fCnt,
  145.                 'critical'    => $audit['critical'],
  146.                 'openItems'   => count($blk),
  147.                 'rd_index'    => $idx,
  148.                 'rd_areas'    => $areas,
  149.                 'rd_blockers' => $blk,
  150.             ];
  151.         } elseif ($slug === 'master-data') {
  152.             // Wire the master-data pipeline to real record counts. A category with records
  153.             // is treated as fully ingested (6/6 steps); an empty one as not-started.
  154.             $conn $this->getDoctrine()->getManager()->getConnection();
  155.             $cnt  = function ($sql) use ($conn) { try { return (int) $conn->fetchOne($sql); } catch (\Throwable $e) { return 0; } };
  156.             $defs = [
  157.                 ['Item Group',        "SELECT COUNT(*) FROM inv_item_group WHERE (status=1 OR status IS NULL) AND name <> 'Uncategorized'"],
  158.                 ['Product',           "SELECT COUNT(*) FROM inv_products WHERE (status=1 OR status IS NULL)"],
  159.                 ['Client / Customer'"SELECT COUNT(*) FROM acc_clients WHERE (delete_flag=0 OR delete_flag IS NULL)"],
  160.                 ['Supplier',          "SELECT COUNT(*) FROM acc_suppliers WHERE (delete_flag=0 OR delete_flag IS NULL)"],
  161.                 ['Chart of Accounts'"SELECT COUNT(*) FROM acc_accounts_head WHERE (delete_flag=0 OR delete_flag IS NULL)"],
  162.                 ['Tax Codes',         "SELECT COUNT(*) FROM tax_config"],
  163.             ];
  164.             $cats = []; $done 0;
  165.             foreach ($defs as [$name$sql]) {
  166.                 $c $cnt($sql);
  167.                 $cats[] = ['name' => $name'count' => $c'state' => $c 'done' 'todo''steps' => $c 0];
  168.                 if ($c 0) { $done++; }
  169.             }
  170.             $extra = ['mdCategories' => $cats'mdDone' => $done'mdTotal' => count($cats)];
  171.         } elseif ($slug === 'my-actions') {
  172.             // Wire the action inbox to the logged-in user's open tasks (planning_item).
  173.             $conn $this->getDoctrine()->getManager()->getConnection();
  174.             $uid  = (int) $request->getSession()->get(UserConstants::USER_ID);
  175.             $rows = [];
  176.             try {
  177.                 $rows $conn->fetchAllAssociative(
  178.                     "SELECT id, item_alias, task_status,
  179.                             estimated_completion_time_ts AS due_ts, project_id,
  180.                             COALESCE(completion_percentage,0) AS pct
  181.                      FROM planning_item
  182.                      WHERE (delete_flag = 0 OR delete_flag IS NULL)
  183.                        AND (assigned_to = ? OR assigned_to_ids LIKE ?)
  184.                        AND (completion_percentage IS NULL OR completion_percentage < 100)
  185.                      ORDER BY estimated_completion_time_ts IS NULL, estimated_completion_time_ts ASC
  186.                      LIMIT 40",
  187.                     [$uid'%"' $uid '"%']
  188.                 );
  189.             } catch (\Throwable $e) { $rows = []; }
  190.             $now time();
  191.             $actions array_map(function ($r) use ($now) {
  192.                 $due = (int) $r['due_ts']; $label 'No date'$overdue false;
  193.                 if ($due 0) {
  194.                     $d = (int) floor(($due $now) / 86400);
  195.                     if ($d 0)      { $label 'Overdue'$overdue true; }
  196.                     elseif ($d === 0){ $label 'Today'; }
  197.                     elseif ($d === 1){ $label 'Tomorrow'; }
  198.                     else             { $label 'In ' $d ' days'; }
  199.                 }
  200.                 return [
  201.                     'id'      => (int) $r['id'],
  202.                     'title'   => $r['item_alias'] ?: ('Task #' $r['id']),
  203.                     'status'  => $r['task_status'] ?: 'open',
  204.                     'percent' => (float) $r['pct'],
  205.                     'due'     => $label,
  206.                     'overdue' => $overdue,
  207.                     'project' => (int) $r['project_id'],
  208.                 ];
  209.             }, $rows);
  210.             $urgent $actions[0] ?? null;
  211.             $extra = [
  212.                 'urgent'      => $urgent,
  213.                 'actions'     => $urgent array_slice($actions1) : $actions,
  214.                 'actionCount' => count($actions),
  215.             ];
  216.         } elseif ($slug === 'command-center') {
  217.             // Home KPIs: setup-readiness % + critical blockers (config audit) + my open tasks.
  218.             $em    $this->getDoctrine()->getManager();
  219.             $audit ConfigReadinessService::audit($em);
  220.             $total max(1count($audit['findings'])); $ok 0$blk = [];
  221.             foreach ($audit['findings'] as $f) {
  222.                 if ($f['status'] === 'ok') { $ok++; }
  223.                 elseif ($f['severity'] === 'critical') { $blk[] = ['label' => $f['label'], 'detail' => $f['blocks']]; }
  224.             }
  225.             $conn $em->getConnection();
  226.             $uid  = (int) $request->getSession()->get(UserConstants::USER_ID);
  227.             $tasks = []; $taskCount 0;
  228.             try {
  229.                 $where "(delete_flag=0 OR delete_flag IS NULL) AND (assigned_to=? OR assigned_to_ids LIKE ?) AND (completion_percentage IS NULL OR completion_percentage<100)";
  230.                 $taskCount = (int) $conn->fetchOne("SELECT COUNT(*) FROM planning_item WHERE $where", [$uid'%"' $uid '"%']);
  231.                 $tasks $conn->fetchAllAssociative("SELECT id, item_alias, project_id FROM planning_item WHERE $where ORDER BY estimated_completion_time_ts IS NULL, estimated_completion_time_ts ASC LIMIT 3", [$uid'%"' $uid '"%']);
  232.             } catch (\Throwable $e) { $tasks = []; }
  233.             $extra = [
  234.                 'readinessIndex' => (int) round($ok $total 100),
  235.                 'blockers'       => array_slice($blk03),
  236.                 'blockerCount'   => count($blk),
  237.                 'tasks'          => $tasks,
  238.                 'taskCount'      => $taskCount,
  239.             ];
  240.         } elseif ($slug === 'team-access') {
  241.             // Wire the team directory to real users (sys_user) + roles (sys_department_position)
  242.             // + last login (sys_login_log).
  243.             $conn $this->getDoctrine()->getManager()->getConnection();
  244.             $members = []; $tot 0$activeToday 0$ready 0$noRole 0$inactive 0$roleCount 0;
  245.             try {
  246.                 $positions = [];
  247.                 foreach ($conn->fetchAllAssociative("SELECT position_id, position_name FROM sys_department_position") as $p) {
  248.                     $positions[(int) $p['position_id']] = $p['position_name'];
  249.                 }
  250.                 $roleCount count($positions);
  251.                 $lastLogin = [];
  252.                 foreach ($conn->fetchAllAssociative("SELECT user_id, MAX(log_time) AS lt FROM sys_login_log GROUP BY user_id") as $l) {
  253.                     $lastLogin[(int) $l['user_id']] = $l['lt'];
  254.                 }
  255.                 $rows $conn->fetchAllAssociative("SELECT user_id, name, email, user_name, position_ids, all_module_access_flag, status FROM sys_user ORDER BY user_id ASC");
  256.                 $tot count($rows);
  257.                 $now time();
  258.                 foreach ($rows as $r) {
  259.                     $uid = (int) $r['user_id'];
  260.                     $posName '—';
  261.                     $pids json_decode($r['position_ids'] ?: '[]'true);
  262.                     if (is_array($pids)) {
  263.                         foreach ($pids as $pid) {
  264.                             if ((int) $pid && isset($positions[(int) $pid])) { $posName $positions[(int) $pid]; break; }
  265.                         }
  266.                     }
  267.                     $ltLabel 'Never'$online false;
  268.                     if (!empty($lastLogin[$uid])) {
  269.                         $ts strtotime($lastLogin[$uid]); $diff $now $ts;
  270.                         if ($diff 900)        { $ltLabel 'Online'$online true; }
  271.                         elseif ($diff 3600)   { $ltLabel = (int) floor($diff 60) . 'm ago'; }
  272.                         elseif ($diff 86400)  { $ltLabel = (int) floor($diff 3600) . 'h ago'; }
  273.                         else                    { $ltLabel = (int) floor($diff 86400) . 'd ago'; }
  274.                         if ($diff 86400) { $activeToday++; }
  275.                     }
  276.                     $isActive = ((int) $r['status'] !== 0);
  277.                     if ($isActive) { $ready++; } else { $inactive++; }
  278.                     if ((int) $r['all_module_access_flag'] === 1) { $access 'Full Access'$accessPill 'info'; }
  279.                     elseif (!$isActive)        { $access 'Inactive'$accessPill 'err'; }
  280.                     elseif ($posName === '—')  { $access 'No Role'$accessPill 'warn'$noRole++; }
  281.                     else                       { $access 'Standard'$accessPill ''; }
  282.                     $name $r['name'] ?: ($r['user_name'] ?: ('User #' $uid));
  283.                     $ini '';
  284.                     foreach (preg_split('/\s+/'trim($name)) as $w) { if ($w !== '') { $ini .= strtoupper($w[0]); } }
  285.                     $ini substr($ini02) ?: 'U';
  286.                     $members[] = [
  287.                         'name' => $name'email' => $r['email'] ?: '—''role' => $posName,
  288.                         'access' => $access'accessPill' => $accessPill'last' => $ltLabel,
  289.                         'online' => $online'initials' => $ini'inactive' => !$isActive,
  290.                     ];
  291.                 }
  292.             } catch (\Throwable $e) { $members = []; }
  293.             $extra = [
  294.                 'members'     => $members,
  295.                 'memberTotal' => $tot,
  296.                 'activeToday' => $activeToday,
  297.                 'teamReady'   => $tot ? (int) round($ready $tot 100) : 0,
  298.                 'roleCount'   => $roleCount,
  299.                 'noRoleCount' => $noRole,
  300.                 'inactiveCount' => $inactive,
  301.             ];
  302.         }
  303.         // GA1 — Gap→Action checklist (deterministic core over null-honest metrics) on the
  304.         // three actionable screens. States are complete/incomplete/not_tracked — a metric the
  305.         // service cannot compute renders "not tracked", never fake-complete and never fake-zero.
  306.         if (in_array($slug, ['master-data''go-live''command-center'], true)) {
  307.             try {
  308.                 $cm    ReadinessStatsService::checklistMetrics($this->getDoctrine()->getManager());
  309.                 $items ReadinessChecklistCore::derive($cm['metrics'], $cm['country']);
  310.                 $extra += [
  311.                     'checklist_groups'  => ReadinessChecklistCore::grouped($items),
  312.                     'checklist_summary' => ReadinessChecklistCore::summarize($items),
  313.                     'checklist_prereqs' => ReadinessChecklistCore::firstValuePrereqs($items),
  314.                     'checklist_country' => $cm['country'],
  315.                 ];
  316.             } catch (\Throwable $e) {
  317.                 // A readiness screen must never 500 — the partial defaults to empty.
  318.             }
  319.         }
  320.         // Shared bag of REAL ERP signals — available to every screen as `stats.*`.
  321.         $uid   = (int) $request->getSession()->get(UserConstants::USER_ID);
  322.         $stats ReadinessStatsService::compute($this->getDoctrine()->getManager(), $uid);
  323.         // Every screen now reads live ERP data via `stats` (widgets with no ERP source are
  324.         // labelled "Not tracked yet" inline), so the page-level illustrative banner is retired.
  325.         return $this->render('@Readiness/pages/' $slug '.html.twig'array_merge([
  326.             'screens'      => self::SCREENS,
  327.             'active'       => $slug,
  328.             'page_title'   => $title,
  329.             'group'        => $group,
  330.             'stats'        => $stats,
  331.             'illustrative' => false,
  332.         ], $extra));
  333.     }
  334. }