['email' => null, 'mobile' => null], 'verdict' => 'WILL_FAIL', 'verdict_message' => 'Email or mobile number is required.', 'problems' => [[ 'severity' => 'high', 'code' => 'INPUT_REQUIRED', 'message' => 'Provide an email or mobile number.', 'fix' => 'Enter email_corporate or mobile used at login.', ]], 'pre' => null, 'post' => null, 'merge' => null, 'connectivity' => null, ]; } $connectivity = self::checkPostConnectivity(); $pre = self::analyzeSide('pre', $email, $mobile); $post = self::analyzeSide('post', $email, $mobile, $connectivity); [$pre, $post] = self::crossLinkSelfDuplicates($pre, $post); $merge = self::simulateMerge($pre, $post); $problems = self::buildProblems($email, $mobile, $pre, $post, $merge, $connectivity); $verdict = self::buildVerdict($pre, $post, $merge, $problems); return [ 'input' => ['email' => $email, 'mobile' => $mobile], 'verdict' => $verdict['status'], 'verdict_message' => $verdict['message'], 'problems' => $problems, 'pre' => $pre, 'post' => $post, 'merge' => $merge, 'connectivity' => $connectivity, ]; } private static function normalize(?string $value): ?string { $value = trim((string) $value); return $value === '' ? null : $value; } private static function checkPostConnectivity(): array { $baseUrl = rtrim((string) env('POST_ENROLLMENT_BASEURL'), '/') . '/'; $url = $baseUrl . 'getPostEmployeeDataForAuth'; $result = [ 'base_url' => $baseUrl, 'api_reachable' => false, 'api_http_code' => null, 'api_message' => null, 'post_db' => false, 'post_db_message'=> null, 'app_signature' => ! empty(getenv('APP_SIGNATURE')), ]; try { $db = Database::connect('postDB'); $db->query('SELECT 1'); $result['post_db'] = true; } catch (\Throwable $e) { $result['post_db_message'] = $e->getMessage(); } try { $client = \Config\Services::curlrequest(['timeout' => 8]); $response = $client->post($url, [ 'json' => ['email_id' => '__login_diagnostic_probe__'], 'headers' => ['App-Signature' => (string) getenv('APP_SIGNATURE')], 'http_errors' => false, ]); $code = $response->getStatusCode(); $result['api_http_code'] = $code; $result['api_reachable'] = $code > 0 && $code < 500; if ($code === 403) { $result['api_message'] = 'Forbidden — App-Signature may be mismatched.'; $result['api_reachable'] = false; } else { $result['api_message'] = 'POST auth API responded.'; } } catch (\Throwable $e) { $result['api_message'] = $e->getMessage(); } return $result; } private static function analyzeSide(string $side, ?string $email, ?string $mobile, ?array $connectivity = null): array { $out = [ 'side' => $side, 'raw_matches' => [], 'raw_by_personal' => [], 'eligible' => null, 'eligible_via_api' => null, 'filters_summary' => [], 'client_short_name' => null, 'client_id' => null, 'token' => null, 'credentials' => null, 'duplicates' => null, 'policies' => [], 'error' => null, ]; try { if ($side === 'pre') { $db = (new EmployeeModel())->db; $statusEmp = ['draft', 'enrolled']; $statusPol = ['draft', 'enrolled']; } else { if ($connectivity && empty($connectivity['post_db'])) { $out['error'] = $connectivity['post_db_message'] ?? 'postDB unavailable'; // Still try login-shaped API for eligible $out['eligible_via_api'] = self::fetchPostEligibleViaApi($email, $mobile); if (! empty($out['eligible_via_api'])) { $out['eligible'] = $out['eligible_via_api']; $out['client_short_name'] = $out['eligible']['client_short_name'] ?? null; $out['client_id'] = $out['eligible']['client_id'] ?? null; } return $out; } $db = Database::connect('postDB'); $statusEmp = ['active', 'expired']; $statusPol = ['active', 'expired']; } $raw = self::fetchRawMatches($db, $email, $mobile); $out['raw_matches'] = $raw; if ($email !== null) { $out['raw_by_personal'] = self::fetchByPersonalEmail($db, $email); } $eligibleRows = self::fetchEligibleMatches($db, $email, $mobile, $statusEmp, $statusPol); $out['filters_summary'] = self::scoreFilters($raw, $statusEmp, $statusPol); if (! empty($eligibleRows)) { $winner = $eligibleRows[0]; $client = self::fetchClient($db, (int) ($winner['client_id'] ?? 0), $side); $winner['client_short_name'] = $client['short_name'] ?? ''; $winner['client_name'] = $client['client_name'] ?? ''; $winner['client_is_active'] = $client['is_active'] ?? null; $out['credentials'] = [ 'otp_set' => ! empty($winner['otp']), 'mpin_set' => isset($winner['mpin']) && $winner['mpin'] !== null && $winner['mpin'] !== '', 'password_set' => ! empty($winner['password']), 'biometric_set' => self::isBiometricEnabled($winner['is_biometric_enabled'] ?? null), 'mpin_skipped' => self::isTruthyFlag($winner['is_mpin_skipped'] ?? null), 'is_biometric_enabled' => $winner['is_biometric_enabled'] ?? null, 'is_mpin_skipped' => $winner['is_mpin_skipped'] ?? null, ]; $out['eligible'] = self::publicEmployeeRow($winner); $out['client_short_name'] = $winner['client_short_name']; $out['client_id'] = $winner['client_id'] ?? null; $out['token'] = self::tokenStatus($winner['token_time_out'] ?? null); } elseif (! empty($raw)) { $first = $raw[0]; $out['token'] = self::tokenStatus($first['token_time_out'] ?? null); $out['credentials'] = [ 'otp_set' => ! empty($first['otp_set']), 'mpin_set' => ! empty($first['mpin_set']), 'password_set' => ! empty($first['password_set']), 'biometric_set' => ! empty($first['biometric_set']), 'mpin_skipped' => ! empty($first['mpin_skipped']), 'is_biometric_enabled' => $first['is_biometric_enabled'] ?? null, 'is_mpin_skipped' => $first['is_mpin_skipped'] ?? null, ]; } // Mark winner among duplicates foreach ($out['raw_matches'] as $i => &$row) { $row['is_login_winner'] = false; if ($out['eligible'] && (int) $row['id'] === (int) $out['eligible']['id']) { $row['is_login_winner'] = true; } elseif ($out['eligible'] === null && $i === 0) { $row['is_login_pick_if_filters_passed'] = true; } } unset($row); $classified = self::classifyRelationshipDuplicates($out['raw_matches']); $out['raw_matches'] = $classified['rows']; $out['duplicates'] = $classified['summary']; $policyEmployeeId = null; if (! empty($out['eligible']['id'])) { $policyEmployeeId = (int) $out['eligible']['id']; } else { foreach ($out['raw_matches'] as $row) { if (! empty($row['is_self'])) { $policyEmployeeId = (int) ($row['id'] ?? 0); break; } } } if ($policyEmployeeId) { try { $out['policies'] = self::fetchEmployeeClientPolicies($db, $policyEmployeeId, $side); } catch (\Throwable $policyEx) { $out['policies'] = []; $out['policies_error'] = $policyEx->getMessage(); } } if ($side === 'post') { $out['eligible_via_api'] = self::fetchPostEligibleViaApi($email, $mobile); } } catch (\Throwable $e) { $out['error'] = $e->getMessage(); } return $out; } private static function fetchRawMatches($db, ?string $email, ?string $mobile): array { $builder = $db->table('employees e') ->select('e.id, e.client_id, e.client_branch_id, e.emp_code, e.name, e.relationship, e.mobile, e.email_corporate, e.email_personal, e.emp_status, e.is_active, e.otp, e.mpin, e.password, e.is_biometric_enabled, e.is_mpin_skipped, e.token_time_out, e.created_at, e.updated_at') ->select('GROUP_CONCAT(DISTINCT CONCAT(ep.id, ":", ep.status, ":", ep.is_active) SEPARATOR ", ") AS policies', false) ->join('employee_polices ep', 'ep.employee_id = e.id', 'left') ->groupBy('e.id') ->orderBy('e.id', 'DESC'); self::applyIdentity($builder, $email, $mobile, 'e'); $rows = $builder->get()->getResultArray(); return array_map(static function (array $row): array { $row['otp_set'] = ! empty($row['otp']); $row['mpin_set'] = isset($row['mpin']) && $row['mpin'] !== null && $row['mpin'] !== ''; $row['password_set'] = ! empty($row['password']); $row['biometric_set'] = self::isBiometricEnabled($row['is_biometric_enabled'] ?? null); $row['mpin_skipped'] = self::isTruthyFlag($row['is_mpin_skipped'] ?? null); return self::publicEmployeeRow($row); }, $rows); } private static function fetchByPersonalEmail($db, string $email): array { $rows = $db->table('employees') ->select('id, client_id, emp_code, name, relationship, mobile, email_corporate, email_personal, emp_status, is_active') ->where('email_personal', $email) ->orderBy('id', 'DESC') ->get() ->getResultArray(); return array_map([self::class, 'publicEmployeeRow'], $rows); } private static function fetchEligibleMatches($db, ?string $email, ?string $mobile, array $statusEmp, array $statusPol): array { $builder = $db->table('employees e') ->select('e.*') ->join('employee_polices ep', 'ep.employee_id = e.id', 'inner') ->where('e.is_active', 1) ->where("TRIM(e.relationship) = 'self'", null, false) ->whereIn('e.emp_status', $statusEmp) ->where('ep.is_active', 1) ->whereIn('ep.status', $statusPol) ->orderBy('e.id', 'DESC'); self::applyIdentity($builder, $email, $mobile, 'e'); return $builder->get()->getResultArray(); } private static function applyIdentity($builder, ?string $email, ?string $mobile, string $alias): void { // Mirror login helper: mobile wins if both provided if ($mobile !== null) { $builder->where("{$alias}.mobile", $mobile); } elseif ($email !== null) { $builder->where("{$alias}.email_corporate", $email); } } private static function fetchClient($db, int $clientId, string $side = 'pre'): array { if ($clientId <= 0) { return []; } if ($side === 'pre') { $clientModel = new ClientModel(); $row = $clientModel->select('id, client_name, short_name, is_active') ->where('id', $clientId) ->first(); return $row ?: []; } $row = $db->table('clients') ->select('id, client_name, short_name, is_active') ->where('id', $clientId) ->get() ->getRowArray(); return $row ?: []; } private static function fetchPostEligibleViaApi(?string $email, ?string $mobile): ?array { try { $url = rtrim((string) env('POST_ENROLLMENT_BASEURL'), '/') . '/getPostEmployeeDataForAuth'; $payload = []; if ($mobile !== null) { $payload['mobile_number'] = $mobile; } elseif ($email !== null) { $payload['email_id'] = $email; } $client = \Config\Services::curlrequest(['timeout' => 10]); $response = $client->post($url, [ 'json' => $payload, 'headers' => ['App-Signature' => (string) getenv('APP_SIGNATURE')], 'http_errors' => false, ]); $body = json_decode($response->getBody(), true); $data = $body['data'] ?? []; if (empty($data) || ! is_array($data)) { return null; } return self::publicEmployeeRow($data); } catch (\Throwable $e) { return null; } } private static function scoreFilters(array $rawMatches, array $statusEmp, array $statusPol): array { if (empty($rawMatches)) { return [ 'raw_found' => false, 'self' => false, 'is_active' => false, 'emp_status_ok' => false, 'has_policy' => false, 'policy_status_ok' => false, 'notes' => ['No raw employee row for this identity.'], ]; } // Score against highest-id row (login pick order) $row = $rawMatches[0]; $rel = strtolower(trim((string) ($row['relationship'] ?? ''))); $selfOk = $rel === 'self'; $activeOk = (int) ($row['is_active'] ?? 0) === 1; $empOk = in_array((string) ($row['emp_status'] ?? ''), $statusEmp, true); $policies = (string) ($row['policies'] ?? ''); $hasPolicy = $policies !== '' && $policies !== null; $policyOk = false; if ($hasPolicy) { foreach (explode(', ', $policies) as $p) { // id:status:is_active $parts = explode(':', $p); if (count($parts) >= 3) { $st = $parts[1]; $ia = (int) $parts[2]; if ($ia === 1 && in_array($st, $statusPol, true)) { $policyOk = true; break; } } } } $notes = []; if (! $selfOk) { $notes[] = 'relationship is "' . ($row['relationship'] ?? '') . '" (need self).'; } if (! $activeOk) { $notes[] = 'employee is_active != 1.'; } if (! $empOk) { $notes[] = 'emp_status "' . ($row['emp_status'] ?? '') . '" not in [' . implode(',', $statusEmp) . '].'; } if (! $hasPolicy) { $notes[] = 'no employee_polices row.'; } elseif (! $policyOk) { $notes[] = 'no policy with status in [' . implode(',', $statusPol) . '] and is_active=1.'; } return [ 'raw_found' => true, 'self' => $selfOk, 'is_active' => $activeOk, 'emp_status_ok' => $empOk, 'has_policy' => $hasPolicy, 'policy_status_ok' => $policyOk, 'notes' => $notes, 'scored_employee_id' => $row['id'] ?? null, ]; } private static function tokenStatus($tokenTimeOut): array { $timeoutEnv = (int) (getenv('TOKENTIMEOUT') ?: 0); if ($tokenTimeOut === null || $tokenTimeOut === '' || $tokenTimeOut === false) { return [ 'token_time_out' => null, 'status' => 'NULL', 'remaining_seconds' => null, 'human' => 'No session (token_time_out is null)', 'tokentimeout_env' => $timeoutEnv, ]; } $epoch = (int) $tokenTimeOut; $now = time(); $remaining = $epoch - $now; $status = $remaining > 0 ? 'VALID' : 'EXPIRED'; return [ 'token_time_out' => $epoch, 'status' => $status, 'remaining_seconds' => $remaining, 'human' => date('Y-m-d H:i:s', $epoch) . ($remaining > 0 ? " ({$remaining}s left)" : ' (expired)'), 'tokentimeout_env' => $timeoutEnv, ]; } private static function publicEmployeeRow(array $row): array { unset($row['otp'], $row['mpin'], $row['password']); // Keep boolean flags only if credentials were extracted separately return $row; } private static function isBiometricEnabled($value): bool { return self::isTruthyFlag($value); } private static function isTruthyFlag($value): bool { if ($value === null || $value === '') { return false; } if (is_bool($value)) { return $value; } return in_array((string) $value, ['1', 'true', 'yes', 'on'], true); } private static function simulateMerge(array $pre, array $post): array { $preEligible = $pre['eligible'] ?? null; $postEligible = $post['eligible'] ?? ($post['eligible_via_api'] ?? null); $result = [ 'pre_eligible' => ! empty($preEligible), 'post_eligible' => ! empty($postEligible), 'pre_short_name' => $preEligible['client_short_name'] ?? ($pre['client_short_name'] ?? null), 'post_short_name' => $postEligible['client_short_name'] ?? ($post['client_short_name'] ?? null), 'pre_created_at' => $preEligible['created_at'] ?? null, 'post_created_at' => $postEligible['created_at'] ?? null, 'short_name_match' => null, 'would_return_both' => false, 'winner' => null, 'otp_sync_possible' => false, 'reason' => '', ]; if (empty($preEligible) && empty($postEligible)) { $result['reason'] = 'Neither PRE nor POST has a login-eligible employee.'; $result['winner'] = 'none'; return $result; } if (! empty($preEligible) && empty($postEligible)) { $result['winner'] = 'pre'; $result['reason'] = 'PRE-only eligible path.'; return $result; } if (empty($preEligible) && ! empty($postEligible)) { $result['winner'] = 'post'; $result['reason'] = 'POST-only path (PRE empty — login proxies to POST).'; return $result; } $preSn = (string) ($result['pre_short_name'] ?? ''); $postSn = (string) ($result['post_short_name'] ?? ''); $result['short_name_match'] = $preSn === $postSn; $preTs = strtotime((string) ($result['pre_created_at'] ?? '')) ?: 0; $postTs = strtotime((string) ($result['post_created_at'] ?? '')) ?: 0; $latest = $preTs > $postTs ? 'pre' : 'post'; if ($result['short_name_match']) { $result['would_return_both'] = true; $result['winner'] = 'both'; $result['otp_sync_possible'] = true; $result['reason'] = 'Same client_short_name — merge keeps both; OTP sync possible.'; } else { $result['would_return_both'] = false; $result['winner'] = $latest; $result['otp_sync_possible'] = false; $result['reason'] = "client_short_name mismatch ('{$preSn}' vs '{$postSn}'). Only latest ({$latest}) is kept; other side dropped."; } return $result; } /** * Client policies linked to this employee (Self) via employee_polices. * PRE: enrollment open/close come from employee_polices (what the employee app uses). * * @return list> */ private static function fetchEmployeeClientPolicies($db, int $employeeId, string $side): array { if ($employeeId <= 0) { return []; } $builder = $db->table('employee_polices ep') ->select('ep.id AS emp_policy_id, ep.employee_id, ep.client_policy_id, ep.status AS emp_policy_status, ep.is_active AS emp_policy_is_active') ->select('cp.policy_no, cp.policy_start_date, cp.policy_end_date, cp.open_date, cp.close_date, cp.open_for_enrollment, cp.is_active AS client_policy_is_active, cp.client_id, cp.is_addon') ->join('client_policy cp', 'cp.id = ep.client_policy_id', 'left') ->where('ep.employee_id', $employeeId) ->orderBy('ep.id', 'DESC'); // Enrollment dates exist only on PRE; wellness fields exist only on POST if ($side === 'pre') { $builder->select('ep.enrollment_open_date, ep.enrollment_close_date'); } else { $builder->select('ep.wellness_onboard, cp.wellness_plan_id, cp.wellness_vendor_id, cp.policy_type_id'); } $rows = $builder->get()->getResultArray(); $today = date('Y-m-d'); $out = []; foreach ($rows as $row) { $open = $side === 'pre' ? self::normalizeDate($row['enrollment_open_date'] ?? null) : null; $close = $side === 'pre' ? self::normalizeDate($row['enrollment_close_date'] ?? null) : null; $window = $side === 'pre' ? self::enrollmentWindowStatus($open, $close, $today) : 'N/A'; $wellnessOnboard = $side === 'post' ? ($row['wellness_onboard'] ?? null) : null; $wellnessReady = $side === 'post' ? self::isWellnessOnboarded($wellnessOnboard) : null; $out[] = [ 'employee_id' => $row['employee_id'] ?? $employeeId, 'emp_policy_id' => $row['emp_policy_id'] ?? null, 'client_policy_id' => $row['client_policy_id'] ?? null, 'client_id' => $row['client_id'] ?? null, 'policy_no' => $row['policy_no'] ?? null, 'policy_type_id' => $side === 'post' ? ($row['policy_type_id'] ?? null) : null, 'emp_policy_status' => $row['emp_policy_status'] ?? null, 'emp_policy_is_active' => $row['emp_policy_is_active'] ?? null, 'client_policy_is_active' => $row['client_policy_is_active'] ?? null, 'is_addon' => $row['is_addon'] ?? null, 'policy_start_date' => $row['policy_start_date'] ?? null, 'policy_end_date' => $row['policy_end_date'] ?? null, 'client_open_date' => $row['open_date'] ?? null, 'client_close_date' => $row['close_date'] ?? null, 'open_for_enrollment' => $row['open_for_enrollment'] ?? null, 'enrollment_open_date' => $open, 'enrollment_close_date' => $close, 'enrollment_window' => $window, 'show_enrollment_dates' => $side === 'pre', 'wellness_onboard' => $wellnessOnboard, 'wellness_onboard_raw' => $side === 'post' ? self::rawFlag($wellnessOnboard) : null, 'wellness_ready' => $wellnessReady, 'wellness_plan_id' => $side === 'post' ? ($row['wellness_plan_id'] ?? null) : null, 'wellness_vendor_id' => $side === 'post' ? ($row['wellness_vendor_id'] ?? null) : null, 'show_wellness' => $side === 'post', ]; } return $out; } /** Matches POST getWellnessUrl: wellness_onboard != 0 (and not null/empty). */ private static function isWellnessOnboarded($value): bool { if ($value === null || $value === '') { return false; } return (string) $value !== '0'; } private static function rawFlag($value): string { if ($value === null || $value === '') { return 'null'; } return (string) $value; } private static function hasWellnessPlanConfigured($planId): bool { if ($planId === null || $planId === '') { return false; } return (string) $planId !== '0'; } private static function normalizeDate($value): ?string { if ($value === null || $value === '' || $value === '0000-00-00') { return null; } $ts = strtotime((string) $value); if ($ts === false) { return (string) $value; } return date('Y-m-d', $ts); } private static function enrollmentWindowStatus(?string $open, ?string $close, string $today): string { if ($open === null && $close === null) { return 'MISSING'; } if ($open !== null && $today < $open) { return 'NOT_STARTED'; } if ($close !== null && $today > $close) { return 'CLOSED'; } if ($open !== null && $close !== null && $today >= $open && $today <= $close) { return 'OPEN'; } if ($open !== null && $close === null && $today >= $open) { return 'OPEN'; } return 'UNKNOWN'; } /** * Split Self vs Family; flag Self dups by client_id+emp_code; Family dups by client+name+rel. * * @param list> $rows * @return array{rows: list>, summary: array} */ private static function classifyRelationshipDuplicates(array $rows): array { $selfRows = []; $familyRows = []; foreach ($rows as $i => $row) { $isSelf = self::isSelfRelationship($row['relationship'] ?? null); $rows[$i]['is_self'] = $isSelf; $rows[$i]['is_family'] = ! $isSelf; $rows[$i]['is_self_duplicate'] = false; $rows[$i]['is_family_duplicate'] = false; $rows[$i]['is_cross_self_dup'] = false; $rows[$i]['dup_badge'] = null; if ($isSelf) { $selfRows[] = $i; } else { $familyRows[] = $i; } } // Self: group by client_id + emp_code $selfGroups = []; foreach ($selfRows as $i) { $client = (string) ($rows[$i]['client_id'] ?? ''); $code = trim((string) ($rows[$i]['emp_code'] ?? '')); if ($code === '') { $code = '__empty_emp_code__'; } $key = $client . '|' . $code; $selfGroups[$key][] = $i; } $selfDupGroups = []; foreach ($selfGroups as $key => $indices) { if (count($indices) < 2) { continue; } [$clientId, $empCode] = explode('|', $key, 2); $ids = []; foreach ($indices as $i) { $rows[$i]['is_self_duplicate'] = true; $rows[$i]['dup_badge'] = 'SELF DUP'; $ids[] = (int) ($rows[$i]['id'] ?? 0); } $selfDupGroups[] = [ 'client_id' => $clientId, 'emp_code' => $empCode === '__empty_emp_code__' ? '' : $empCode, 'count' => count($indices), 'ids' => $ids, ]; } // Family: group by client_id + lower(name) + lower(rel) $familyGroups = []; foreach ($familyRows as $i) { $client = (string) ($rows[$i]['client_id'] ?? ''); $name = strtolower(trim((string) ($rows[$i]['name'] ?? ''))); $rel = strtolower(trim((string) ($rows[$i]['relationship'] ?? ''))); $key = $client . '|' . $name . '|' . $rel; $familyGroups[$key][] = $i; } $familyDupGroups = []; foreach ($familyGroups as $key => $indices) { if (count($indices) < 2) { continue; } $parts = explode('|', $key, 3); $ids = []; foreach ($indices as $i) { $rows[$i]['is_family_duplicate'] = true; if (empty($rows[$i]['dup_badge'])) { $rows[$i]['dup_badge'] = 'FAMILY DUP'; } $ids[] = (int) ($rows[$i]['id'] ?? 0); } $familyDupGroups[] = [ 'client_id' => $parts[0] ?? '', 'name' => $parts[1] ?? '', 'relationship' => $parts[2] ?? '', 'count' => count($indices), 'ids' => $ids, ]; } $selfList = []; $familyList = []; foreach ($rows as $row) { if (! empty($row['is_self'])) { $selfList[] = $row; } else { $familyList[] = $row; } } return [ 'rows' => $rows, 'summary' => [ 'self_count' => count($selfList), 'family_count' => count($familyList), 'self_rows' => $selfList, 'family_rows' => $familyList, 'self_duplicate_groups' => $selfDupGroups, 'family_duplicate_groups' => $familyDupGroups, 'has_self_duplicates' => $selfDupGroups !== [], 'has_family_duplicates' => $familyDupGroups !== [], 'cross_self_dup_emp_codes'=> [], ], ]; } private static function isSelfRelationship($relationship): bool { return strtolower(trim((string) $relationship)) === 'self'; } /** * If Self is duplicated on one side for emp_code, highlight same emp_code Self rows on the other side. * * @return array{0: array, 1: array} */ private static function crossLinkSelfDuplicates(array $pre, array $post): array { $preDupCodes = self::selfDupEmpCodes($pre); $postDupCodes = self::selfDupEmpCodes($post); $preSelfCodes = self::selfEmpCodes($pre); $postSelfCodes = self::selfEmpCodes($post); // Highlight peer side when this emp_code is Self-duplicated on one DB and exists as Self on the other $crossFromPre = array_values(array_intersect($preDupCodes, $postSelfCodes)); $crossFromPost = array_values(array_intersect($postDupCodes, $preSelfCodes)); $crossCodes = array_values(array_unique(array_merge($crossFromPre, $crossFromPost))); if ($crossCodes === [] && $preDupCodes === [] && $postDupCodes === []) { return [$pre, $post]; } $markCodes = array_values(array_unique(array_merge($preDupCodes, $postDupCodes, $crossCodes))); $pre = self::markCrossSelfDupRows($pre, $markCodes); $post = self::markCrossSelfDupRows($post, $markCodes); if (! isset($pre['duplicates']) || ! is_array($pre['duplicates'])) { $pre['duplicates'] = []; } if (! isset($post['duplicates']) || ! is_array($post['duplicates'])) { $post['duplicates'] = []; } $pre['duplicates']['cross_self_dup_emp_codes'] = $crossCodes; $post['duplicates']['cross_self_dup_emp_codes'] = $crossCodes; return [$pre, $post]; } private static function selfEmpCodes(array $side): array { $codes = []; foreach ($side['raw_matches'] ?? [] as $row) { if (empty($row['is_self'])) { continue; } $code = trim((string) ($row['emp_code'] ?? '')); if ($code !== '') { $codes[] = $code; } } return array_values(array_unique($codes)); } private static function selfDupEmpCodes(array $side): array { $codes = []; foreach ($side['duplicates']['self_duplicate_groups'] ?? [] as $g) { $code = trim((string) ($g['emp_code'] ?? '')); if ($code !== '') { $codes[] = $code; } } return array_values(array_unique($codes)); } private static function markCrossSelfDupRows(array $side, array $empCodes): array { $codeSet = array_fill_keys($empCodes, true); foreach ($side['raw_matches'] ?? [] as $i => $row) { if (empty($row['is_self'])) { continue; } $code = trim((string) ($row['emp_code'] ?? '')); if ($code !== '' && isset($codeSet[$code])) { $side['raw_matches'][$i]['is_cross_self_dup'] = true; if (empty($side['raw_matches'][$i]['dup_badge'])) { $side['raw_matches'][$i]['dup_badge'] = 'SELF DUP LINK'; } elseif ($side['raw_matches'][$i]['dup_badge'] === 'SELF DUP') { $side['raw_matches'][$i]['dup_badge'] = 'SELF DUP'; } } } // Refresh grouped lists from annotated raw_matches if (! empty($side['duplicates'])) { $selfList = []; $familyList = []; foreach ($side['raw_matches'] as $row) { if (! empty($row['is_self'])) { $selfList[] = $row; } else { $familyList[] = $row; } } $side['duplicates']['self_rows'] = $selfList; $side['duplicates']['family_rows'] = $familyList; } return $side; } private static function addDuplicateProblems(callable $add, string $label, array $side): void { $dup = $side['duplicates'] ?? null; if (! is_array($dup)) { return; } $prefix = strtoupper($label); foreach ($dup['self_duplicate_groups'] ?? [] as $g) { $empCode = (string) ($g['emp_code'] ?? ''); $client = (string) ($g['client_id'] ?? ''); $count = (int) ($g['count'] ?? 0); $ids = implode(', ', $g['ids'] ?? []); $add( 'high', $prefix . '_SELF_DUPLICATES', "{$label}: {$count} Self rows for emp_code '{$empCode}' under client_id {$client} (ids: {$ids}). Login picks highest id.", 'Deactivate extra Self rows; keep one Self per client_id + emp_code.' ); } foreach ($dup['family_duplicate_groups'] ?? [] as $g) { $name = (string) ($g['name'] ?? ''); $rel = (string) ($g['relationship'] ?? ''); $client = (string) ($g['client_id'] ?? ''); $count = (int) ($g['count'] ?? 0); $ids = implode(', ', $g['ids'] ?? []); $add( 'medium', $prefix . '_FAMILY_DUPLICATES', "{$label}: {$count} Family rows for '{$name}' / {$rel} under client_id {$client} (ids: {$ids}).", 'Review family duplicates; deactivate wrong dependent rows.' ); } } /** POST-only: wellness_onboard must be not null and not 0 for getWellnessUrl. */ private static function addPostWellnessProblems(callable $add, array $post): void { $policies = $post['policies'] ?? []; if ($policies === []) { return; } $anyPlanConfigured = false; $anyOnboarded = false; $notOnboardedWithPlan = []; foreach ($policies as $pol) { $planOk = self::hasWellnessPlanConfigured($pol['wellness_plan_id'] ?? null); $onboarded = ! empty($pol['wellness_ready']); if ($planOk) { $anyPlanConfigured = true; } if ($onboarded) { $anyOnboarded = true; } if ($planOk && ! $onboarded) { $notOnboardedWithPlan[] = $pol; } } if ($anyOnboarded) { return; // at least one policy can drive wellness SSO } if ($notOnboardedWithPlan !== []) { foreach ($notOnboardedWithPlan as $pol) { $pno = (string) ($pol['policy_no'] ?? $pol['client_policy_id'] ?? ''); $raw = (string) ($pol['wellness_onboard_raw'] ?? 'null'); $add( 'high', 'POST_WELLNESS_NOT_ONBOARDED', "POST policy {$pno}: wellness_plan_id set but wellness_onboard is {$raw} (need not null and not 0). Wellness SSO will return Coming soon.", 'Run wellness onboard for this client_policy so employee_polices.wellness_onboard is set to a non-zero value.' ); } return; } // No policy has wellness_onboard set (null/0) — always flag on POST $samples = []; foreach ($policies as $pol) { $pno = (string) ($pol['policy_no'] ?? $pol['emp_policy_id'] ?? ''); $raw = (string) ($pol['wellness_onboard_raw'] ?? 'null'); $samples[] = "{$pno}={$raw}"; } $add( 'medium', 'POST_WELLNESS_ONBOARD_EMPTY', 'POST: no employee_polices row has wellness_onboard set (not null / not 0). Values: ' . implode(', ', $samples) . '.', 'Onboard the employee to wellness on the live policy (POST only). Login can still succeed without this.' ); if ($anyPlanConfigured === false && ! empty($post['eligible'])) { $add( 'low', 'POST_WELLNESS_PLAN_NOT_CONFIGURED', 'POST employee policies have no wellness_plan_id; wellness SSO will not work.', 'Set client_policy.wellness_plan_id (and vendor if needed) on the live policy, then onboard employees.' ); } } private static function buildProblems(?string $email, ?string $mobile, array $pre, array $post, array $merge, array $connectivity): array { $problems = []; $add = static function (string $sev, string $code, string $msg, string $fix) use (&$problems) { $problems[] = [ 'severity' => $sev, 'code' => $code, 'message' => $msg, 'fix' => $fix, ]; }; if (empty($connectivity['app_signature'])) { $add('high', 'APP_SIGNATURE_MISSING', 'APP_SIGNATURE env is empty.', 'Set APP_SIGNATURE on PRE to match POST.'); } if (empty($connectivity['api_reachable'])) { $add('high', 'POST_API_UNREACHABLE', $connectivity['api_message'] ?? 'POST employeeRest API not reachable.', 'Check POST_ENROLLMENT_BASEURL and App-Signature.'); } if (empty($connectivity['post_db'])) { $add('medium', 'POST_DB_UNAVAILABLE', 'Direct postDB connection failed: ' . ($connectivity['post_db_message'] ?? ''), 'Fix database.postDB.* in .env for richer POST diagnostics.'); } if ($email !== null && empty($pre['raw_matches']) && ! empty($pre['raw_by_personal'])) { $add('high', 'EMAIL_IN_PERSONAL_ONLY', 'Email found in email_personal on PRE but login only checks email_corporate.', 'Copy/fix email_corporate on the self employee row.'); } if (empty($pre['raw_matches']) && empty($post['raw_matches']) && empty($post['eligible_via_api'])) { $add('high', 'NOT_IN_EITHER_DB', 'No employee row matched this identity in PRE or POST (raw).', 'Verify mobile/email_corporate spelling and formatting.'); } if (! empty($pre['raw_matches']) && empty($pre['eligible'])) { $notes = implode(' ', $pre['filters_summary']['notes'] ?? []); $add('high', 'PRE_NOT_ELIGIBLE', 'PRE has raw match(es) but not login-eligible. ' . $notes, 'Fix relationship/status/policy to draft|enrolled for enrollment login.'); } if (! empty($post['raw_matches']) && empty($post['eligible']) && empty($post['eligible_via_api'])) { $notes = implode(' ', $post['filters_summary']['notes'] ?? []); $add('high', 'POST_NOT_ELIGIBLE', 'POST has raw match(es) but not login-eligible. ' . $notes, 'Fix relationship/status/policy to active|expired for live login.'); } if (($merge['winner'] ?? '') === 'none') { $add('high', 'NO_ELIGIBLE_SIDE', 'Neither PRE nor POST is login-eligible.', 'Fix eligibility filters on at least one side.'); } if ($merge['short_name_match'] === false && ($merge['pre_eligible'] ?? false) && ($merge['post_eligible'] ?? false)) { $add( 'high', 'CLIENT_SHORT_NAME_MISMATCH', $merge['reason'], 'Align clients.short_name between PRE and POST for the same employer, or correct wrong client mapping.' ); } if (($merge['winner'] ?? '') === 'pre' && empty($merge['post_eligible'])) { $add('medium', 'PRE_ONLY', 'Login will use PRE only; no POST eligible employee.', 'Normal mid-enrollment; after push to live, POST should become eligible.'); } if (($merge['winner'] ?? '') === 'post' && empty($merge['pre_eligible'])) { $add('medium', 'POST_ONLY', 'Login will proxy to POST only (no PRE eligible).', 'OK for live-only users; SAML requiring PRE will still fail.'); } self::addDuplicateProblems($add, 'PRE', $pre); self::addDuplicateProblems($add, 'POST', $post); if (! empty($pre['duplicates']['cross_self_dup_emp_codes'])) { $codes = implode(', ', $pre['duplicates']['cross_self_dup_emp_codes']); $add( 'high', 'PRE_POST_SELF_DUPLICATE', "Self duplicate involves emp_code(s) [{$codes}] across PRE/POST (same emp_code; check same client). Login picks highest id per side.", 'Keep one active Self row per client_id + emp_code on each DB; deactivate extras.' ); } // PRE employee enrollment window (employee_polices dates) if (! empty($pre['policies_error'])) { $add('medium', 'PRE_POLICIES_FETCH_ERROR', 'PRE policy list failed: ' . $pre['policies_error'], 'Check employee_polices / client_policy schema on enrollment DB.'); } if (! empty($post['policies_error'])) { $add('medium', 'POST_POLICIES_FETCH_ERROR', 'POST policy list failed: ' . $post['policies_error'], 'POST has no enrollment_open/close columns — query must not select them.'); } if (! empty($post['eligible']) && empty($post['policies']) && empty($post['policies_error'])) { $add( 'medium', 'POST_NO_POLICIES_LISTED', 'POST employee is login-eligible but no employee_polices rows were returned for that employee id.', 'Check POST employee_polices for that employee_id; eligibility join may differ from policy list query.' ); } // POST-only wellness_onboard (matches getWellnessUrl: wellness_onboard != 0) self::addPostWellnessProblems($add, $post); foreach ($pre['policies'] ?? [] as $pol) { $window = (string) ($pol['enrollment_window'] ?? ''); $pno = (string) ($pol['policy_no'] ?? $pol['client_policy_id'] ?? ''); if ($window === 'CLOSED') { $add( 'medium', 'PRE_ENROLLMENT_CLOSED', "PRE policy {$pno}: enrollment closed (close {$pol['enrollment_close_date']}).", 'Extend enrollment_close_date on employee_polices or inform employee enrollment is closed.' ); } elseif ($window === 'NOT_STARTED') { $add( 'medium', 'PRE_ENROLLMENT_NOT_STARTED', "PRE policy {$pno}: enrollment not started (open {$pol['enrollment_open_date']}).", 'Wait until enrollment_open_date or adjust the date on employee_polices.' ); } elseif ($window === 'MISSING' && ! empty($pol['emp_policy_id'])) { $add( 'low', 'PRE_ENROLLMENT_DATES_MISSING', "PRE policy {$pno}: enrollment_open_date / enrollment_close_date not set on employee_polices.", 'Set enrollment dates on the employee policy (used by the employee app).' ); } } $preCred = $pre['credentials'] ?? null; $postCred = $post['credentials'] ?? null; if ($preCred && $postCred) { if (! empty($preCred['mpin_set']) xor ! empty($postCred['mpin_set'])) { $add('medium', 'MPIN_OUT_OF_SYNC', 'MPIN set on only one side (PRE/POST).', 'Re-save MPIN after login when both sides are in merge, or set on both DBs.'); } if (! empty($preCred['password_set']) xor ! empty($postCred['password_set'])) { $add('medium', 'PASSWORD_OUT_OF_SYNC', 'Password set on only one side. Password is not auto-synced.', 'Set/change password on both apps or use OTP/MPIN.'); } if (! empty($preCred['biometric_set']) xor ! empty($postCred['biometric_set'])) { $add('medium', 'BIOMETRIC_OUT_OF_SYNC', 'Biometric enabled on only one side (PRE/POST).', 'Align is_biometric_enabled on both DBs or re-enable biometric after login when merge keeps both.'); } } $preTok = $pre['token']['status'] ?? null; $postTok = $post['token']['status'] ?? null; if ($preTok === 'VALID' && ($postTok === 'NULL' || $postTok === 'EXPIRED')) { $add('medium', 'TOKEN_HALF_SESSION', 'PRE session valid but POST session null/expired — half-login risk.', 'User may need to re-verify OTP so both tokens refresh, or use correct token per app.'); } if ($postTok === 'VALID' && ($preTok === 'NULL' || $preTok === 'EXPIRED')) { $add('low', 'TOKEN_POST_ONLY_SESSION', 'POST has active token_time_out; PRE does not.', 'Usually fine for POST-only usage.'); } if ($preTok === 'EXPIRED') { $add('low', 'TOKEN_PRE_EXPIRED', 'PRE token_time_out is expired.', 'User must login again; logout API does not clear timeout today.'); } if ($postTok === 'EXPIRED') { $add('low', 'TOKEN_POST_EXPIRED', 'POST token_time_out is expired.', 'User must login again on live app.'); } if (! empty($pre['error'])) { $add('high', 'PRE_QUERY_ERROR', 'PRE analysis error: ' . $pre['error'], 'Check enrollment DB connectivity.'); } if (! empty($post['error']) && empty($post['eligible']) && empty($post['eligible_via_api'])) { $add('high', 'POST_QUERY_ERROR', 'POST analysis error: ' . $post['error'], 'Check postDB and/or POST API.'); } // Severity sort $order = ['high' => 0, 'medium' => 1, 'low' => 2]; usort($problems, static function ($a, $b) use ($order) { return ($order[$a['severity']] ?? 9) <=> ($order[$b['severity']] ?? 9); }); return $problems; } private static function buildVerdict(array $pre, array $post, array $merge, array $problems): array { $hasHigh = false; foreach ($problems as $p) { if (($p['severity'] ?? '') === 'high') { $hasHigh = true; break; } } $winner = $merge['winner'] ?? 'none'; if ($winner === 'none' || $hasHigh && empty($merge['pre_eligible']) && empty($merge['post_eligible'])) { return [ 'status' => 'WILL_FAIL', 'message' => $problems[0]['message'] ?? 'Login will fail for this identity.', ]; } if ($winner === 'both' && ! $hasHigh) { $hasMed = false; foreach ($problems as $p) { if (($p['severity'] ?? '') === 'medium') { $hasMed = true; break; } } if ($hasMed) { return [ 'status' => 'OK_WITH_WARNINGS', 'message' => 'Both PRE and POST eligible with matching client_short_name, but warnings exist.', ]; } return [ 'status' => 'OK', 'message' => 'Both PRE and POST eligible; merge keeps both; OTP sync possible.', ]; } if ($winner === 'both' && $hasHigh) { return [ 'status' => 'OK_WITH_WARNINGS', 'message' => 'Both sides eligible but high-severity issues remain — review problems list.', ]; } // pre-only, post-only, or mismatch latest-only return [ 'status' => 'PARTIAL', 'message' => $merge['reason'] ?: 'Only one side will be used for login.', ]; } }