diff --git a/.env.sample b/.env.sample index 3d57c40a..a9e88c7e 100755 --- a/.env.sample +++ b/.env.sample @@ -180,6 +180,23 @@ bds.installmentReminder.daily = true # Comma-separated weekdays (mon,tue,wed,thu,fri,sat,sun) — used only when daily = false bds.installmentReminder.days = mon,tue,wed,thu,fri +# Policy Transaction Renewal Reminder — used by sendRenewalReminderMail cron +pt.renewalReminder.enabled = true +pt.renewalReminder.daily = true +pt.renewalReminder.days = mon,tue,wed,thu,fri +# Client types: 1=Group, 2=Individual +pt.renewalReminder.clientTypes = 1,2 +# renewal_date | policy_end_date +pt.renewalReminder.dateField = renewal_date +pt.renewalReminder.daysBefore = 30 +pt.renewalReminder.includeClientEmail = true +# When true, also send for policies whose dateField is already past (overdue) +pt.renewalReminder.includeOverdue = false +pt.renewalReminder.toEmails = +pt.renewalReminder.ccEmails = +pt.renewalReminder.bccEmails = +pt.renewalReminder.fromMail = + #-------------------------------------------------------------------- # MEDI ASSIST WELLNESS SSO Configuration diff --git a/app/Config/Filters.php b/app/Config/Filters.php index c6166946..aa658c76 100755 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -70,7 +70,7 @@ class Filters extends BaseConfig 'HttpRequestLog' => ['except' => 'cli/*'], 'Cors', 'AclFilter' => ['except' => ['login', 'logout', 'auth/*', 'oauth2callback','claim-form-download', 'claims-feedback-form', 'autobookstackLogin','employeeRest/*','processjob','getCommission','downloadEmployeeEcardZip', 'downloadClaimFile/*', 'api/v1/*', 'cronCdLowBalanceAlert', 'sendCroneRemainderMail']], - 'SecurityInputFilter' => ['except' => ['/client/notification/create','/ticket/crud_mail_template/*','test_mail','leads/sendMail', 'ticket/reply'] ], + 'SecurityInputFilter' => ['except' => ['/client/notification/create','/ticket/crud_mail_template/*','test_mail','leads/sendMail', 'ticket/reply', 'policy_tranction/retail-policy/template', 'policy_tranction/retail-policy/send-test-mail'] ], 'GlobalPostFileUploadGuard' // 'csrf', // 'invalidchars', diff --git a/app/Config/PtRenewalReminderConfig.php b/app/Config/PtRenewalReminderConfig.php new file mode 100644 index 00000000..09363875 --- /dev/null +++ b/app/Config/PtRenewalReminderConfig.php @@ -0,0 +1,321 @@ + env('pt.renewalReminder.enabled', 'true'), + 'daily' => env('pt.renewalReminder.daily', 'true'), + 'days' => env('pt.renewalReminder.days', 'mon,tue,wed,thu,fri'), + 'clientTypes' => env('pt.renewalReminder.clientTypes', '2'), + 'dateField' => env('pt.renewalReminder.dateField', 'renewal_date'), + 'daysBefore' => env('pt.renewalReminder.daysBefore', 30), + 'includeClientEmail' => env('pt.renewalReminder.includeClientEmail', 'true'), + 'includeOverdue' => env('pt.renewalReminder.includeOverdue', 'false'), + 'toEmails' => env('pt.renewalReminder.toEmails', ''), + 'ccEmails' => env('pt.renewalReminder.ccEmails', ''), + 'bccEmails' => env('pt.renewalReminder.bccEmails', ''), + 'fromMail' => env('pt.renewalReminder.fromMail', ''), + ]; + + $dbConfig = $this->loadConfigFromNotification(); + $merged = array_merge($defaults, $dbConfig); + + $this->applyMergedConfig($merged); + } + + /** + * @return array + */ + private function loadConfigFromNotification(): array + { + try { + $db = Database::connect(); + $row = $db->table('notifications') + ->where('template_name', self::TEMPLATE_NAME) + ->groupStart() + ->where('client_id', null) + ->orWhere('client_id', 0) + ->orWhere('client_id', '') + ->groupEnd() + ->orderBy('id', 'DESC') + ->get() + ->getRowArray(); + + if (empty($row)) { + log_message('error', '[PtRenewalReminderConfig] No notifications row for retail_reminder_mail; using env defaults'); + return []; + } + + $this->notificationId = isset($row['id']) ? (int) $row['id'] : null; + $this->mailSubject = trim((string) ($row['subject'] ?? '')); + $this->mailContent = (string) ($row['mail_content'] ?? ''); + + $json = trim((string) ($row['config_json'] ?? '')); + if ($json === '') { + log_message('error', '[PtRenewalReminderConfig] config_json empty for notification_id=' . ($this->notificationId ?? 'n/a')); + return []; + } + + $decoded = json_decode($json, true); + if (! is_array($decoded)) { + log_message('error', '[PtRenewalReminderConfig] Invalid config_json for notification_id=' . ($this->notificationId ?? 'n/a')); + return []; + } + + return $decoded; + } catch (\Throwable $e) { + log_message( + 'error', + '[PtRenewalReminderConfig] Failed loading notification config: ' . $e->getMessage() + ); + return []; + } + } + + /** + * @param array $merged + */ + private function applyMergedConfig(array $merged): void + { + $this->enabled = $this->toBool($merged['enabled'] ?? false); + $this->daily = $this->toBool($merged['daily'] ?? false); + + $daysRaw = $merged['days'] ?? 'mon,tue,wed,thu,fri'; + if (is_array($daysRaw)) { + $daysRaw = implode(',', $daysRaw); + } + $this->days = array_values(array_filter(array_map( + static fn (string $day): string => strtolower(substr(trim($day), 0, 3)), + explode(',', (string) $daysRaw) + ))); + + $clientTypesRaw = $merged['clientTypes'] ?? '2'; + if (is_array($clientTypesRaw)) { + $this->clientTypes = array_values(array_unique(array_filter(array_map( + static fn ($type): int => (int) $type, + $clientTypesRaw + ), static fn (int $type): bool => $type > 0))); + } else { + $this->clientTypes = array_values(array_unique(array_filter(array_map( + static fn (string $type): int => (int) trim($type), + explode(',', (string) $clientTypesRaw) + ), static fn (int $type): bool => $type > 0))); + } + + $dateField = strtolower(trim((string) ($merged['dateField'] ?? 'renewal_date'))); + if (! in_array($dateField, self::ALLOWED_DATE_FIELDS, true)) { + log_message( + 'error', + '[PtRenewalReminderConfig] Invalid dateField "' . $dateField . '", falling back to renewal_date' + ); + $dateField = 'renewal_date'; + } + $this->dateField = $dateField; + + $this->daysBefore = max(0, (int) ($merged['daysBefore'] ?? 30)); + + $this->includeClientEmail = $this->toBool($merged['includeClientEmail'] ?? true); + $this->includeOverdue = $this->toBool($merged['includeOverdue'] ?? false); + + $this->toEmails = $this->parseEmailList($merged['toEmails'] ?? ''); + $this->ccEmails = $this->parseEmailList($merged['ccEmails'] ?? ''); + $this->bccEmails = $this->parseEmailList($merged['bccEmails'] ?? ''); + $this->fromMail = trim((string) ($merged['fromMail'] ?? '')); + } + + public function shouldRunToday(): bool + { + if (! $this->enabled) { + return false; + } + + if ($this->daily) { + return true; + } + + $today = strtolower(date('D')); + + return in_array($today, $this->days, true); + } + + public function getDateField(): string + { + return $this->dateField; + } + + public function getDaysBefore(): int + { + return $this->daysBefore; + } + + /** + * @return int[] + */ + public function getClientTypes(): array + { + return $this->clientTypes; + } + + /** + * @return string[] + */ + public function getToEmails(): array + { + return $this->toEmails; + } + + /** + * @return string[] + */ + public function getCcEmails(): array + { + return $this->ccEmails; + } + + /** + * @return string[] + */ + public function getBccEmails(): array + { + return $this->bccEmails; + } + + public function getFromMail(): string + { + return $this->fromMail; + } + + public function includeClientEmail(): bool + { + return $this->includeClientEmail; + } + + public function includeOverdue(): bool + { + return $this->includeOverdue; + } + + public function getMailSubject(): string + { + return $this->mailSubject; + } + + public function getMailContent(): string + { + return $this->mailContent; + } + + /** + * Flatten config for UI / API response. + * + * @return array + */ + public function toArray(): array + { + return [ + 'enabled' => $this->enabled, + 'daily' => $this->daily, + 'days' => implode(',', $this->days), + 'clientTypes' => $this->clientTypes, + 'dateField' => $this->dateField, + 'daysBefore' => $this->daysBefore, + 'includeClientEmail' => $this->includeClientEmail, + 'includeOverdue' => $this->includeOverdue, + 'toEmails' => implode(',', $this->toEmails), + 'ccEmails' => implode(',', $this->ccEmails), + 'bccEmails' => implode(',', $this->bccEmails), + 'fromMail' => $this->fromMail, + ]; + } + + /** + * @return string[] + */ + private function parseEmailList(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_array($value)) { + $emails = array_map('trim', $value); + } else { + $emails = array_map('trim', explode(',', (string) $value)); + } + + $unique = []; + foreach ($emails as $email) { + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + $key = strtolower($email); + if (! isset($unique[$key])) { + $unique[$key] = $email; + } + } + + return array_values($unique); + } + + private function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true); + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 2d215227..e87c090d 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -541,6 +541,8 @@ $routes->group("/util", ["filter" => "authMVC"], function ($routes) { $routes->get("policy_tranction/sendInstallmentRemainderMail","PolicyTransactionController::sendInstallmentRemainderMail"); $routes->cli("cli/sendInstallmentRemainderMail","PolicyTransactionController::sendInstallmentRemainderMail"); +$routes->get("policy_tranction/sendRenewalReminderMail","PolicyTransactionController::sendRenewalReminderMail"); +$routes->cli("cli/sendRenewalReminderMail","PolicyTransactionController::sendRenewalReminderMail"); $routes->cli("cli/cronDailyBDSReport", "PolicyTransactionController::cronDailyBDSReport"); $routes->cli('cli/croneDailyActivityReport', 'DashboardController::croneDailyActivityReport'); @@ -561,6 +563,17 @@ $routes->group("policy_tranction", ["filter" => "authMVC"], function ($routes) { $routes->post("getMoreInfo","PolicyTransactionController::getMoreInfo"); $routes->post("saveInstallment","PolicyTransactionController::saveInstallment"); + $routes->group("retail-policy", ["filter" => "authMVC"], function ($routes) { + $routes->get('list', 'PolicyTransactionController::retailPolicyList'); + $routes->post('list-data', 'PolicyTransactionController::retailPolicyListData'); + $routes->post('mark-renewed', 'PolicyTransactionController::markRetailPolicyRenewed'); + $routes->get('config', 'PolicyTransactionController::getRetailReminderConfig'); + $routes->post('config', 'PolicyTransactionController::saveRetailReminderConfig'); + $routes->get('template', 'PolicyTransactionController::getRetailReminderTemplate'); + $routes->post('template', 'PolicyTransactionController::saveRetailReminderTemplate'); + $routes->post('send-test-mail', 'PolicyTransactionController::sendRetailReminderTestMail'); + }); + $routes->group("inception", ["filter" => "authMVC"], function ($routes) { $routes->match(['get', 'post'], 'list', 'PolicyTransactionController::viewInception'); $routes->post('list/datatable', 'PolicyTransactionController::inceptionListDataTable'); diff --git a/app/Controllers/EmployeeRestController.php b/app/Controllers/EmployeeRestController.php index 781bba3e..5ea68637 100755 --- a/app/Controllers/EmployeeRestController.php +++ b/app/Controllers/EmployeeRestController.php @@ -7732,14 +7732,16 @@ class EmployeeRestController extends AdminController $benefits = []; if (!empty($termsArray['enrollment_display_key']) && is_array($termsArray['enrollment_display_key'])) { - $benefits = $termsArray['enrollment_display_key']; + $benefits = $this->normalizePolicyTermsKeys($termsArray['enrollment_display_key']); } else { - $benefits = $this->policyTermsFiter($termsRaw, $ticketMeta['policy_group']); + $benefits = $this->normalizePolicyTermsKeys( + $this->policyTermsFiter($termsRaw, $ticketMeta['policy_group']) ?: [] + ); } $policyTerms = []; if (!empty($termsArray['enrollment_display_key']) && is_array($termsArray['enrollment_display_key'])) { - $policyTerms = $termsArray['enrollment_display_key']; + $policyTerms = $this->normalizePolicyTermsKeys($termsArray['enrollment_display_key']); } $familyFloaters = $this->extractFamilyFloaters($termsArray); @@ -8032,6 +8034,36 @@ class EmployeeRestController extends AdminController return number_format((float) $amount, 0, '.', ','); } + /** + * Normalize enrollment_display_key terms: + * - "Room Rent Limit" => room_rent_limit + * - whitespace-only values => "" + */ + private function normalizePolicyTermsKeys(array $terms): array + { + $normalized = []; + + foreach ($terms as $key => $value) { + $snakeKey = strtolower(trim((string) $key)); + $snakeKey = preg_replace('/[^a-z0-9]+/', '_', $snakeKey); + $snakeKey = trim($snakeKey, '_'); + + if (is_string($value)) { + $value = trim($value); + } elseif (is_array($value)) { + $value = $this->normalizePolicyTermsKeys($value); + } + + if ($snakeKey === '') { + continue; + } + + $normalized[$snakeKey] = $value; + } + + return $normalized; + } + private function buildPremiumDetails(array $coveredMembers, int $policyTypeId): array { $siValue = 0; diff --git a/app/Controllers/PolicyTransactionController.php b/app/Controllers/PolicyTransactionController.php index 351d674f..e8247198 100644 --- a/app/Controllers/PolicyTransactionController.php +++ b/app/Controllers/PolicyTransactionController.php @@ -39,8 +39,10 @@ use App\Helpers\ExcelSanitizeHelper; use App\Models\NhanceBranchModel; use App\Models\BDSDumpModel; use App\Models\VehicleModel; +use App\Models\NotificationModel; use CodeIgniter\CLI\CLI; use Config\BdsConfig; +use Config\PtRenewalReminderConfig; use Exception; class PolicyTransactionController extends BaseController @@ -77,6 +79,7 @@ class PolicyTransactionController extends BaseController protected $nhanceBranchModel; protected $bdsDumpModel; protected $vehicleModel; + protected $notificationModel; protected $bds_bulk_upload_excel_file_column; @@ -113,6 +116,7 @@ class PolicyTransactionController extends BaseController $this->nhanceBranchModel = new NhanceBranchModel(); $this->bdsDumpModel = new BDSDumpModel(); $this->vehicleModel = new VehicleModel(); + $this->notificationModel = new NotificationModel(); $this->invoiceStatus = [ 'pending' => 'Pending', 'generated' => 'Generated', @@ -5962,6 +5966,1425 @@ class PolicyTransactionController extends BaseController } } + public function sendRenewalReminderMail() + { + $logPrefix = '[PT Renewal Reminder]'; + $isCli = is_cli(); + $dryRun = $this->isRenewalReminderDryRun($isCli); + + $this->logRenewalReminderStep( + $logPrefix . ' Cron started' . ($dryRun ? ' (DRY RUN)' : '') + ); + + try { + /** @var PtRenewalReminderConfig $config */ + $config = config(PtRenewalReminderConfig::class); + + $configSnapshot = $config->toArray(); + $configSnapshot['notificationId'] = $config->notificationId; + $configSnapshot['dry_run'] = $dryRun; + + $this->logRenewalReminderStep( + $logPrefix . ' Config loaded: ' . json_encode($configSnapshot) + ); + + if (! $config->shouldRunToday()) { + $message = 'Fetch skipped per configuration (enabled/daily/days)'; + $this->logRenewalReminderStep($logPrefix . ' ' . $message); + if ($isCli) { + CLI::write($message); + } + + return $this->renewalReminderResponse([ + 'status' => false, + 'code' => 200, + 'message' => $message, + 'dry_run' => $dryRun, + 'config' => $configSnapshot, + 'summary' => [ + 'total' => 0, + 'sent' => 0, + 'failed' => 0, + 'skipped' => 0, + ], + 'data' => [], + ], $isCli); + } + + $this->logRenewalReminderStep( + $logPrefix . ' Fetching candidates for dateField=' + . $config->getDateField() + . ', daysBefore=' . $config->getDaysBefore() + . ', includeOverdue=' . ($config->includeOverdue() ? 'true' : 'false') + . ', clientTypes=' . json_encode($config->getClientTypes()) + ); + + $candidates = []; + try { + $candidates = $this->policyTransactionModel->getRenewalReminderCandidates( + $config->getDateField(), + $config->getDaysBefore(), + $config->getClientTypes(), + $config->includeOverdue() + ); + } catch (Exception $e) { + $message = 'Candidate fetch failed: ' . $e->getMessage(); + $this->logRenewalReminderStep( + $logPrefix . ' ' . $message + . ' | File: ' . $e->getFile() . ' Line: ' . $e->getLine() + ); + + return $this->renewalReminderResponse([ + 'status' => false, + 'code' => 500, + 'message' => $message, + 'dry_run' => $dryRun, + 'config' => $configSnapshot, + 'summary' => [ + 'total' => 0, + 'sent' => 0, + 'failed' => 0, + 'skipped' => 0, + ], + 'data' => [], + ], $isCli, 500); + } + + $candidateCount = count($candidates); + $candidateIds = array_values(array_map( + static fn ($row) => $row['id'] ?? null, + $candidates + )); + + $this->logRenewalReminderStep( + $logPrefix . ' Candidates fetched: count=' . $candidateCount + . ', pt_ids=' . json_encode($candidateIds) + ); + + if ($candidateCount === 0) { + $message = 'No policy_transaction renewal reminders due'; + $this->logRenewalReminderStep( + $logPrefix . ' ' . $message + . ' | dateField=' . $config->getDateField() + . ' | daysBefore=' . $config->getDaysBefore() + . ' | includeOverdue=' . ($config->includeOverdue() ? 'true' : 'false') + ); + if ($isCli) { + CLI::write($message); + } + + // Heavy diagnostics only for HTTP dry-run (not CLI / not live send) + $httpDryRun = $dryRun && ! $isCli; + $diagnostics = null; + $detailedSummary = null; + if ($httpDryRun) { + try { + $diagnostics = $this->policyTransactionModel->diagnoseRenewalReminderCandidates( + $config->getDateField(), + $config->getDaysBefore(), + $config->getClientTypes(), + $config->includeOverdue() + ); + } catch (Exception $diagEx) { + $diagnostics = [ + 'error' => $diagEx->getMessage(), + ]; + } + $detailedSummary = $this->buildRenewalReminderDryRunSummary( + $config, + 0, + 0, + 0, + 0, + [] + ); + } + + $emptyPayload = [ + 'status' => true, + 'code' => 200, + 'message' => $message, + 'dry_run' => $dryRun, + 'summary' => [ + 'total' => 0, + 'sent' => 0, + 'would_send' => 0, + 'failed' => 0, + 'skipped' => 0, + ], + 'data' => [], + ]; + + if ($httpDryRun) { + $emptyPayload['config'] = $configSnapshot; + $emptyPayload['detailed_summary'] = $detailedSummary; + $emptyPayload['why_empty'] = $diagnostics; + } + + return $this->renewalReminderResponse($emptyPayload, $isCli); + } + + $results = []; + $successCount = 0; + $failedCount = 0; + $skippedCount = 0; + $httpDryRun = $dryRun && ! $isCli; + + foreach ($candidates as $index => $row) { + $ptId = $row['id'] ?? 'unknown'; + $stepNo = $index + 1; + $isOverdue = ! empty($row['is_overdue']); + + $this->logRenewalReminderStep( + $logPrefix . " Step {$stepNo}/{$candidateCount}: preparing mail for PT {$ptId}" + . ' | client_id=' . ($row['client_id'] ?? '') + . ' | client_type=' . ($row['client_type'] ?? '') + . ' | policy_no=' . ($row['policy_no'] ?? '') + . ' | is_overdue=' . ($isOverdue ? 'true' : 'false') + ); + + try { + $mailData = $this->prepareRenewalReminderMailData($row, $config, $dryRun); + + if (empty($mailData) || empty($mailData['to_mail'])) { + $skippedCount++; + $skipMsg = "PT {$ptId}: skipped — no valid recipient emails"; + $this->logRenewalReminderStep($logPrefix . ' ' . $skipMsg); + if ($isCli) { + CLI::write($skipMsg); + } + + if ($httpDryRun) { + $results[] = [ + 'pt_id' => $ptId, + 'client_id' => $row['client_id'] ?? null, + 'client_name' => $row['client_name'] ?? null, + 'client_email'=> $row['client_email'] ?? ($mailData['client_email'] ?? null), + 'client_type' => $row['client_type'] ?? null, + 'policy_no' => $row['policy_no'] ?? null, + 'policy_type' => $row['policy_type'] ?? null, + 'insurer_name'=> $row['insurer_name'] ?? null, + 'renewal_date'=> $row['renewal_date'] ?? null, + 'policy_end_date' => $row['policy_end_date'] ?? null, + 'is_overdue' => $isOverdue, + 'status' => 'skipped', + 'message' => 'No valid recipient emails', + 'include_client_email_enabled' => $mailData['include_client_email_enabled'] ?? $config->includeClientEmail(), + 'client_email_included' => $mailData['client_email_included'] ?? false, + 'recipient_sources' => $mailData['recipient_sources'] ?? [ + 'client_email' => [], + 'branch_hr' => [], + 'configured_to' => [], + ], + 'to' => [], + 'to_count' => 0, + 'cc' => $mailData['cc'] ?? [], + 'bcc' => $mailData['bcc'] ?? [], + ]; + } elseif (! $dryRun) { + $results[] = [ + 'pt_id' => $ptId, + 'client_id' => $row['client_id'] ?? null, + 'client_name' => $row['client_name'] ?? null, + 'policy_no' => $row['policy_no'] ?? null, + 'is_overdue' => $isOverdue, + 'status' => 'skipped', + 'message' => 'No valid recipient emails', + 'to' => [], + 'cc' => $mailData['cc'] ?? [], + 'bcc' => $mailData['bcc'] ?? [], + ]; + } + continue; + } + + if ($dryRun) { + $successCount++; + $dryMsg = "PT {$ptId}: dry run — would send mail to " + . count($mailData['to_mail']) . ' recipient(s)' + . ' | client_email_included=' . (! empty($mailData['client_email_included']) ? 'true' : 'false'); + $this->logRenewalReminderStep($logPrefix . ' ' . $dryMsg); + if ($isCli) { + CLI::write($dryMsg); + } + + if ($httpDryRun) { + $dateField = $config->getDateField(); + $reminderRaw = $row[$dateField] ?? ($row['renewal_date'] ?? $row['policy_end_date'] ?? null); + + $results[] = [ + 'pt_id' => $ptId, + 'client_id' => $row['client_id'] ?? null, + 'client_name' => $row['client_name'] ?? null, + 'client_email' => $mailData['client_email'] ?? ($row['client_email'] ?? null), + 'client_type' => $row['client_type'] ?? null, + 'policy_no' => $row['policy_no'] ?? null, + 'policy_type' => $row['policy_type'] ?? null, + 'insurer_name' => $row['insurer_name'] ?? null, + 'renewal_date' => $row['renewal_date'] ?? null, + 'policy_start_date' => $row['policy_start_date'] ?? null, + 'policy_end_date' => $row['policy_end_date'] ?? null, + 'reminder_date' => ! empty($reminderRaw) ? date('Y-m-d', strtotime((string) $reminderRaw)) : null, + 'is_overdue' => $isOverdue, + 'status' => 'dry_run', + 'message' => 'Dry run — mail not sent, status not updated', + 'include_client_email_enabled' => $mailData['include_client_email_enabled'] ?? $config->includeClientEmail(), + 'client_email_included' => $mailData['client_email_included'] ?? false, + 'recipient_sources' => $mailData['recipient_sources'] ?? [ + 'client_email' => [], + 'branch_hr' => [], + 'configured_to' => [], + ], + 'subject' => $mailData['subject'] ?? null, + 'to' => $mailData['to_mail'], + 'to_count' => count($mailData['to_mail']), + 'cc' => $mailData['cc'] ?? [], + 'cc_count' => count($mailData['cc'] ?? []), + 'bcc' => $mailData['bcc'] ?? [], + 'bcc_count' => count($mailData['bcc'] ?? []), + 'from' => $mailData['from_mail'] ?? null, + ]; + } + continue; + } + + $this->logRenewalReminderStep( + $logPrefix . " Step {$stepNo}/{$candidateCount}: sending mail for PT {$ptId}" + . ' | subject=' . ($mailData['subject'] ?? '') + . ' | to=' . json_encode($mailData['to_mail']) + . ' | cc=' . json_encode($mailData['cc'] ?? []) + . ' | bcc=' . json_encode($mailData['bcc'] ?? []) + . ' | from=' . ($mailData['from_mail'] ?? '') + ); + + $payload = [ + 'mail' => $mailData['to_mail'], + 'subject' => $mailData['subject'], + 'message' => $mailData['message'], + 'common' => ['mail_type' => 'pt_renewal_reminder_mail'], + ]; + + if (! empty($mailData['cc'])) { + $payload['cc'] = implode(',', $mailData['cc']); + } + if (! empty($mailData['bcc'])) { + $payload['bcc'] = implode(',', $mailData['bcc']); + } + if (! empty($mailData['from_mail'])) { + $payload['from_mail'] = $mailData['from_mail']; + } + + $rawRes = MailHelper::send_email($payload); + $res = json_decode($rawRes); + + $this->logRenewalReminderStep( + $logPrefix . " Step {$stepNo}/{$candidateCount}: mail API result for PT {$ptId}: " + . (is_string($rawRes) ? $rawRes : json_encode($res)) + ); + + if (isset($res->status) && $res->status === 'success') { + $successCount++; + try { + $this->policyTransactionModel->update((int) $ptId, [ + 'renewal_status' => 'renewal_mail_send', + ]); + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: renewal_status set to renewal_mail_send" + ); + } catch (Exception $statusEx) { + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: failed to update renewal_status — " + . $statusEx->getMessage() + ); + } + + $okMsg = "PT {$ptId}: mail sent successfully to " + . count($mailData['to_mail']) . ' recipient(s)'; + $this->logRenewalReminderStep($logPrefix . ' ' . $okMsg); + if ($isCli) { + CLI::write($okMsg); + } + + $results[] = [ + 'pt_id' => $ptId, + 'client_id' => $row['client_id'] ?? null, + 'client_name' => $row['client_name'] ?? null, + 'policy_no' => $row['policy_no'] ?? null, + 'is_overdue' => $isOverdue, + 'status' => 'success', + 'message' => 'Mail sent successfully', + 'subject' => $mailData['subject'] ?? null, + 'to' => $mailData['to_mail'], + 'cc' => $mailData['cc'] ?? [], + 'bcc' => $mailData['bcc'] ?? [], + 'from' => $mailData['from_mail'] ?? null, + ]; + } else { + $failedCount++; + $failMsg = "PT {$ptId}: mail send failed"; + $this->logRenewalReminderStep( + $logPrefix . ' ' . $failMsg . ' | response=' . json_encode($res) + ); + if ($isCli) { + CLI::write($failMsg); + } + + $results[] = [ + 'pt_id' => $ptId, + 'client_id' => $row['client_id'] ?? null, + 'client_name' => $row['client_name'] ?? null, + 'policy_no' => $row['policy_no'] ?? null, + 'is_overdue' => $isOverdue, + 'status' => 'failed', + 'message' => 'Mail send failed', + 'subject' => $mailData['subject'] ?? null, + 'to' => $mailData['to_mail'], + 'cc' => $mailData['cc'] ?? [], + 'bcc' => $mailData['bcc'] ?? [], + 'from' => $mailData['from_mail'] ?? null, + 'mail_response' => $res, + ]; + } + } catch (Exception $e) { + $failedCount++; + $failMsg = "PT {$ptId}: exception — " . $e->getMessage(); + $this->logRenewalReminderStep( + $logPrefix . ' ' . $failMsg + . ' | File: ' . $e->getFile() . ' Line: ' . $e->getLine() + ); + if ($isCli) { + CLI::write($failMsg); + } + + $results[] = [ + 'pt_id' => $ptId, + 'client_id' => $row['client_id'] ?? null, + 'client_name' => $row['client_name'] ?? null, + 'policy_no' => $row['policy_no'] ?? null, + 'is_overdue' => $isOverdue, + 'status' => 'failed', + 'message' => $e->getMessage(), + ]; + } + } + + $summaryMessage = ($dryRun ? 'DRY RUN — ' : '') + . "Processed {$candidateCount} renewal reminder(s): " + . ($dryRun + ? "{$successCount} would send, {$failedCount} failed, {$skippedCount} skipped" + : "{$successCount} sent, {$failedCount} failed, {$skippedCount} skipped"); + + $this->logRenewalReminderStep($logPrefix . ' Cron completed — ' . $summaryMessage); + if ($isCli) { + CLI::write($summaryMessage); + } + + $responsePayload = [ + 'status' => $successCount > 0 || ($dryRun && $candidateCount > 0), + 'code' => 200, + 'message' => $summaryMessage, + 'dry_run' => $dryRun, + 'summary' => [ + 'total' => $candidateCount, + 'sent' => $dryRun ? 0 : $successCount, + 'would_send' => $dryRun ? $successCount : 0, + 'failed' => $failedCount, + 'skipped' => $skippedCount, + ], + 'data' => $results, + ]; + + // Detailed dry-run payload only for HTTP dry-run (skip on CLI for speed) + if ($httpDryRun) { + $detailedSummary = $this->buildRenewalReminderDryRunSummary( + $config, + $candidateCount, + $successCount, + $skippedCount, + $failedCount, + $results + ); + $responsePayload['config'] = $configSnapshot; + $responsePayload['detailed_summary'] = $detailedSummary; + $responsePayload['message'] = $detailedSummary['headline'] ?? $summaryMessage; + } + + return $this->renewalReminderResponse($responsePayload, $isCli); + } catch (Exception $e) { + $message = 'Unhandled exception: ' . $e->getMessage(); + $this->logRenewalReminderStep( + $logPrefix . ' ' . $message + . ' | File: ' . $e->getFile() . ' Line: ' . $e->getLine() + ); + + return $this->renewalReminderResponse([ + 'status' => false, + 'code' => 500, + 'message' => $message, + 'dry_run' => $dryRun, + 'summary' => [ + 'total' => 0, + 'sent' => 0, + 'failed' => 0, + 'skipped' => 0, + ], + 'data' => [], + ], $isCli, 500); + } + } + + /** + * Detect dry-run mode for HTTP (?dry_run=1) or CLI (--dry-run / dry_run). + */ + protected function isRenewalReminderDryRun(bool $isCli): bool + { + if ($isCli) { + $argv = $_SERVER['argv'] ?? []; + foreach ($argv as $arg) { + $normalized = strtolower(ltrim((string) $arg, '-')); + if (in_array($normalized, ['dry-run', 'dry_run', 'dryrun'], true)) { + return true; + } + } + + if (class_exists(CLI::class)) { + if (CLI::getOption('dry-run') !== null || CLI::getOption('dry_run') !== null) { + return true; + } + } + + return false; + } + + $param = $this->request->getGet('dry_run') + ?? $this->request->getGet('dryRun') + ?? $this->request->getPost('dry_run') + ?? $this->request->getPost('dryRun') + ?? '0'; + + return in_array(strtolower(trim((string) $param)), ['1', 'true', 'yes', 'on'], true); + } + + /** + * Build a detailed dry-run summary for renewal reminder mails. + * + * @param PtRenewalReminderConfig $config + * @param array> $results + * @return array + */ + protected function buildRenewalReminderDryRunSummary( + $config, + int $candidateCount, + int $wouldSendCount, + int $skippedCount, + int $failedCount, + array $results + ): array { + $overdueCount = 0; + $dueCount = 0; + $uniqueTo = []; + $uniqueCc = []; + $uniqueBcc = []; + $totalToHits = 0; + $totalCcHits = 0; + $totalBccHits = 0; + $byPolicyType = []; + $pendingPolicies = []; + $skippedPolicies = []; + $policiesWithoutEmail = 0; + $policiesWithoutPolicyNo = 0; + $policiesWithClientEmailInTo = 0; + $policiesMissingClientEmail = 0; + $clientEmailsInTo = []; + + foreach ($results as $item) { + $status = (string) ($item['status'] ?? ''); + $isOverdue = ! empty($item['is_overdue']); + + if ($isOverdue) { + $overdueCount++; + } else { + $dueCount++; + } + + $policyType = trim((string) ($item['policy_type'] ?? '')); + if ($policyType === '') { + $policyType = 'Unknown'; + } + if (! isset($byPolicyType[$policyType])) { + $byPolicyType[$policyType] = [ + 'policy_type' => $policyType, + 'total' => 0, + 'would_send' => 0, + 'skipped' => 0, + ]; + } + $byPolicyType[$policyType]['total']++; + + $policyNo = trim((string) ($item['policy_no'] ?? '')); + if ($policyNo === '') { + $policiesWithoutPolicyNo++; + } + + $clientEmailIncluded = ! empty($item['client_email_included']); + if ($clientEmailIncluded) { + $policiesWithClientEmailInTo++; + foreach (($item['recipient_sources']['client_email'] ?? []) as $email) { + $email = strtolower(trim((string) $email)); + if ($email !== '') { + $clientEmailsInTo[$email] = true; + } + } + } elseif (! empty($item['include_client_email_enabled'])) { + $policiesMissingClientEmail++; + } + + $compact = [ + 'pt_id' => $item['pt_id'] ?? null, + 'policy_no' => $policyNo !== '' ? $policyNo : 'Not Assigned', + 'client_name' => $item['client_name'] ?? null, + 'client_email' => $item['client_email'] ?? null, + 'client_email_included' => $clientEmailIncluded, + 'include_client_email_enabled' => ! empty($item['include_client_email_enabled']), + 'policy_type' => $item['policy_type'] ?? null, + 'reminder_date' => $item['reminder_date'] ?? ($item['renewal_date'] ?? $item['policy_end_date'] ?? null), + 'is_overdue' => $isOverdue, + 'to' => $item['to'] ?? [], + 'to_count' => (int) ($item['to_count'] ?? count($item['to'] ?? [])), + 'recipient_sources' => $item['recipient_sources'] ?? null, + 'status' => $status, + ]; + + if ($status === 'dry_run') { + $byPolicyType[$policyType]['would_send']++; + $pendingPolicies[] = $compact; + + foreach (($item['to'] ?? []) as $email) { + $email = strtolower(trim((string) $email)); + if ($email === '') { + continue; + } + $uniqueTo[$email] = true; + $totalToHits++; + } + foreach (($item['cc'] ?? []) as $email) { + $email = strtolower(trim((string) $email)); + if ($email === '') { + continue; + } + $uniqueCc[$email] = true; + $totalCcHits++; + } + foreach (($item['bcc'] ?? []) as $email) { + $email = strtolower(trim((string) $email)); + if ($email === '') { + continue; + } + $uniqueBcc[$email] = true; + $totalBccHits++; + } + } elseif ($status === 'skipped') { + $byPolicyType[$policyType]['skipped']++; + $policiesWithoutEmail++; + $skippedPolicies[] = $compact; + } + } + + $targetDate = date('Y-m-d', strtotime('+' . max(0, (int) $config->getDaysBefore()) . ' days')); + + $headline = sprintf( + 'DRY RUN — %d policy(ies) pending for renewal reminder: %d ready to mail, %d skipped (no email), %d failed | client email in TO: %d, missing client email: %d', + $candidateCount, + $wouldSendCount, + $skippedCount, + $failedCount, + $policiesWithClientEmailInTo, + $policiesMissingClientEmail + ); + + $lines = [ + 'Policies pending for renewal reminder: ' . $candidateCount, + 'Policies ready to mail: ' . $wouldSendCount, + 'Policies skipped (no valid email): ' . $skippedCount, + 'Policies failed during prepare: ' . $failedCount, + 'Include client email enabled: ' . ($config->includeClientEmail() ? 'yes' : 'no'), + 'Policies with client email in TO: ' . $policiesWithClientEmailInTo, + 'Policies missing/invalid client email: ' . $policiesMissingClientEmail, + 'Unique client emails in TO: ' . count($clientEmailsInTo), + 'Due on target date (' . $targetDate . '): ' . $dueCount, + 'Overdue policies included: ' . $overdueCount, + 'Unique TO recipients: ' . count($uniqueTo), + 'Unique CC recipients: ' . count($uniqueCc), + 'Unique BCC recipients: ' . count($uniqueBcc), + 'Total TO deliveries (per mail): ' . $totalToHits, + 'Policies without policy no: ' . $policiesWithoutPolicyNo, + 'Date field: ' . $config->getDateField(), + 'Days before: ' . $config->getDaysBefore(), + 'Include overdue: ' . ($config->includeOverdue() ? 'yes' : 'no'), + ]; + + return [ + 'headline' => $headline, + 'lines' => $lines, + 'counts' => [ + 'policies_pending_renewal' => $candidateCount, + 'policies_ready_to_mail' => $wouldSendCount, + 'policies_skipped_no_email' => $skippedCount, + 'policies_failed' => $failedCount, + 'policies_due' => $dueCount, + 'policies_overdue' => $overdueCount, + 'policies_without_policy_no' => $policiesWithoutPolicyNo, + 'policies_without_email' => $policiesWithoutEmail, + 'include_client_email_enabled' => $config->includeClientEmail(), + 'policies_with_client_email_in_to' => $policiesWithClientEmailInTo, + 'policies_missing_client_email' => $policiesMissingClientEmail, + 'unique_client_emails_in_to' => count($clientEmailsInTo), + 'unique_to_recipients' => count($uniqueTo), + 'unique_cc_recipients' => count($uniqueCc), + 'unique_bcc_recipients' => count($uniqueBcc), + 'total_to_deliveries' => $totalToHits, + 'total_cc_deliveries' => $totalCcHits, + 'total_bcc_deliveries' => $totalBccHits, + 'mails_that_would_be_sent' => $wouldSendCount, + ], + 'filters' => [ + 'date_field' => $config->getDateField(), + 'days_before' => $config->getDaysBefore(), + 'target_date' => $targetDate, + 'include_overdue' => $config->includeOverdue(), + 'include_client_email' => $config->includeClientEmail(), + 'client_types' => $config->getClientTypes(), + 'configured_to_emails' => $config->getToEmails(), + 'configured_cc_emails' => $config->getCcEmails(), + 'configured_bcc_emails' => $config->getBccEmails(), + ], + 'by_policy_type' => array_values($byPolicyType), + 'unique_to_emails' => array_keys($uniqueTo), + 'unique_client_emails' => array_keys($clientEmailsInTo), + 'unique_cc_emails' => array_keys($uniqueCc), + 'unique_bcc_emails' => array_keys($uniqueBcc), + 'policies_ready' => $pendingPolicies, + 'policies_skipped' => $skippedPolicies, + ]; + } + + /** + * Structured JSON for HTTP; plain array for CLI. + * + * @param array $payload + * @param bool $isCli + * @param int $httpStatus + * @return array|\CodeIgniter\HTTP\ResponseInterface + */ + protected function renewalReminderResponse(array $payload, bool $isCli, int $httpStatus = 200) + { + if ($isCli) { + return $payload; + } + + return $this->respond($payload, $httpStatus); + } + + /** + * Write to CI log and app myLogger for every renewal-reminder step. + */ + protected function logRenewalReminderStep(string $message): void + { + log_message('error', $message); + if ($this->myLogger) { + $this->myLogger->logme('error', $message); + } + } + + /** + * @param array $data + * @param PtRenewalReminderConfig $config + * @param bool $skipMailBody Skip HTML body build (faster for dry-run) + * @return array|null + */ + protected function prepareRenewalReminderMailData(array $data, PtRenewalReminderConfig $config, bool $skipMailBody = false) + { + $logPrefix = '[PT Renewal Reminder]'; + $ptId = $data['id'] ?? 'unknown'; + + try { + $this->logRenewalReminderStep( + $logPrefix . " Preparing mail payload for PT {$ptId}" + ); + + $policyNo = trim((string) ($data['policy_no'] ?? '')); + $clientName = (string) ($data['client_name'] ?? ''); + $clientShortName = ! empty($data['client_short_name']) + ? (string) $data['client_short_name'] + : $clientName; + $clientType = (int) ($data['client_type'] ?? 0); + $clientTypeLabel = $clientType === 1 ? 'Group' : ($clientType === 2 ? 'Individual' : ''); + $dateField = $config->getDateField(); + $reminderRaw = $data[$dateField] ?? ($data['renewal_date'] ?? $data['policy_end_date'] ?? null); + $reminderDate = ! empty($reminderRaw) ? date('d-m-Y', strtotime((string) $reminderRaw)) : ''; + $daysBefore = $config->getDaysBefore(); + $isOverdue = ! empty($data['is_overdue']) + || (! empty($reminderRaw) && strtotime((string) $reminderRaw) < strtotime(date('Y-m-d'))); + $policyLabel = $policyNo !== '' ? "Policy #{$policyNo}" : 'Policy Pending'; + + $policyPeriod = ''; + if (! empty($data['policy_start_date']) && ! empty($data['policy_end_date'])) { + $policyPeriod = date('d-m-Y', strtotime($data['policy_start_date'])) + . ' to ' + . date('d-m-Y', strtotime($data['policy_end_date'])); + } + + $subject = $isOverdue + ? "Overdue Policy Renewal Reminder - {$clientShortName} | {$policyLabel} | Due {$reminderDate}" + : "Policy Renewal Reminder - {$clientShortName} | {$policyLabel} | Due {$reminderDate}"; + + $placeholderVars = [ + 'client_name' => $clientName, + 'client_short_name' => $clientShortName, + 'policy_no' => $policyNo !== '' ? $policyNo : 'Not Assigned', + 'policy_type' => (string) ($data['policy_type'] ?? ''), + 'insurer_name' => (string) ($data['insurer_name'] ?? ''), + 'policy_period' => $policyPeriod, + 'reminder_date' => $reminderDate, + 'days_before' => (string) $daysBefore, + 'client_type' => $clientTypeLabel, + ]; + + if ($config->getMailSubject() !== '') { + $subject = $this->replaceRetailReminderPlaceholders($config->getMailSubject(), $placeholderVars); + } + + $message = ''; + if (! $skipMailBody) { + if (trim($config->getMailContent()) !== '') { + $message = $this->replaceRetailReminderPlaceholders($config->getMailContent(), $placeholderVars); + } else { + $message = view('pt_renewal_reminder_email_template', [ + 'client_name' => $clientName, + 'client_type_label' => $clientTypeLabel, + 'policy_no' => $policyNo !== '' ? $policyNo : 'Not Assigned', + 'policy_type' => $data['policy_type'] ?? '', + 'insurer_name' => $data['insurer_name'] ?? '', + 'policy_period' => $policyPeriod, + 'reminder_date' => $reminderDate, + 'days_before' => $daysBefore, + 'is_overdue' => $isOverdue, + 'generated_on' => date('d-m-Y H:i'), + ]); + } + } + + $toMails = []; + $clientEmailRaw = trim((string) ($data['client_email'] ?? '')); + $clientEmailIncluded = false; + $clientEmailSource = []; + $branchHrSource = []; + $configuredToSource = []; + + // Retail (individual) and group: when enabled, add clients.email to TO + if ($config->includeClientEmail()) { + if ($clientEmailRaw !== '' && filter_var($clientEmailRaw, FILTER_VALIDATE_EMAIL)) { + $toMails[] = $clientEmailRaw; + $clientEmailIncluded = true; + $clientEmailSource[] = $clientEmailRaw; + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: added client email to TO: {$clientEmailRaw}" + . ' | client_type=' . $clientType + ); + } else { + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: includeClientEmail=true but client email missing/invalid" + . ' | client_type=' . $clientType + . ' | client_email=' . json_encode($clientEmailRaw) + ); + } + } else { + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: includeClientEmail=false — client email not added to TO" + ); + } + + if ($clientType === 1) { + $branchId = (int) ($data['client_branch_id'] ?? 0); + if ($branchId > 0) { + $db = \Config\Database::connect(); + $branchHrEmails = $db->table('level_contacts') + ->select('email') + ->where('ref_id', $branchId) + ->where('contact_type', 'client') + ->where('is_active', 1) + ->where('email IS NOT NULL', null, false) + ->where("TRIM(email) != ''", null, false) + ->get() + ->getResultArray(); + + $hrList = []; + foreach ($branchHrEmails as $branchHrEmailRow) { + $hrEmail = trim((string) ($branchHrEmailRow['email'] ?? '')); + if ($hrEmail !== '' && filter_var($hrEmail, FILTER_VALIDATE_EMAIL)) { + $toMails[] = $hrEmail; + $branchHrSource[] = $hrEmail; + $hrList[] = $hrEmail; + } + } + + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: branch HR emails for branch_id={$branchId}: " + . json_encode($hrList) + ); + } else { + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: group client has no client_branch_id for HR lookup" + ); + } + } + + foreach ($config->getToEmails() as $envToEmail) { + $envToEmail = trim((string) $envToEmail); + if ($envToEmail === '') { + continue; + } + $toMails[] = $envToEmail; + $configuredToSource[] = $envToEmail; + } + + $uniqueTo = []; + foreach ($toMails as $email) { + $email = trim((string) $email); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + $emailKey = strtolower($email); + if (! isset($uniqueTo[$emailKey])) { + $uniqueTo[$emailKey] = $email; + } + } + $toMails = array_values($uniqueTo); + + $fromMail = $config->getFromMail(); + if ($fromMail === '') { + $fromMail = (string) (getenv('email.fromEmail') ?: ''); + } + + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: final recipients" + . ' | to=' . json_encode($toMails) + . ' | cc=' . json_encode($config->getCcEmails()) + . ' | bcc=' . json_encode($config->getBccEmails()) + . ' | from=' . $fromMail + . ' | subject=' . $subject + . ' | client_email_included=' . ($clientEmailIncluded ? 'true' : 'false') + ); + + return [ + 'to_mail' => $toMails, + 'cc' => $config->getCcEmails(), + 'bcc' => $config->getBccEmails(), + 'from_mail' => $fromMail, + 'message' => $message, + 'subject' => $subject, + 'client_email' => $clientEmailRaw !== '' ? $clientEmailRaw : null, + 'client_email_included' => $clientEmailIncluded, + 'include_client_email_enabled' => $config->includeClientEmail(), + 'recipient_sources' => [ + 'client_email' => $clientEmailSource, + 'branch_hr' => array_values(array_unique($branchHrSource)), + 'configured_to' => array_values(array_unique($configuredToSource)), + ], + ]; + } catch (Exception $e) { + $this->logRenewalReminderStep( + $logPrefix . " PT {$ptId}: prepareRenewalReminderMailData exception — " + . $e->getMessage() + . ' | File: ' . $e->getFile() . ' Line: ' . $e->getLine() + ); + return null; + } + } + + /** + * Replace {{placeholder}} tokens in retail reminder subject/body. + * + * @param array $vars + */ + protected function replaceRetailReminderPlaceholders(string $content, array $vars): string + { + foreach ($vars as $key => $value) { + $content = str_replace('{{' . $key . '}}', (string) $value, $content); + } + + return $content; + } + + public function retailPolicyList() + { + $data = [ + 'page_name' => 'Retail Policy', + 'retail_default_from_mail' => $this->resolveRetailDefaultFromMail(), + ]; + + return $this->loadLayout('retail_policy_list', $data); + } + + public function retailPolicyListData() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => false, + 'message' => 'Invalid request.', + ]); + } + + $draw = (int) ($this->request->getPost('draw') ?? 0); + $start = max(0, (int) ($this->request->getPost('start') ?? 0)); + $length = (int) ($this->request->getPost('length') ?? 10); + $search = trim((string) ($this->request->getPost('search')['value'] ?? '')); + + $result = $this->policyTransactionModel->getRetailPolicyList($draw, $start, $length, $search); + + $rows = []; + foreach ($result['data'] as $index => $row) { + $status = strtolower(trim((string) ($row['renewal_status'] ?? ''))); + if ($status === '') { + $status = 'pending'; + } + + // Treat legacy "reminded" the same as renewal_mail_send + if ($status === 'reminded') { + $status = 'renewal_mail_send'; + } + + if ($status === 'renewal_mail_send') { + $statusHtml = 'Mail Sent'; + } elseif ($status === 'renewed') { + $statusHtml = 'Renewed'; + } else { + $statusHtml = 'Pending'; + } + + $actionHtml = '-'; + if ($status === 'renewal_mail_send') { + $ptId = (int) ($row['id'] ?? 0); + $actionHtml = ''; + } + + $rows[] = [ + $start + $index + 1, + esc($row['policy_no'] ?? '-'), + esc($row['policy_type'] ?? '-'), + esc($row['client_name'] ?? '-'), + esc($row['client_mobile'] ?? '-'), + esc($row['client_email'] ?? '-'), + ! empty($row['renewal_date']) ? $this->formatRetailDisplayDate($row['renewal_date']) : '-', + $this->formatRetailPolicyPeriod($row), + $statusHtml, + $actionHtml, + ]; + } + + return $this->response->setJSON([ + 'draw' => $result['draw'], + 'recordsTotal' => $result['recordsTotal'], + 'recordsFiltered' => $result['recordsFiltered'], + 'data' => $rows, + ]); + } + + public function markRetailPolicyRenewed() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => false, + 'message' => 'Invalid request.', + ]); + } + + $ptId = (int) ($this->request->getPost('id') ?? 0); + if ($ptId <= 0) { + return $this->respond([ + 'status' => false, + 'message' => 'Invalid policy id.', + ], 400); + } + + $row = $this->policyTransactionModel->find($ptId); + if (empty($row) || (int) ($row['is_active'] ?? 0) !== 1) { + return $this->respond([ + 'status' => false, + 'message' => 'Policy not found.', + ], 404); + } + + $currentStatus = strtolower(trim((string) ($row['renewal_status'] ?? ''))); + if (! in_array($currentStatus, ['renewal_mail_send', 'reminded'], true)) { + return $this->respond([ + 'status' => false, + 'message' => 'Only policies with mail sent status can be marked as renewed.', + ], 400); + } + + try { + $this->policyTransactionModel->update($ptId, [ + 'renewal_status' => 'renewed', + ]); + } catch (Exception $e) { + return $this->respond([ + 'status' => false, + 'message' => 'Failed to update status.', + ], 500); + } + + return $this->respond([ + 'status' => true, + 'message' => 'Policy marked as renewed.', + ], 200); + } + + public function getRetailReminderConfig() + { + $row = $this->getOrCreateRetailReminderNotification(); + $configJson = []; + if (! empty($row['config_json'])) { + $decoded = json_decode($row['config_json'], true); + if (is_array($decoded)) { + $configJson = $decoded; + } + } + + /** @var PtRenewalReminderConfig $config */ + $config = config(PtRenewalReminderConfig::class); + $merged = array_merge($config->toArray(), $configJson); + $merged['clientTypes'] = [2]; + $merged = $this->normalizeRetailReminderConfigForUi($merged); + + return $this->respond([ + 'status' => true, + 'message' => 'Config loaded', + 'data' => $merged, + 'notification_id' => $row['id'] ?? null, + ], 200); + } + + public function saveRetailReminderConfig() + { + $row = $this->getOrCreateRetailReminderNotification(); + + $payload = [ + 'enabled' => $this->request->getPost('enabled'), + 'daily' => $this->request->getPost('daily'), + 'days' => trim((string) $this->request->getPost('days')), + 'clientTypes' => [2], + 'dateField' => trim((string) $this->request->getPost('dateField')), + 'daysBefore' => (int) $this->request->getPost('daysBefore'), + 'includeClientEmail' => $this->request->getPost('includeClientEmail'), + 'includeOverdue' => $this->request->getPost('includeOverdue'), + 'toEmails' => trim((string) $this->request->getPost('toEmails')), + 'ccEmails' => trim((string) $this->request->getPost('ccEmails')), + 'bccEmails' => trim((string) $this->request->getPost('bccEmails')), + 'fromMail' => trim((string) $this->request->getPost('fromMail')), + ]; + + $payload['enabled'] = in_array(strtolower((string) $payload['enabled']), ['1', 'true', 'on', 'yes'], true); + $payload['daily'] = in_array(strtolower((string) $payload['daily']), ['1', 'true', 'on', 'yes'], true); + $payload['includeClientEmail'] = in_array(strtolower((string) $payload['includeClientEmail']), ['1', 'true', 'on', 'yes'], true); + $payload['includeOverdue'] = in_array(strtolower((string) $payload['includeOverdue']), ['1', 'true', 'on', 'yes'], true); + + if (! in_array($payload['dateField'], ['renewal_date', 'policy_end_date'], true)) { + $payload['dateField'] = 'renewal_date'; + } + $payload['daysBefore'] = max(0, $payload['daysBefore']); + + $updated = $this->notificationModel->update((int) $row['id'], [ + 'config_json' => json_encode($payload), + 'enabled' => $payload['enabled'] ? 1 : 0, + ]); + + if ($updated === false && ! empty($this->notificationModel->errors())) { + return $this->respond([ + 'status' => false, + 'message' => 'Failed to save mail config', + 'errors' => $this->notificationModel->errors(), + ], 500); + } + + return $this->respond([ + 'status' => true, + 'message' => 'Mail config saved successfully', + 'data' => $payload, + ], 200); + } + + /** + * @param array $config + * @return array + */ + protected function normalizeRetailReminderConfigForUi(array $config): array + { + if (is_array($config['days'] ?? null)) { + $config['days'] = implode(',', $config['days']); + } + + foreach (['toEmails', 'ccEmails', 'bccEmails'] as $field) { + if (is_array($config[$field] ?? null)) { + $config[$field] = implode(',', $config[$field]); + } + } + + if (trim((string) ($config['fromMail'] ?? '')) === '') { + $config['fromMail'] = $this->resolveRetailDefaultFromMail(); + } + + return $config; + } + + protected function resolveRetailDefaultFromMail(): string + { + $from = trim((string) env('pt.renewalReminder.fromMail', '')); + if ($from !== '') { + return $from; + } + + $from = trim((string) env('email.fromEmail', '')); + if ($from !== '') { + return $from; + } + + return trim((string) (getenv('email.fromEmail') ?: '')); + } + + /** + * @param array $row + */ + protected function formatRetailPolicyPeriod(array $row): string + { + $start = trim((string) ($row['policy_start_date'] ?? '')); + $end = trim((string) ($row['policy_end_date'] ?? '')); + + if ($start !== '' && $end !== '') { + return $this->formatRetailDisplayDate($start) . ' to ' . $this->formatRetailDisplayDate($end); + } + + if ($end !== '') { + return $this->formatRetailDisplayDate($end); + } + + if ($start !== '') { + return $this->formatRetailDisplayDate($start); + } + + return '-'; + } + + protected function formatRetailDisplayDate(?string $date): string + { + $date = trim((string) $date); + if ($date === '') { + return '-'; + } + + $timestamp = strtotime($date); + if ($timestamp === false) { + return $date; + } + + return date('d/m/Y', $timestamp); + } + + public function getRetailReminderTemplate() + { + $row = $this->getOrCreateRetailReminderNotification(); + + return $this->respond([ + 'status' => true, + 'message' => 'Template loaded', + 'data' => [ + 'id' => $row['id'] ?? null, + 'template_name' => $row['template_name'] ?? PtRenewalReminderConfig::TEMPLATE_NAME, + 'subject' => $row['subject'] ?? '', + 'mail_content' => $row['mail_content'] ?? '', + 'mail_content_json'=> $row['mail_content_json'] ?? '', + 'enabled' => (int) ($row['enabled'] ?? 0), + ], + ], 200); + } + + public function saveRetailReminderTemplate() + { + $row = $this->getOrCreateRetailReminderNotification(); + + $subject = trim((string) $this->request->getPost('subject')); + $mailContent = (string) ( + $this->request->getPost('mailContent') ?? $this->request->getPost('mail_content') ?? '' + ); + $mailJson = (string) ( + $this->request->getPost('mailJson') ?? $this->request->getPost('mail_content_json') ?? '' + ); + + $updated = $this->notificationModel->update((int) $row['id'], [ + 'subject' => $subject, + 'mail_content' => $mailContent, + 'mail_content_json' => $mailJson !== '' ? $mailJson : null, + 'updated_by' => get_session_userid(), + ]); + + if ($updated === false && ! empty($this->notificationModel->errors())) { + return $this->respond([ + 'status' => false, + 'message' => 'Failed to save email template', + 'errors' => $this->notificationModel->errors(), + ], 500); + } + + return $this->respond([ + 'status' => true, + 'message' => 'Email template saved successfully', + ], 200); + } + + public function sendRetailReminderTestMail() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => false, + 'message' => 'Invalid request.', + ]); + } + + $testEmail = trim((string) ( + $this->request->getPost('test_email') ?? $this->request->getPost('testEmail') ?? '' + )); + $subject = trim((string) $this->request->getPost('subject')); + $mailContent = (string) ( + $this->request->getPost('mailContent') ?? $this->request->getPost('mail_content') ?? '' + ); + + if ($testEmail === '' || ! filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { + return $this->respond([ + 'status' => false, + 'message' => 'Please enter a valid email address.', + ], 400); + } + + if ($subject === '') { + return $this->respond([ + 'status' => false, + 'message' => 'Subject is required.', + ], 400); + } + + if (trim(strip_tags($mailContent)) === '') { + return $this->respond([ + 'status' => false, + 'message' => 'Mail content is empty.', + ], 400); + } + + $sampleVars = [ + 'client_name' => 'Ravi Kumar', + 'client_short_name' => 'Ravi', + 'policy_no' => 'RET-TEST-1001', + 'policy_type' => 'Individual Health', + 'insurer_name' => 'Sample Insurer', + 'policy_period' => '01/04/2026 to 31/03/2027', + 'reminder_date' => date('d/m/Y', strtotime('+30 days')), + 'days_before' => '30', + 'client_type' => 'Individual', + ]; + + $finalSubject = $this->replaceRetailReminderPlaceholders($subject, $sampleVars); + $finalMessage = $this->replaceRetailReminderPlaceholders($mailContent, $sampleVars); + $fromMail = $this->resolveRetailDefaultFromMail(); + + $payload = [ + 'mail' => [$testEmail], + 'subject' => '[TEST] ' . $finalSubject, + 'message' => $finalMessage, + 'common' => ['mail_type' => 'pt_renewal_reminder_test_mail'], + ]; + if ($fromMail !== '') { + $payload['from_mail'] = $fromMail; + } + + try { + $rawRes = MailHelper::send_email($payload); + $res = json_decode($rawRes); + + if (isset($res->status) && $res->status === 'success') { + return $this->respond([ + 'status' => true, + 'message' => 'Test mail sent to ' . $testEmail, + 'to' => $testEmail, + ], 200); + } + + return $this->respond([ + 'status' => false, + 'message' => 'Failed to send test mail.', + 'mail_response' => $res, + ], 500); + } catch (Exception $e) { + return $this->respond([ + 'status' => false, + 'message' => 'Failed to send test mail.', + ], 500); + } + } + + /** + * @return array + */ + protected function getOrCreateRetailReminderNotification(): array + { + $row = $this->notificationModel + ->where('template_name', PtRenewalReminderConfig::TEMPLATE_NAME) + ->groupStart() + ->where('client_id', null) + ->orWhere('client_id', 0) + ->orWhere('client_id', '') + ->groupEnd() + ->orderBy('id', 'DESC') + ->first(); + + if (! empty($row)) { + return $row; + } + + $defaultConfig = [ + 'enabled' => true, + 'daily' => true, + 'days' => 'mon,tue,wed,thu,fri', + 'clientTypes' => [2], + 'dateField' => 'renewal_date', + 'daysBefore' => 30, + 'includeClientEmail' => true, + 'includeOverdue' => false, + 'toEmails' => '', + 'ccEmails' => '', + 'bccEmails' => '', + 'fromMail' => '', + ]; + + $insertId = $this->notificationModel->insert([ + 'client_id' => null, + 'template_name' => PtRenewalReminderConfig::TEMPLATE_NAME, + 'subject' => 'Policy Renewal Reminder', + 'mail_content' => '', + 'enabled' => 1, + 'config_json' => json_encode($defaultConfig), + ]); + + return $this->notificationModel->find($insertId) ?? [ + 'id' => $insertId, + 'template_name' => PtRenewalReminderConfig::TEMPLATE_NAME, + 'subject' => 'Policy Renewal Reminder', + 'mail_content' => '', + 'config_json' => json_encode($defaultConfig), + 'enabled' => 1, + ]; + } + public function getMoreInfo() { diff --git a/app/Controllers/VoloApiController.php b/app/Controllers/VoloApiController.php index 09b02f92..9ab2177e 100644 --- a/app/Controllers/VoloApiController.php +++ b/app/Controllers/VoloApiController.php @@ -906,7 +906,7 @@ class VoloApiController extends BaseController } /** - * E-card PDF — returns a data URL the app can open, or null on failure. + * E-card PDF — saves base64 PDF to public/tmp and returns a downloadable URL, or null on failure. * * @param string|null $voloEmployeeId Volo/TPA employee or member id stored in employee_polices.tpa_id */ @@ -919,13 +919,15 @@ class VoloApiController extends BaseController } $entityId = $this->resolveEntityId((string) $policy_no); + log_message('error', 'VOLO - Ecard | entity id: ' . $entityId . ' | policy_no: ' . $policy_no . ' | voloEmployeeId: ' . $voloEmployeeId); if ($entityId === null) { log_message('error', 'VOLO - Ecard | entity id not resolved | policy: ' . ($policy_no ?? '')); return null; } - $res = $this->getEcardPdfRequest((string) $voloEmployeeId, $entityId); + $res = $this->getEcardPdfRequest((string) $emp_code, $entityId); + log_message('error', 'VOLO - Ecard | res: ' . json_encode($res)); if (empty($res['status']) || !is_array($res['data'])) { log_message('error', 'VOLO - Ecard FAILED | ' . json_encode($res)); @@ -939,6 +941,38 @@ class VoloApiController extends BaseController return null; } - return 'data:application/pdf;base64,' . $body; + // Strip data-URI prefix if the API already included it. + if (stripos($body, 'base64,') !== false) { + $body = substr($body, strpos($body, 'base64,') + 7); + } + + $pdfBinary = base64_decode($body, true); + if ($pdfBinary === false || $pdfBinary === '') { + log_message('error', 'VOLO - Ecard FAILED | base64 decode failed | emp_code: ' . ($emp_code ?? '')); + + return null; + } + + $dir = FCPATH . 'tmp/'; + if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { + log_message('error', 'VOLO - Ecard FAILED | cannot create dir: ' . $dir); + + return null; + } + + $safeEmp = preg_replace('/[^A-Za-z0-9_-]/', '_', (string) $emp_code) ?: 'emp'; + $fileName = 'volo_ecard_' . $safeEmp . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.pdf'; + $filePath = $dir . $fileName; + + if (file_put_contents($filePath, $pdfBinary) === false) { + log_message('error', 'VOLO - Ecard FAILED | cannot write file: ' . $filePath); + + return null; + } + + $downloadUrl = base_url('public/tmp/' . $fileName); + log_message('error', 'VOLO - Ecard SUCCESS | url: ' . $downloadUrl); + + return $downloadUrl; } } diff --git a/app/Models/NotificationModel.php b/app/Models/NotificationModel.php index a39df3b3..54b1790e 100755 --- a/app/Models/NotificationModel.php +++ b/app/Models/NotificationModel.php @@ -8,5 +8,5 @@ class NotificationModel extends Model { protected $table = 'notifications'; protected $primaryKey = 'id'; - protected $allowedFields = ["id","client_id","template_name","subject","mail_content","enabled","created_by","updated_by","is_active",'mail_content_json','common_mail']; + protected $allowedFields = ["id","client_id","template_name","subject","mail_content","enabled","created_by","updated_by","is_active",'mail_content_json','common_mail','config_json']; } \ No newline at end of file diff --git a/app/Models/PolicyTransactionModel.php b/app/Models/PolicyTransactionModel.php index 38ca5936..21b58b45 100644 --- a/app/Models/PolicyTransactionModel.php +++ b/app/Models/PolicyTransactionModel.php @@ -62,6 +62,7 @@ 'etat_band', 'edate', 'renewal_date', + 'renewal_status', 'rollover_date', 'policy_holder_name', 'same_as_proposer', @@ -2126,6 +2127,311 @@ // print($this->db->getLastQuery()); die; return $results; } + + /** + * Candidates for policy_transaction renewal reminder cron. + * + * @param string $dateField Whitelisted column: renewal_date | policy_end_date + * @param int $daysBefore Days ahead of today to match + * @param int[] $clientTypes Client type ids (1=Group, 2=Individual) + * @param bool $includeOverdue When true, also include rows where dateField < today + * @return array + */ + public function getRenewalReminderCandidates( + string $dateField, + int $daysBefore, + array $clientTypes, + bool $includeOverdue = false + ): array { + $allowedDateFields = ['renewal_date', 'policy_end_date']; + if (! in_array($dateField, $allowedDateFields, true)) { + $dateField = 'renewal_date'; + } + + $clientTypes = array_values(array_unique(array_filter(array_map('intval', $clientTypes), static function ($type) { + return $type > 0; + }))); + + if (empty($clientTypes)) { + return []; + } + + $daysBefore = max(0, (int) $daysBefore); + $targetDate = date('Y-m-d', strtotime('+' . $daysBefore . ' days')); + $today = date('Y-m-d'); + $column = 'pt.' . $dateField; + + $query = $this->db->table('policy_transaction pt') + ->select(" + pt.id, + pt.policy_no, + pt.client_id, + pt.client_branch_id, + pt.policy_start_date, + pt.policy_end_date, + pt.renewal_date, + c.client_name, + c.short_name AS client_short_name, + c.client_type, + c.email AS client_email, + c.common_mails, + policy_type.policy_type, + i.name AS insurer_name, + CASE WHEN {$column} < '{$today}' THEN 1 ELSE 0 END AS is_overdue + ") + ->join('clients c', 'pt.client_id = c.id', 'left') + ->join('pt_co_share_details pt_co', 'pt.id = pt_co.pt_id and pt_co.co_share_type = 1', 'left') + ->join('insurers i', 'pt_co.insurer_id = i.id', 'left') + ->join('policy_type', 'pt.policy_type_id = policy_type.id', 'left') + ->where('pt.is_active', 1) + ->where('pt.action_type', 'inception') + ->whereIn('c.client_type', $clientTypes) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("NOT EXISTS (SELECT 1 FROM policy_transaction p2 WHERE p2.source_client_policy_id = pt.client_policy_id AND p2.is_active = 1)"); + + if ($includeOverdue) { + $query->groupStart() + ->where($column, $targetDate) + ->orWhere("{$column} <", $today) + ->groupEnd(); + } else { + $query->where($column, $targetDate); + } + + $query->groupBy('pt.id') + ->orderBy('pt.id', 'DESC'); + + return $query->get()->getResultArray(); + } + + /** + * Explain why renewal reminder candidates may be empty (for dry-run diagnostics). + * + * @param int[] $clientTypes + * @return array + */ + public function diagnoseRenewalReminderCandidates( + string $dateField, + int $daysBefore, + array $clientTypes, + bool $includeOverdue = false + ): array { + $allowedDateFields = ['renewal_date', 'policy_end_date']; + if (! in_array($dateField, $allowedDateFields, true)) { + $dateField = 'renewal_date'; + } + + $clientTypes = array_values(array_unique(array_filter(array_map('intval', $clientTypes), static function ($type) { + return $type > 0; + }))); + + $daysBefore = max(0, (int) $daysBefore); + $targetDate = date('Y-m-d', strtotime('+' . $daysBefore . ' days')); + $today = date('Y-m-d'); + $column = 'pt.' . $dateField; + + $base = function () { + return $this->db->table('policy_transaction pt') + ->join('clients c', 'pt.client_id = c.id', 'left') + ->where('pt.is_active', 1) + ->where('pt.action_type', 'inception'); + }; + + $countRetailActive = 0; + $countAfterClientType = 0; + $countAfterStatus = 0; + $countAfterNotRenewedExists = 0; + $countExactTargetDate = 0; + $countOverdue = 0; + $countFinal = 0; + $nearestSample = []; + + if (! empty($clientTypes)) { + $countRetailActive = (int) $base() + ->whereIn('c.client_type', $clientTypes) + ->countAllResults(); + + $countAfterClientType = $countRetailActive; + + $countAfterStatus = (int) $base() + ->whereIn('c.client_type', $clientTypes) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->countAllResults(); + + $countAfterNotRenewedExists = (int) $base() + ->whereIn('c.client_type', $clientTypes) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("NOT EXISTS (SELECT 1 FROM policy_transaction p2 WHERE p2.source_client_policy_id = pt.client_policy_id AND p2.is_active = 1)") + ->countAllResults(); + + $countExactTargetDate = (int) $base() + ->whereIn('c.client_type', $clientTypes) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("NOT EXISTS (SELECT 1 FROM policy_transaction p2 WHERE p2.source_client_policy_id = pt.client_policy_id AND p2.is_active = 1)") + ->where($column, $targetDate) + ->countAllResults(); + + $countOverdue = (int) $base() + ->whereIn('c.client_type', $clientTypes) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("NOT EXISTS (SELECT 1 FROM policy_transaction p2 WHERE p2.source_client_policy_id = pt.client_policy_id AND p2.is_active = 1)") + ->where("{$column} <", $today) + ->where("{$column} IS NOT NULL", null, false) + ->where("{$column} !=", '0000-00-00') + ->countAllResults(); + + $finalBuilder = $base() + ->whereIn('c.client_type', $clientTypes) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("NOT EXISTS (SELECT 1 FROM policy_transaction p2 WHERE p2.source_client_policy_id = pt.client_policy_id AND p2.is_active = 1)"); + + if ($includeOverdue) { + $finalBuilder->groupStart() + ->where($column, $targetDate) + ->orWhere("{$column} <", $today) + ->groupEnd(); + } else { + $finalBuilder->where($column, $targetDate); + } + $countFinal = (int) $finalBuilder->countAllResults(); + + // Sample nearest upcoming renewals (next 60 days) for debugging + $from = $today; + $to = date('Y-m-d', strtotime('+60 days')); + $nearestSample = $base() + ->select("pt.id, pt.policy_no, c.client_name, c.email AS client_email, pt.renewal_date, pt.policy_end_date, pt.renewal_status, {$column} AS match_date") + ->whereIn('c.client_type', $clientTypes) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("NOT EXISTS (SELECT 1 FROM policy_transaction p2 WHERE p2.source_client_policy_id = pt.client_policy_id AND p2.is_active = 1)") + ->where("{$column} >=", $from) + ->where("{$column} <=", $to) + ->orderBy($column, 'ASC') + ->limit(10) + ->get() + ->getResultArray(); + } + + $reasons = []; + if (empty($clientTypes)) { + $reasons[] = 'No clientTypes configured.'; + } + if ($countAfterClientType === 0) { + $reasons[] = 'No active inception policies found for configured client type(s).'; + } elseif ($countAfterStatus === 0) { + $reasons[] = 'All matching policies already have renewal_status of renewal_mail_send/renewed/reminded.'; + } elseif ($countAfterNotRenewedExists === 0) { + $reasons[] = 'All remaining policies already have an active renewed policy linked via source_client_policy_id.'; + } elseif (! $includeOverdue && $countExactTargetDate === 0) { + $reasons[] = "No policies have {$dateField} exactly equal to target date {$targetDate} (today + {$daysBefore} days). includeOverdue is false, so only exact date matches are selected."; + if ($countOverdue > 0) { + $reasons[] = "There are {$countOverdue} overdue policies that would be included if includeOverdue=true."; + } + } elseif ($includeOverdue && $countFinal === 0) { + $reasons[] = "No policies match target date {$targetDate} or overdue (< {$today}) on {$dateField}."; + } + + if (empty($reasons) && $countFinal === 0) { + $reasons[] = 'No candidates matched the current filters.'; + } + + return [ + 'today' => $today, + 'target_date' => $targetDate, + 'date_field' => $dateField, + 'days_before' => $daysBefore, + 'include_overdue' => $includeOverdue, + 'client_types' => $clientTypes, + 'counts' => [ + 'active_inception_for_client_types' => $countAfterClientType, + 'after_excluding_sent_or_renewed' => $countAfterStatus, + 'after_excluding_already_renewed_policy' => $countAfterNotRenewedExists, + 'exact_target_date_matches' => $countExactTargetDate, + 'overdue_matches' => $countOverdue, + 'final_candidates' => $countFinal, + ], + 'reasons' => $reasons, + 'nearest_upcoming_sample_60_days' => $nearestSample, + 'hint' => $includeOverdue + ? 'Candidates = exact target date OR overdue on selected date field.' + : "Candidates require {$dateField} = {$targetDate} exactly. Enable includeOverdue to also include past dates, or change daysBefore / dateField in Mail Config.", + ]; + } + + /** + * Server-side DataTables list for Retail Policy (individual clients only). + * + * @param int $draw + * @param int $start + * @param int $length + * @param string $search + * @return array{draw:int,recordsTotal:int,recordsFiltered:int,data:array} + */ + public function getRetailPolicyList(int $draw, int $start, int $length, string $search = ''): array + { + $exportAll = $length < 0; + $length = $exportAll ? 0 : ($length > 0 ? $length : 10); + $start = max(0, $start); + $search = trim($search); + + $applyBase = static function ($builder) { + return $builder->join('clients c', 'pt.client_id = c.id', 'left') + ->join('policy_type', 'pt.policy_type_id = policy_type.id', 'left') + ->where('pt.is_active', 1) + ->where('pt.action_type', 'inception') + ->where('c.client_type', 2) + ->where("IFNULL(pt.renewal_status, '') !=", 'renewed'); + }; + + $applySearch = static function ($builder) use ($search) { + if ($search !== '') { + $builder->groupStart() + ->like('pt.policy_no', $search) + ->orLike('policy_type.policy_type', $search) + ->orLike('c.client_name', $search) + ->orLike('c.phone', $search) + ->orLike('c.email', $search) + ->groupEnd(); + } + return $builder; + }; + + $totalBuilder = $applyBase($this->db->table('policy_transaction pt')); + $recordsTotal = (int) $totalBuilder->countAllResults(); + + $filteredBuilder = $applySearch($applyBase($this->db->table('policy_transaction pt'))); + $recordsFiltered = (int) $filteredBuilder->countAllResults(); + + $dataBuilder = $applySearch($applyBase($this->db->table('policy_transaction pt'))); + $dataBuilder = $dataBuilder + ->select(' + pt.id, + pt.policy_no, + pt.renewal_date, + pt.policy_start_date, + pt.policy_end_date, + pt.renewal_status, + pt.client_id, + c.client_name, + c.phone AS client_mobile, + c.email AS client_email, + policy_type.policy_type + ') + ->orderBy("CASE WHEN LOWER(IFNULL(pt.renewal_status, '')) IN ('renewal_mail_send', 'reminded') THEN 0 ELSE 1 END", 'ASC', false) + ->orderBy('pt.id', 'DESC'); + + if (! $exportAll) { + $dataBuilder->limit($length, $start); + } + + $rows = $dataBuilder->get()->getResultArray(); + + return [ + 'draw' => $draw, + 'recordsTotal' => $recordsTotal, + 'recordsFiltered' => $recordsFiltered, + 'data' => $rows, + ]; + } public function getRenewalReportDataOld($start_date = null, $end_date = null, $client_type = null, $client = null, $issuer = null) { diff --git a/app/Views/layout/header.php b/app/Views/layout/header.php index 698a6ccb..4d77cec6 100755 --- a/app/Views/layout/header.php +++ b/app/Views/layout/header.php @@ -2276,6 +2276,13 @@ body[data-sidebar-size="condensed"] .footer { +
  • + + + Retail Policy + +
  • + + + + + + + diff --git a/writable/sql/db_changes_2026-07-01_to_2026-08-12.sql b/writable/sql/db_changes_2026-07-01_to_2026-08-12.sql new file mode 100644 index 00000000..11b1a8d7 --- /dev/null +++ b/writable/sql/db_changes_2026-07-01_to_2026-08-12.sql @@ -0,0 +1,612 @@ +-- ============================================================================= +-- NHANCE DB changes: 2026-07-01 through 2026-08-12 +-- Consolidated from app/Database/*.sql and Migrations (chronological). +-- Safe-ish to re-run where noted (IF NOT EXISTS / information_schema checks). +-- Demo seed (digit_motor_demo_seed.sql) omitted — schema only + retail reminder seed. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 2026-07-06 | Insurer claim form columns +-- Source: app/Database/insurers_add_claim_form_column.sql +-- ----------------------------------------------------------------------------- +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'insurers' + AND column_name = 'insurer_claim_form' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE insurers ADD COLUMN insurer_claim_form VARCHAR(255) NULL DEFAULT NULL AFTER insurer_logo', + 'SELECT ''insurers.insurer_claim_form already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'insurers' + AND column_name = 'insurer_claim_form_original_name' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE insurers ADD COLUMN insurer_claim_form_original_name VARCHAR(255) NULL DEFAULT NULL AFTER insurer_claim_form', + 'SELECT ''insurers.insurer_claim_form_original_name already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 2026-07-13 | BDS report performance indexes +-- Source: app/Database/bds_report_performance_indexes.sql +-- ----------------------------------------------------------------------------- +SET @idx := ( + SELECT COUNT(1) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'policy_transaction' + AND index_name = 'idx_pt_active_created' +); +SET @sql := IF(@idx = 0, + 'CREATE INDEX idx_pt_active_created ON policy_transaction (is_active, created_at)', + 'SELECT ''idx_pt_active_created already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'pt_co_share_details' + AND index_name = 'idx_pcsd_pt_active' +); +SET @sql := IF(@idx = 0, + 'CREATE INDEX idx_pcsd_pt_active ON pt_co_share_details (pt_id, is_active)', + 'SELECT ''idx_pcsd_pt_active already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'co_share_stmt_details' + AND index_name = 'idx_cssd_coshare_stmt_active' +); +SET @sql := IF(@idx = 0, + 'CREATE INDEX idx_cssd_coshare_stmt_active ON co_share_stmt_details (co_share_id, statement_id, is_active)', + 'SELECT ''idx_cssd_coshare_stmt_active already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'insurer_statements' + AND index_name = 'idx_insq_active_month_invoice' +); +SET @sql := IF(@idx = 0, + 'CREATE INDEX idx_insq_active_month_invoice ON insurer_statements (is_active, month, invoice_status)', + 'SELECT ''idx_insq_active_month_invoice already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 2026-07-21 / 2026-07-23 / 2026-07-30 | Digit Motor module tables (final schema) +-- Sources: digit_motor_tables.sql, digit_motor_master_tables.sql +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `motor_token` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `environment` VARCHAR(20) NOT NULL DEFAULT 'staging', + `access_token` TEXT NOT NULL, + `refresh_token` TEXT, + `expires_at` TIMESTAMP NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_motor_token_env` (`environment`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_quote` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `enquiry_id` VARCHAR(64) NOT NULL, + `quote_number` VARCHAR(32) DEFAULT NULL, + `application_id` VARCHAR(255) DEFAULT NULL, + `policy_holder_type` VARCHAR(20) NOT NULL DEFAULT 'INDIVIDUAL', + `insurance_product_code` VARCHAR(10) NOT NULL, + `sub_insurance_product_code` VARCHAR(10) NOT NULL DEFAULT 'PB', + `previous_insurer_code` SMALLINT DEFAULT NULL, + `previous_policy_expiry_date` DATE DEFAULT NULL, + `external_policy_number` VARCHAR(32) DEFAULT NULL, + `is_ncb_transfer` TINYINT(1) DEFAULT 0, + `start_date` DATE DEFAULT NULL, + `end_date` DATE DEFAULT NULL, + `pincode` VARCHAR(6) NOT NULL, + `coverage_details` JSON DEFAULT NULL, + `policyholder_details` JSON DEFAULT NULL, + `premium` DECIMAL(12,2) DEFAULT NULL, + `idv` DECIMAL(12,2) DEFAULT NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + `created_by` INT DEFAULT NULL, + `updated_by` INT DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uq_motor_quote_enquiry` (`enquiry_id`), + KEY `idx_motor_quote_status` (`status`), + KEY `idx_motor_quote_quote_number` (`quote_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_vehicle` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `quote_id` BIGINT NOT NULL, + `is_vehicle_new` TINYINT(1) NOT NULL DEFAULT 0, + `vehicle_maincode` VARCHAR(30) NOT NULL, + `license_plate_number` VARCHAR(12) NOT NULL, + `vehicle_identification_number` VARCHAR(30) DEFAULT NULL, + `engine_number` VARCHAR(30) DEFAULT NULL, + `manufacture_date` DATE NOT NULL, + `registration_date` DATE NOT NULL, + `registration_authority` VARCHAR(10) DEFAULT NULL, + `idv` DECIMAL(12,2) DEFAULT NULL, + `default_idv` DECIMAL(12,2) DEFAULT NULL, + `minimum_idv` DECIMAL(12,2) DEFAULT NULL, + `maximum_idv` DECIMAL(12,2) DEFAULT NULL, + UNIQUE KEY `uq_motor_vehicle_quote` (`quote_id`), + CONSTRAINT `fk_motor_vehicle_quote` FOREIGN KEY (`quote_id`) REFERENCES `motor_quote`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_kyc` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `quote_id` BIGINT NOT NULL, + `kyc_id` VARCHAR(64) DEFAULT NULL, + `kyc_verification_status` VARCHAR(20) DEFAULT NULL, + `reference_id` VARCHAR(64) DEFAULT NULL, + `link` VARCHAR(512) DEFAULT NULL, + `mismatch_type` VARCHAR(40) DEFAULT NULL, + `id_verification_doc_type` VARCHAR(40) DEFAULT NULL, + `address_verification_doc_type` VARCHAR(40) DEFAULT NULL, + `mode` CHAR(1) DEFAULT 'O', + `checked_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY `idx_motor_kyc_quote` (`quote_id`), + CONSTRAINT `fk_motor_kyc_quote` FOREIGN KEY (`quote_id`) REFERENCES `motor_quote`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_payment` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `quote_id` BIGINT NOT NULL, + `application_id` VARCHAR(255) NOT NULL, + `digit_payment_id` VARCHAR(64) DEFAULT NULL, + `request_reference` VARCHAR(64) DEFAULT NULL, + `payment_mode` VARCHAR(5) DEFAULT 'EB', + `cancel_return_url` VARCHAR(512) DEFAULT NULL, + `success_return_url` VARCHAR(512) DEFAULT NULL, + `dispatcher_response` VARCHAR(512) DEFAULT NULL, + `premium` DECIMAL(12,2) DEFAULT NULL, + `payment_status` VARCHAR(20) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY `idx_motor_payment_quote` (`quote_id`), + CONSTRAINT `fk_motor_payment_quote` FOREIGN KEY (`quote_id`) REFERENCES `motor_quote`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_policy` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `quote_id` BIGINT NOT NULL, + `policy_number` VARCHAR(32) DEFAULT NULL, + `policy_status` VARCHAR(20) DEFAULT NULL, + `schedule_path` VARCHAR(512) DEFAULT NULL, + `proposal_path` VARCHAR(512) DEFAULT NULL, + `response_code` VARCHAR(10) DEFAULT NULL, + `response_message` VARCHAR(255) DEFAULT NULL, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uq_motor_policy_quote` (`quote_id`), + CONSTRAINT `fk_motor_policy_quote` FOREIGN KEY (`quote_id`) REFERENCES `motor_quote`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_api_log` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `quote_id` BIGINT DEFAULT NULL, + `integration_id` VARCHAR(20) DEFAULT NULL, + `endpoint` VARCHAR(120) DEFAULT NULL, + `request_body` JSON DEFAULT NULL, + `response_body` JSON DEFAULT NULL, + `http_status` SMALLINT DEFAULT NULL, + `error_code` VARCHAR(10) DEFAULT NULL, + `duration_ms` INT DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY `idx_motor_api_log_quote` (`quote_id`), + KEY `idx_motor_api_log_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Digit Motor master / lookup tables +CREATE TABLE IF NOT EXISTS `motor_master_vehicle` ( + `vehicle_code` VARCHAR(30) NOT NULL, + `make` VARCHAR(80) NOT NULL, + `model` VARCHAR(120) NOT NULL, + `variant` VARCHAR(120) DEFAULT NULL, + `body_type` VARCHAR(60) DEFAULT NULL, + `seating_capacity` SMALLINT DEFAULT NULL, + `power` DECIMAL(10,2) DEFAULT NULL, + `cubic_capacity` DECIMAL(10,2) DEFAULT NULL, + `gross_vehicle_weight` DECIMAL(12,2) DEFAULT NULL, + `fuel_type` VARCHAR(40) DEFAULT NULL, + `no_of_wheels` TINYINT DEFAULT NULL, + `abs` CHAR(1) DEFAULT NULL, + `air_bags` SMALLINT DEFAULT NULL, + `length_m` DECIMAL(10,3) DEFAULT NULL, + `ex_showroom_price` DECIMAL(14,2) DEFAULT NULL, + `price_year` SMALLINT DEFAULT NULL, + `production_status` VARCHAR(60) DEFAULT NULL, + `manufacturing` VARCHAR(40) DEFAULT NULL, + `vehicle_type` VARCHAR(40) DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`vehicle_code`), + KEY `idx_mmv_make` (`make`), + KEY `idx_mmv_make_model` (`make`, `model`), + KEY `idx_mmv_make_model_variant` (`make`, `model`, `variant`), + KEY `idx_mmv_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_previous_insurer` ( + `insurer_code` VARCHAR(10) NOT NULL, + `insurer_name` VARCHAR(180) NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`insurer_code`), + KEY `idx_mmpi_name` (`insurer_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_product` ( + `product_code` VARCHAR(10) NOT NULL, + `product_name` VARCHAR(120) NOT NULL, + `vehicle_class` VARCHAR(10) DEFAULT NULL COMMENT '2W / 4W / CV', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`product_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_sub_product` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `business_type` VARCHAR(20) NOT NULL COMMENT 'NEW / ROLLOVER', + `product_label` VARCHAR(120) NOT NULL, + `sub_product_code` VARCHAR(20) NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_mmsp` (`business_type`, `product_label`, `sub_product_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_pincode` ( + `pincode` VARCHAR(6) NOT NULL, + `city` VARCHAR(120) DEFAULT NULL, + `district` VARCHAR(120) DEFAULT NULL, + `street` VARCHAR(180) DEFAULT NULL, + `taluk` VARCHAR(120) DEFAULT NULL, + `state_code` VARCHAR(10) DEFAULT NULL, + `segment` VARCHAR(40) DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`pincode`), + KEY `idx_mmp_city` (`city`), + KEY `idx_mmp_state` (`state_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_rto` ( + `rto_code` VARCHAR(10) NOT NULL, + `city_state` VARCHAR(180) DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`rto_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_ncb` ( + `ncb_code` VARCHAR(30) NOT NULL, + `sort_order` SMALLINT NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ncb_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_previous_policy_type` ( + `policy_type_code` VARCHAR(20) NOT NULL, + `description` VARCHAR(120) DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`policy_type_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_voluntary_deductible` ( + `deductible_code` VARCHAR(40) NOT NULL, + `sort_order` SMALLINT NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`deductible_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_doc_type` ( + `doc_code` VARCHAR(10) NOT NULL, + `doc_type` VARCHAR(60) NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`doc_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_nominee_relation` ( + `relation_code` VARCHAR(40) NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`relation_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_state` ( + `state_code` VARCHAR(10) NOT NULL, + `state_name` VARCHAR(120) NOT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`state_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_addon_age_limit` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `addon_name` VARCHAR(120) NOT NULL, + `age_limit_4w` VARCHAR(120) DEFAULT NULL, + `age_limit_2w` VARCHAR(255) DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_mmaal_addon` (`addon_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `motor_master_import_log` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `master_key` VARCHAR(60) NOT NULL, + `source_file` VARCHAR(255) DEFAULT NULL, + `rows_upserted` INT NOT NULL DEFAULT 0, + `status` VARCHAR(20) NOT NULL DEFAULT 'OK', + `message` TEXT DEFAULT NULL, + `imported_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY `idx_mmil_master` (`master_key`), + KEY `idx_mmil_imported` (`imported_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Align existing Digit Motor tables if created from an earlier draft schema +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'motor_quote' + AND column_name = 'policyholder_details' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE motor_quote ADD COLUMN policyholder_details JSON DEFAULT NULL AFTER coverage_details', + 'SELECT ''motor_quote.policyholder_details already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'motor_quote' + AND column_name = 'application_id' + AND character_maximum_length < 255 + ), + 'ALTER TABLE motor_quote MODIFY COLUMN application_id VARCHAR(255) DEFAULT NULL', + 'SELECT ''motor_quote.application_id already wide enough'' AS info' + ) +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'motor_payment' + AND column_name = 'application_id' + AND character_maximum_length < 255 + ), + 'ALTER TABLE motor_payment MODIFY COLUMN application_id VARCHAR(255) NOT NULL', + 'SELECT ''motor_payment.application_id already wide enough'' AS info' + ) +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 2026-07-28 | claim_report table +-- Sources: claim_report.sql / Migration 2026-07-28-090700_CreateClaimReportTable +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `claim_report` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tpa_id` INT UNSIGNED NULL, + `client_id` INT UNSIGNED NULL, + `client_policy_id` INT UNSIGNED NOT NULL, + `file_id` INT UNSIGNED NULL, + `ticket_id` BIGINT UNSIGNED NULL, + `source_table` VARCHAR(64) NULL, + `source_row_id` BIGINT UNSIGNED NULL, + `claim_number` VARCHAR(191) NOT NULL, + `emp_code` VARCHAR(100) NULL, + `tpa_no` VARCHAR(100) NULL, + `emp_id` INT UNSIGNED NULL, + `insured_emp_id` INT UNSIGNED NULL, + `claim_amount` VARCHAR(50) NULL, + `approved_amount` VARCHAR(50) NULL, + `incurred_amount` VARCHAR(50) NULL, + `si_amt` VARCHAR(50) NULL, + `tpa_claim_status` VARCHAR(191) NULL, + `claim_status_id` INT UNSIGNED NULL, + `tpa_claim_type` VARCHAR(100) NULL, + `tpa_ailments` TEXT NULL, + `doa` DATE NULL, + `dod` DATE NULL, + `date_of_intimat` DATE NULL, + `settled_date` DATE NULL, + `approved_date` DATE NULL, + `claim_dump_date` DATETIME NULL, + `hospital_name` VARCHAR(255) NULL, + `hospital_city` VARCHAR(150) NULL, + `hospital_state` VARCHAR(150) NULL, + `hospital_pin_code` VARCHAR(20) NULL, + `hospital_address` TEXT NULL, + `gender` VARCHAR(30) NULL, + `age` VARCHAR(20) NULL, + `relation` VARCHAR(50) NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_claim_report_policy_claim` (`client_policy_id`, `claim_number`), + KEY `idx_claim_report_policy_active` (`client_policy_id`, `is_active`), + KEY `idx_claim_report_ticket` (`ticket_id`), + KEY `idx_claim_report_source` (`source_table`, `source_row_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ----------------------------------------------------------------------------- +-- 2026-08-01 / 2026-08-04 | Dependent add approval tracking +-- Migrations: +-- 2026-08-01-043000_AddDependentApprovalTrackingColumns +-- 2026-08-04-064500_AddRejectReasonToEmployeesAndEmployeePolices +-- 2026-08-04-065000_RenameApprovedByToProcessedBy +-- ----------------------------------------------------------------------------- + +-- employees +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employees' AND column_name = 'emp_created_by' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE employees ADD COLUMN emp_created_by VARCHAR(50) NULL DEFAULT NULL COMMENT ''Creator role: HR / USER'' AFTER emp_status', + 'SELECT ''employees.emp_created_by already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Rename approved_by -> processed_by when needed +SET @sql := ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employees' AND column_name = 'approved_by' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employees' AND column_name = 'processed_by' + ), + 'ALTER TABLE employees CHANGE COLUMN approved_by processed_by VARCHAR(50) NULL DEFAULT NULL COMMENT ''Processor role: HR / ACM (approve or reject)''', + 'SELECT ''employees.processed_by rename skipped'' AS info' + ) +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employees' AND column_name = 'processed_by' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE employees ADD COLUMN processed_by VARCHAR(50) NULL DEFAULT NULL COMMENT ''Processor role: HR / ACM (approve or reject)'' AFTER emp_created_by', + 'SELECT ''employees.processed_by already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employees' AND column_name = 'reject_reason' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE employees ADD COLUMN reject_reason TEXT NULL DEFAULT NULL COMMENT ''Reason when dependent addition is rejected'' AFTER processed_by', + 'SELECT ''employees.reject_reason already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- employee_polices +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employee_polices' AND column_name = 'emp_policy_created_by' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE employee_polices ADD COLUMN emp_policy_created_by VARCHAR(50) NULL DEFAULT NULL COMMENT ''Creator role: HR / USER'' AFTER status', + 'SELECT ''employee_polices.emp_policy_created_by already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql := ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employee_polices' AND column_name = 'approved_by' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employee_polices' AND column_name = 'processed_by' + ), + 'ALTER TABLE employee_polices CHANGE COLUMN approved_by processed_by VARCHAR(50) NULL DEFAULT NULL COMMENT ''Processor role: HR / ACM (approve or reject)''', + 'SELECT ''employee_polices.processed_by rename skipped'' AS info' + ) +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employee_polices' AND column_name = 'processed_by' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE employee_polices ADD COLUMN processed_by VARCHAR(50) NULL DEFAULT NULL COMMENT ''Processor role: HR / ACM (approve or reject)'' AFTER emp_policy_created_by', + 'SELECT ''employee_polices.processed_by already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'employee_polices' AND column_name = 'reject_reason' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE employee_polices ADD COLUMN reject_reason TEXT NULL DEFAULT NULL COMMENT ''Reason when dependent addition is rejected'' AFTER processed_by', + 'SELECT ''employee_polices.reject_reason already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ----------------------------------------------------------------------------- +-- 2026-08-11 | Retail policy renewal reminder +-- Source: writable/sql/retail_policy_reminder_schema.sql +-- ----------------------------------------------------------------------------- +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'policy_transaction' + AND column_name = 'renewal_status' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE policy_transaction ADD COLUMN renewal_status VARCHAR(50) NULL DEFAULT NULL AFTER renewal_date', + 'SELECT ''policy_transaction.renewal_status already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'notifications' + AND column_name = 'config_json' +); +SET @sql := IF(@col = 0, + 'ALTER TABLE notifications ADD COLUMN config_json LONGTEXT NULL AFTER common_mail', + 'SELECT ''notifications.config_json already exists'' AS info' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO `notifications` ( + `client_id`, + `template_name`, + `subject`, + `mail_content`, + `enabled`, + `config_json` +) +SELECT + NULL, + 'retail_reminder_mail', + 'Policy Renewal Reminder', + '', + 1, + '{"enabled":true,"daily":true,"days":"mon,tue,wed,thu,fri","clientTypes":[2],"dateField":"renewal_date","daysBefore":30,"includeClientEmail":true,"includeOverdue":false,"toEmails":"","ccEmails":"","bccEmails":"","fromMail":""}' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM `notifications` + WHERE `template_name` = 'retail_reminder_mail' + AND (`client_id` IS NULL OR `client_id` = 0) +); + +-- ============================================================================= +-- END +-- Optional demo data (not included): app/Database/digit_motor_demo_seed.sql +-- ============================================================================= diff --git a/writable/sql/retail_policy_reminder_schema.sql b/writable/sql/retail_policy_reminder_schema.sql new file mode 100644 index 00000000..9e3bb0a0 --- /dev/null +++ b/writable/sql/retail_policy_reminder_schema.sql @@ -0,0 +1,36 @@ +-- Retail Policy Reminder: schema + seed +-- Run manually against the application database. + +-- 1) policy_transaction.renewal_status +ALTER TABLE `policy_transaction` + ADD COLUMN `renewal_status` VARCHAR(50) NULL DEFAULT NULL + AFTER `renewal_date`; + +-- 2) notifications.config_json +ALTER TABLE `notifications` + ADD COLUMN `config_json` LONGTEXT NULL + AFTER `common_mail`; + +-- 3) Seed global retail reminder notification (skip if row already exists) +INSERT INTO `notifications` ( + `client_id`, + `template_name`, + `subject`, + `mail_content`, + `enabled`, + `config_json` +) +SELECT + NULL, + 'retail_reminder_mail', + 'Policy Renewal Reminder', + '', + 1, + '{"enabled":true,"daily":true,"days":"mon,tue,wed,thu,fri","clientTypes":[2],"dateField":"renewal_date","daysBefore":30,"includeClientEmail":true,"includeOverdue":false,"toEmails":"","ccEmails":"","bccEmails":"","fromMail":""}' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM `notifications` + WHERE `template_name` = 'retail_reminder_mail' + AND (`client_id` IS NULL OR `client_id` = 0) +);