diff --git a/.env.sample b/.env.sample index a9e88c7e..033ceca5 100755 --- a/.env.sample +++ b/.env.sample @@ -188,7 +188,7 @@ pt.renewalReminder.days = mon,tue,wed,thu,fri pt.renewalReminder.clientTypes = 1,2 # renewal_date | policy_end_date pt.renewalReminder.dateField = renewal_date -pt.renewalReminder.daysBefore = 30 +pt.renewalReminder.daysBefore = 30, 15, 7, 1, -2, -5, -10 pt.renewalReminder.includeClientEmail = true # When true, also send for policies whose dateField is already past (overdue) pt.renewalReminder.includeOverdue = false diff --git a/app/Config/PtRenewalReminderConfig.php b/app/Config/PtRenewalReminderConfig.php index 09363875..9f375447 100644 --- a/app/Config/PtRenewalReminderConfig.php +++ b/app/Config/PtRenewalReminderConfig.php @@ -20,7 +20,8 @@ class PtRenewalReminderConfig extends BaseConfig /** Whitelisted: renewal_date | policy_end_date */ public string $dateField = 'renewal_date'; - public int $daysBefore = 30; + /** @var int[] Reminder offsets: positive = days before due, negative = days after due */ + public array $daysBeforeList = [30]; public bool $includeClientEmail = false; @@ -61,7 +62,7 @@ class PtRenewalReminderConfig extends BaseConfig '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), + 'daysBefore' => env('pt.renewalReminder.daysBefore', '30'), 'includeClientEmail' => env('pt.renewalReminder.includeClientEmail', 'true'), 'includeOverdue' => env('pt.renewalReminder.includeOverdue', 'false'), 'toEmails' => env('pt.renewalReminder.toEmails', ''), @@ -165,7 +166,7 @@ class PtRenewalReminderConfig extends BaseConfig } $this->dateField = $dateField; - $this->daysBefore = max(0, (int) ($merged['daysBefore'] ?? 30)); + $this->daysBeforeList = self::parseDaysBeforeList($merged['daysBefore'] ?? 30); $this->includeClientEmail = $this->toBool($merged['includeClientEmail'] ?? true); $this->includeOverdue = $this->toBool($merged['includeOverdue'] ?? false); @@ -196,9 +197,74 @@ class PtRenewalReminderConfig extends BaseConfig return $this->dateField; } + /** + * First / primary offset (legacy callers). + */ public function getDaysBefore(): int { - return $this->daysBefore; + return $this->daysBeforeList[0] ?? 30; + } + + /** + * @return int[] + */ + public function getDaysBeforeList(): array + { + return $this->daysBeforeList; + } + + public function getDaysBeforeDisplay(): string + { + return implode(', ', $this->daysBeforeList); + } + + /** + * Map each offset to the calendar date it matches (today + offset). + * + * @return array offset => Y-m-d + */ + public function getTargetDates(?string $today = null): array + { + $today = $today ?: date('Y-m-d'); + $map = []; + foreach ($this->daysBeforeList as $offset) { + $map[(int) $offset] = date('Y-m-d', strtotime($today . ' ' . sprintf('%+d days', (int) $offset))); + } + + return $map; + } + + /** + * Parse "30, 15, 7, 1, -2, -5, -10" (or a single int / array) into unique offsets. + * + * @return int[] + */ + public static function parseDaysBeforeList(mixed $value): array + { + if (is_int($value) || is_float($value)) { + $raw = [(int) $value]; + } elseif (is_array($value)) { + $raw = $value; + } else { + $raw = preg_split('/[,\s]+/', trim((string) $value), -1, PREG_SPLIT_NO_EMPTY) ?: []; + } + + $out = []; + $seen = []; + foreach ($raw as $item) { + $item = trim((string) $item); + if ($item === '' || ! is_numeric($item)) { + continue; + } + $n = (int) $item; + if ($n > 365 || $n < -365 || isset($seen[$n])) { + continue; + } + $seen[$n] = true; + $out[] = $n; + } + + return $out !== [] ? $out : [30]; } /** @@ -271,7 +337,7 @@ class PtRenewalReminderConfig extends BaseConfig 'days' => implode(',', $this->days), 'clientTypes' => $this->clientTypes, 'dateField' => $this->dateField, - 'daysBefore' => $this->daysBefore, + 'daysBefore' => $this->getDaysBeforeDisplay(), 'includeClientEmail' => $this->includeClientEmail, 'includeOverdue' => $this->includeOverdue, 'toEmails' => implode(',', $this->toEmails), diff --git a/app/Controllers/PolicyTransactionController.php b/app/Controllers/PolicyTransactionController.php index e8247198..b718c273 100644 --- a/app/Controllers/PolicyTransactionController.php +++ b/app/Controllers/PolicyTransactionController.php @@ -6014,7 +6014,7 @@ class PolicyTransactionController extends BaseController $this->logRenewalReminderStep( $logPrefix . ' Fetching candidates for dateField=' . $config->getDateField() - . ', daysBefore=' . $config->getDaysBefore() + . ', daysBefore=' . $config->getDaysBeforeDisplay() . ', includeOverdue=' . ($config->includeOverdue() ? 'true' : 'false') . ', clientTypes=' . json_encode($config->getClientTypes()) ); @@ -6023,7 +6023,7 @@ class PolicyTransactionController extends BaseController try { $candidates = $this->policyTransactionModel->getRenewalReminderCandidates( $config->getDateField(), - $config->getDaysBefore(), + $config->getDaysBeforeList(), $config->getClientTypes(), $config->includeOverdue() ); @@ -6066,7 +6066,7 @@ class PolicyTransactionController extends BaseController $this->logRenewalReminderStep( $logPrefix . ' ' . $message . ' | dateField=' . $config->getDateField() - . ' | daysBefore=' . $config->getDaysBefore() + . ' | daysBefore=' . $config->getDaysBeforeDisplay() . ' | includeOverdue=' . ($config->includeOverdue() ? 'true' : 'false') ); if ($isCli) { @@ -6081,7 +6081,7 @@ class PolicyTransactionController extends BaseController try { $diagnostics = $this->policyTransactionModel->diagnoseRenewalReminderCandidates( $config->getDateField(), - $config->getDaysBefore(), + $config->getDaysBeforeList(), $config->getClientTypes(), $config->includeOverdue() ); @@ -6119,6 +6119,9 @@ class PolicyTransactionController extends BaseController $emptyPayload['config'] = $configSnapshot; $emptyPayload['detailed_summary'] = $detailedSummary; $emptyPayload['why_empty'] = $diagnostics; + if (! empty($detailedSummary['client_mail'])) { + $emptyPayload['summary']['client_mail'] = $detailedSummary['client_mail']; + } } return $this->renewalReminderResponse($emptyPayload, $isCli); @@ -6225,6 +6228,8 @@ class PolicyTransactionController extends BaseController '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, + 'days_before' => isset($row['reminder_offset']) ? (int) $row['reminder_offset'] : null, + 'reminder_offset' => isset($row['reminder_offset']) ? (int) $row['reminder_offset'] : null, 'is_overdue' => $isOverdue, 'status' => 'dry_run', 'message' => 'Dry run — mail not sent, status not updated', @@ -6285,9 +6290,10 @@ class PolicyTransactionController extends BaseController if (isset($res->status) && $res->status === 'success') { $successCount++; try { - $this->policyTransactionModel->update((int) $ptId, [ - 'renewal_status' => 'renewal_mail_send', - ]); + $this->policyTransactionModel->markRenewalReminderSent( + (int) $ptId, + isset($row['reminder_offset']) ? (int) $row['reminder_offset'] : null + ); $this->logRenewalReminderStep( $logPrefix . " PT {$ptId}: renewal_status set to renewal_mail_send" ); @@ -6407,6 +6413,9 @@ class PolicyTransactionController extends BaseController $responsePayload['config'] = $configSnapshot; $responsePayload['detailed_summary'] = $detailedSummary; $responsePayload['message'] = $detailedSummary['headline'] ?? $summaryMessage; + if (! empty($detailedSummary['client_mail'])) { + $responsePayload['summary']['client_mail'] = $detailedSummary['client_mail']; + } } return $this->renewalReminderResponse($responsePayload, $isCli); @@ -6495,6 +6504,8 @@ class PolicyTransactionController extends BaseController $policiesWithoutPolicyNo = 0; $policiesWithClientEmailInTo = 0; $policiesMissingClientEmail = 0; + $policiesWithClientEmailOnRecord = 0; + $policiesConfiguredToOnly = 0; $clientEmailsInTo = []; foreach ($results as $item) { @@ -6526,6 +6537,11 @@ class PolicyTransactionController extends BaseController $policiesWithoutPolicyNo++; } + $clientEmailRaw = trim((string) ($item['client_email'] ?? '')); + if ($clientEmailRaw !== '') { + $policiesWithClientEmailOnRecord++; + } + $clientEmailIncluded = ! empty($item['client_email_included']); if ($clientEmailIncluded) { $policiesWithClientEmailInTo++; @@ -6539,15 +6555,20 @@ class PolicyTransactionController extends BaseController $policiesMissingClientEmail++; } + if ($status === 'dry_run' && ! $clientEmailIncluded && (int) ($item['to_count'] ?? count($item['to'] ?? [])) > 0) { + $policiesConfiguredToOnly++; + } + $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' => $clientEmailRaw !== '' ? $clientEmailRaw : 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), + 'days_before' => $item['days_before'] ?? ($item['reminder_offset'] ?? null), 'is_overdue' => $isOverdue, 'to' => $item['to'] ?? [], 'to_count' => (int) ($item['to_count'] ?? count($item['to'] ?? [])), @@ -6590,7 +6611,8 @@ class PolicyTransactionController extends BaseController } } - $targetDate = date('Y-m-d', strtotime('+' . max(0, (int) $config->getDaysBefore()) . ' days')); + $targetDates = $config->getTargetDates(); + $targetDate = implode(', ', array_values($targetDates)); $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', @@ -6611,7 +6633,7 @@ class PolicyTransactionController extends BaseController '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, + 'Due on target date(s) (' . $targetDate . '): ' . $dueCount, 'Overdue policies included: ' . $overdueCount, 'Unique TO recipients: ' . count($uniqueTo), 'Unique CC recipients: ' . count($uniqueCc), @@ -6619,13 +6641,24 @@ class PolicyTransactionController extends BaseController 'Total TO deliveries (per mail): ' . $totalToHits, 'Policies without policy no: ' . $policiesWithoutPolicyNo, 'Date field: ' . $config->getDateField(), - 'Days before: ' . $config->getDaysBefore(), + 'Days before: ' . $config->getDaysBeforeDisplay(), 'Include overdue: ' . ($config->includeOverdue() ? 'yes' : 'no'), ]; + $clientMailSummary = [ + 'enabled' => $config->includeClientEmail(), + 'policies_with_email_on_record' => $policiesWithClientEmailOnRecord, + 'policies_included_in_to' => $policiesWithClientEmailInTo, + 'policies_missing_or_invalid' => $policiesMissingClientEmail, + 'policies_configured_to_only' => $policiesConfiguredToOnly, + 'unique_client_emails_in_to' => count($clientEmailsInTo), + 'client_emails_in_to' => array_keys($clientEmailsInTo), + ]; + return [ 'headline' => $headline, 'lines' => $lines, + 'client_mail' => $clientMailSummary, 'counts' => [ 'policies_pending_renewal' => $candidateCount, 'policies_ready_to_mail' => $wouldSendCount, @@ -6636,8 +6669,10 @@ class PolicyTransactionController extends BaseController 'policies_without_policy_no' => $policiesWithoutPolicyNo, 'policies_without_email' => $policiesWithoutEmail, 'include_client_email_enabled' => $config->includeClientEmail(), + 'policies_with_client_email_on_record' => $policiesWithClientEmailOnRecord, 'policies_with_client_email_in_to' => $policiesWithClientEmailInTo, 'policies_missing_client_email' => $policiesMissingClientEmail, + 'policies_configured_to_only' => $policiesConfiguredToOnly, 'unique_client_emails_in_to' => count($clientEmailsInTo), 'unique_to_recipients' => count($uniqueTo), 'unique_cc_recipients' => count($uniqueCc), @@ -6649,8 +6684,9 @@ class PolicyTransactionController extends BaseController ], 'filters' => [ 'date_field' => $config->getDateField(), - 'days_before' => $config->getDaysBefore(), - 'target_date' => $targetDate, + 'days_before' => $config->getDaysBeforeList(), + 'target_date' => $targetDates[$config->getDaysBefore()] ?? reset($targetDates), + 'target_dates' => $targetDates, 'include_overdue' => $config->includeOverdue(), 'include_client_email' => $config->includeClientEmail(), 'client_types' => $config->getClientTypes(), @@ -6722,7 +6758,9 @@ class PolicyTransactionController extends BaseController $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(); + $daysBefore = isset($data['reminder_offset']) && $data['reminder_offset'] !== '' && $data['reminder_offset'] !== null + ? (int) $data['reminder_offset'] + : $config->getDaysBefore(); $isOverdue = ! empty($data['is_overdue']) || (! empty($reminderRaw) && strtotime((string) $reminderRaw) < strtotime(date('Y-m-d'))); $policyLabel = $policyNo !== '' ? "Policy #{$policyNo}" : 'Policy Pending'; @@ -7077,7 +7115,7 @@ class PolicyTransactionController extends BaseController 'days' => trim((string) $this->request->getPost('days')), 'clientTypes' => [2], 'dateField' => trim((string) $this->request->getPost('dateField')), - 'daysBefore' => (int) $this->request->getPost('daysBefore'), + 'daysBefore' => PtRenewalReminderConfig::parseDaysBeforeList($this->request->getPost('daysBefore')), 'includeClientEmail' => $this->request->getPost('includeClientEmail'), 'includeOverdue' => $this->request->getPost('includeOverdue'), 'toEmails' => trim((string) $this->request->getPost('toEmails')), @@ -7094,7 +7132,7 @@ class PolicyTransactionController extends BaseController if (! in_array($payload['dateField'], ['renewal_date', 'policy_end_date'], true)) { $payload['dateField'] = 'renewal_date'; } - $payload['daysBefore'] = max(0, $payload['daysBefore']); + $payload['daysBefore'] = implode(', ', $payload['daysBefore']); $updated = $this->notificationModel->update((int) $row['id'], [ 'config_json' => json_encode($payload), @@ -7126,6 +7164,12 @@ class PolicyTransactionController extends BaseController $config['days'] = implode(',', $config['days']); } + if (is_array($config['daysBefore'] ?? null)) { + $config['daysBefore'] = implode(', ', $config['daysBefore']); + } elseif (isset($config['daysBefore'])) { + $config['daysBefore'] = implode(', ', PtRenewalReminderConfig::parseDaysBeforeList($config['daysBefore'])); + } + foreach (['toEmails', 'ccEmails', 'bccEmails'] as $field) { if (is_array($config[$field] ?? null)) { $config[$field] = implode(',', $config[$field]); @@ -7357,7 +7401,7 @@ class PolicyTransactionController extends BaseController 'days' => 'mon,tue,wed,thu,fri', 'clientTypes' => [2], 'dateField' => 'renewal_date', - 'daysBefore' => 30, + 'daysBefore' => '30', 'includeClientEmail' => true, 'includeOverdue' => false, 'toEmails' => '', diff --git a/app/Models/PolicyTransactionModel.php b/app/Models/PolicyTransactionModel.php index 21b58b45..2ca03c91 100644 --- a/app/Models/PolicyTransactionModel.php +++ b/app/Models/PolicyTransactionModel.php @@ -63,6 +63,7 @@ 'edate', 'renewal_date', 'renewal_status', + 'renewal_last_reminder_offset', 'rollover_date', 'policy_holder_name', 'same_as_proposer', @@ -2131,15 +2132,15 @@ /** * 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 + * @param string $dateField Whitelisted column: renewal_date | policy_end_date + * @param int|string|int[] $daysBefore Comma-separated or list of offsets (negative = days after due) + * @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, + $daysBefore, array $clientTypes, bool $includeOverdue = false ): array { @@ -2156,10 +2157,10 @@ return []; } - $daysBefore = max(0, (int) $daysBefore); - $targetDate = date('Y-m-d', strtotime('+' . $daysBefore . ' days')); - $today = date('Y-m-d'); - $column = 'pt.' . $dateField; + $offsets = $this->normalizeReminderOffsets($daysBefore); + $today = date('Y-m-d'); + $targetDates = $this->reminderTargetDates($offsets, $today); + $column = 'pt.' . $dateField; $query = $this->db->table('policy_transaction pt') ->select(" @@ -2177,6 +2178,7 @@ c.common_mails, policy_type.policy_type, i.name AS insurer_name, + DATEDIFF({$column}, CURDATE()) AS reminder_offset, CASE WHEN {$column} < '{$today}' THEN 1 ELSE 0 END AS is_overdue ") ->join('clients c', 'pt.client_id = c.id', 'left') @@ -2185,18 +2187,9 @@ ->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)"); + ->whereIn('c.client_type', $clientTypes); - if ($includeOverdue) { - $query->groupStart() - ->where($column, $targetDate) - ->orWhere("{$column} <", $today) - ->groupEnd(); - } else { - $query->where($column, $targetDate); - } + $this->applyRenewalReminderCandidateFilters($query, $column, $targetDates, $today, $includeOverdue); $query->groupBy('pt.id') ->orderBy('pt.id', 'DESC'); @@ -2207,12 +2200,13 @@ /** * Explain why renewal reminder candidates may be empty (for dry-run diagnostics). * + * @param int|string|int[] $daysBefore * @param int[] $clientTypes * @return array */ public function diagnoseRenewalReminderCandidates( string $dateField, - int $daysBefore, + $daysBefore, array $clientTypes, bool $includeOverdue = false ): array { @@ -2225,10 +2219,11 @@ return $type > 0; }))); - $daysBefore = max(0, (int) $daysBefore); - $targetDate = date('Y-m-d', strtotime('+' . $daysBefore . ' days')); - $today = date('Y-m-d'); - $column = 'pt.' . $dateField; + $offsets = $this->normalizeReminderOffsets($daysBefore); + $today = date('Y-m-d'); + $targetDates = $this->reminderTargetDates($offsets, $today); + $column = 'pt.' . $dateField; + $targetLabel = implode(', ', $targetDates); $base = function () { return $this->db->table('policy_transaction pt') @@ -2255,25 +2250,25 @@ $countAfterStatus = (int) $base() ->whereIn('c.client_type', $clientTypes) - ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("IFNULL(pt.renewal_status, '') !=", 'renewed') ->countAllResults(); $countAfterNotRenewedExists = (int) $base() ->whereIn('c.client_type', $clientTypes) - ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("IFNULL(pt.renewal_status, '') !=", 'renewed') ->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("IFNULL(pt.renewal_status, '') !=", 'renewed') ->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) + ->whereIn($column, $targetDates) ->countAllResults(); $countOverdue = (int) $base() ->whereIn('c.client_type', $clientTypes) - ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("IFNULL(pt.renewal_status, '') !=", 'renewed') ->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) @@ -2281,27 +2276,16 @@ ->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); - } + ->whereIn('c.client_type', $clientTypes); + $this->applyRenewalReminderCandidateFilters($finalBuilder, $column, $targetDates, $today, $includeOverdue); $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") + ->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, DATEDIFF({$column}, CURDATE()) AS reminder_offset") ->whereIn('c.client_type', $clientTypes) - ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false) + ->where("IFNULL(pt.renewal_status, '') !=", 'renewed') ->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) @@ -2318,16 +2302,16 @@ 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.'; + $reasons[] = 'All matching policies already have renewal_status of renewed.'; } 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."; + $reasons[] = "No policies have {$dateField} equal to any target date ({$targetLabel}) for offsets [" . implode(', ', $offsets) . '].'; if ($countOverdue > 0) { - $reasons[] = "There are {$countOverdue} overdue policies that would be included if includeOverdue=true."; + $reasons[] = "There are {$countOverdue} overdue policies that would be included if includeOverdue=true, or if a negative offset matches today."; } } elseif ($includeOverdue && $countFinal === 0) { - $reasons[] = "No policies match target date {$targetDate} or overdue (< {$today}) on {$dateField}."; + $reasons[] = "No policies match target dates ({$targetLabel}) or overdue (< {$today}) on {$dateField}."; } if (empty($reasons) && $countFinal === 0) { @@ -2336,14 +2320,15 @@ return [ 'today' => $today, - 'target_date' => $targetDate, + 'target_date' => $targetDates[0] ?? null, + 'target_dates' => $targetDates, 'date_field' => $dateField, - 'days_before' => $daysBefore, + 'days_before' => $offsets, 'include_overdue' => $includeOverdue, 'client_types' => $clientTypes, 'counts' => [ 'active_inception_for_client_types' => $countAfterClientType, - 'after_excluding_sent_or_renewed' => $countAfterStatus, + 'after_excluding_renewed' => $countAfterStatus, 'after_excluding_already_renewed_policy' => $countAfterNotRenewedExists, 'exact_target_date_matches' => $countExactTargetDate, 'overdue_matches' => $countOverdue, @@ -2352,11 +2337,106 @@ '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.", + ? 'Candidates = any configured offset date OR overdue on selected date field.' + : "Candidates require {$dateField} IN ({$targetLabel}). Negative offsets send after the due date. Change daysBefore in Mail Config.", ]; } + /** + * Mark a policy as mailed for the current reminder offset. + */ + public function markRenewalReminderSent(int $ptId, ?int $offset = null): bool + { + $data = ['renewal_status' => 'renewal_mail_send']; + if ($offset !== null && $this->hasRenewalLastReminderOffsetColumn()) { + $data['renewal_last_reminder_offset'] = $offset; + } + + return (bool) $this->update($ptId, $data); + } + + /** + * @param int|string|int[] $daysBefore + * @return int[] + */ + private function normalizeReminderOffsets($daysBefore): array + { + return \Config\PtRenewalReminderConfig::parseDaysBeforeList($daysBefore); + } + + /** + * @param int[] $offsets + * @return string[] + */ + private function reminderTargetDates(array $offsets, string $today): array + { + $dates = []; + foreach ($offsets as $offset) { + $dates[] = date('Y-m-d', strtotime($today . ' ' . sprintf('%+d days', (int) $offset))); + } + + return array_values(array_unique($dates)); + } + + private function hasRenewalLastReminderOffsetColumn(): bool + { + static $exists = null; + if ($exists === null) { + $exists = $this->db->fieldExists('renewal_last_reminder_offset', $this->table); + } + + return $exists; + } + + /** + * @param object $query Query builder + * @param string[] $targetDates + */ + private function applyRenewalReminderCandidateFilters( + $query, + string $column, + array $targetDates, + string $today, + bool $includeOverdue + ): void { + $query->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 ($this->hasRenewalLastReminderOffsetColumn()) { + $query->where("IFNULL(pt.renewal_status, '') !=", 'renewed'); + $offsetSkip = "(pt.renewal_last_reminder_offset IS NULL OR pt.renewal_last_reminder_offset != DATEDIFF({$column}, CURDATE()))"; + + if ($includeOverdue) { + $query->groupStart() + ->groupStart() + ->whereIn($column, $targetDates) + ->where($offsetSkip, null, false) + ->groupEnd() + ->orGroupStart() + ->where("{$column} <", $today) + ->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'reminded')", null, false) + ->where('pt.renewal_last_reminder_offset', null) + ->groupEnd() + ->groupEnd(); + } else { + $query->whereIn($column, $targetDates) + ->where($offsetSkip, null, false); + } + + return; + } + + $query->where("IFNULL(pt.renewal_status, '') NOT IN ('renewal_mail_send', 'renewed', 'reminded')", null, false); + + if ($includeOverdue) { + $query->groupStart() + ->whereIn($column, $targetDates) + ->orWhere("{$column} <", $today) + ->groupEnd(); + } else { + $query->whereIn($column, $targetDates); + } + } + /** * Server-side DataTables list for Retail Policy (individual clients only). * diff --git a/app/Views/retail_policy_list.php b/app/Views/retail_policy_list.php index 0765c2e7..7b708e88 100644 --- a/app/Views/retail_policy_list.php +++ b/app/Views/retail_policy_list.php @@ -261,12 +261,14 @@ +
@@ -283,11 +285,14 @@
-
+
- + + Comma-separated offsets. Positive = days before due, negative = days after due.
-
+
+
+
@@ -478,7 +483,7 @@ $('#cfg_daily').prop('checked', boolVal(data.daily)); $('#cfg_includeClientEmail').prop('checked', boolVal(data.includeClientEmail)); $('#cfg_includeOverdue').prop('checked', boolVal(data.includeOverdue)); - $('#cfg_daysBefore').val(data.daysBefore != null ? data.daysBefore : 30); + $('#cfg_daysBefore').val(listVal(data.daysBefore) || '30'); setRetailDateField(data.dateField || 'renewal_date'); $('#cfg_days').val(listVal(data.days) || 'mon,tue,wed,thu,fri'); $('#cfg_toEmails').val(listVal(data.toEmails)); diff --git a/writable/sql/retail_policy_reminder_schema.sql b/writable/sql/retail_policy_reminder_schema.sql index 9e3bb0a0..ad313d37 100644 --- a/writable/sql/retail_policy_reminder_schema.sql +++ b/writable/sql/retail_policy_reminder_schema.sql @@ -2,10 +2,16 @@ -- Run manually against the application database. -- 1) policy_transaction.renewal_status +-- Skip this ALTER if the column already exists. ALTER TABLE `policy_transaction` ADD COLUMN `renewal_status` VARCHAR(50) NULL DEFAULT NULL AFTER `renewal_date`; +-- Required for multi-offset reminders (30, 15, 7, ...). Skip if the column already exists. +ALTER TABLE `policy_transaction` + ADD COLUMN `renewal_last_reminder_offset` INT NULL DEFAULT NULL + AFTER `renewal_status`; + -- 2) notifications.config_json ALTER TABLE `notifications` ADD COLUMN `config_json` LONGTEXT NULL @@ -26,7 +32,7 @@ SELECT '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":""}' + '{"enabled":true,"daily":true,"days":"mon,tue,wed,thu,fri","clientTypes":[2],"dateField":"renewal_date","daysBefore":"30, 15, 7, 1, -2, -5, -10","includeClientEmail":true,"includeOverdue":false,"toEmails":"","ccEmails":"","bccEmails":"","fromMail":""}' FROM DUAL WHERE NOT EXISTS ( SELECT 1