1212 lines
50 KiB
PHP
1212 lines
50 KiB
PHP
<?php
|
|
|
|
namespace App\Helpers;
|
|
|
|
use App\Models\ClientModel;
|
|
use App\Models\EmployeeModel;
|
|
use Config\Database;
|
|
|
|
/**
|
|
* Read-only employee login diagnostic (PRE + POST).
|
|
* Does not send OTP/SMS and does not modify auth controllers.
|
|
*/
|
|
class LoginDiagnosticHelper
|
|
{
|
|
public static function diagnose(?string $email, ?string $mobile): array
|
|
{
|
|
$email = self::normalize($email);
|
|
$mobile = self::normalize($mobile);
|
|
|
|
if ($email === null && $mobile === null) {
|
|
return [
|
|
'input' => ['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<array<string, mixed>>
|
|
*/
|
|
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<array<string, mixed>> $rows
|
|
* @return array{rows: list<array<string, mixed>>, summary: array<string, mixed>}
|
|
*/
|
|
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.',
|
|
];
|
|
}
|
|
}
|