Commit 3cab64d8 by jianan

保费到期提醒21-保单邮件逐条通知

parent 7fbe44f9
......@@ -18,4 +18,14 @@ public interface RenewalMailService {
*/
void sendWithAttachment(List<String> recipients, String subject, String content,
String fileName, byte[] attachment);
/**
* 发送无附件 HTML 邮件(逐单文字通知),To 单地址 + CC 列表,发件人带显示名。
*
* @param to 唯一主送人
* @param ccList 抄送列表(可为空)
* @param subject 邮件主题
* @param htmlContent HTML 正文
*/
void sendHtml(String to, List<String> ccList, String subject, String htmlContent);
}
......@@ -5,10 +5,14 @@ import com.yd.common.utils.RandomStringGenerator;
import com.yd.csf.api.service.RenewalDispatchService;
import com.yd.csf.api.service.RenewalMailService;
import com.yd.csf.service.component.RenewalPolicyReportService;
import com.yd.csf.service.dto.RenewalDuePolicy;
import com.yd.csf.service.dto.RenewalReminderData;
import com.yd.csf.service.enums.RenewalMailTypeEnum;
import com.yd.csf.service.enums.RenewalRemindStatusEnum;
import com.yd.csf.service.model.RenewalRemindBatch;
import com.yd.csf.service.model.RenewalRemindPolicyMail;
import com.yd.csf.service.service.IRenewalRemindBatchService;
import com.yd.csf.service.service.IRenewalRemindPolicyMailService;
import com.yd.csf.service.service.RenewalReminderQueryService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
......@@ -21,12 +25,17 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 续期提醒月度跑批编排实现
* 续期提醒月度跑批编排实现:R2 宽限期跟进 + R1 到期通知逐单发送,全部成功后发送 Excel 汇总邮件。
*/
@Service
@Slf4j
......@@ -34,9 +43,25 @@ public class RenewalDispatchServiceImpl implements RenewalDispatchService {
private static final String BATCH_BIZ_ID_PREFIX = "RR";
private static final int ERROR_MSG_MAX_LEN = 1900;
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
@Value("${renewal.reminder.recipients:}")
private String recipientsConfig;
/**
* 逐单邮件唯一主送人
*/
@Value("${renewal.reminder.policy.to:}")
private String policyToConfig;
/**
* 逐单邮件抄送名单(逗号分隔,可空)
*/
@Value("${renewal.reminder.policy.cc:}")
private String policyCcConfig;
@Value("${renewal.reminder.default-grace-days:31}")
private int defaultGraceDays;
@Value("${renewal.reminder.send-interval-ms:2000}")
private long sendIntervalMs;
@Resource
private RenewalReminderQueryService renewalReminderQueryService;
......@@ -46,6 +71,10 @@ public class RenewalDispatchServiceImpl implements RenewalDispatchService {
private RenewalMailService renewalMailService;
@Resource
private IRenewalRemindBatchService renewalRemindBatchService;
@Resource
private IRenewalRemindPolicyMailService renewalRemindPolicyMailService;
@Resource
private RenewalPolicyMailBuilder policyMailBuilder;
@Override
public void dispatch(String bizMonthParam) {
......@@ -61,43 +90,200 @@ public class RenewalDispatchServiceImpl implements RenewalDispatchService {
RenewalReminderData data = null;
String fileName = null;
String recipientsSnapshot = null;
boolean failBatchRecorded = false;
try {
// 计算基准日固定为执行月 1 号:定时跑与手动补发同一 bizMonth 结果可复现
// 1. 配置 fail-fast:任何收件配置错误都在发送前暴露,避免留下半批 FAIL
List<String> summaryRecipients = parseAddressList(recipientsConfig);
List<String> policyToList = parseAddressList(policyToConfig);
List<String> policyCcList = parseAddressList(policyCcConfig);
validateConfig(summaryRecipients, policyToList, policyCcList);
String policyTo = policyToList.get(0);
String ccSnapshot = CollectionUtils.isEmpty(policyCcList) ? null : String.join(",", policyCcList);
recipientsSnapshot = String.join(",", summaryRecipients);
// 2. 计算基准日固定为执行月 1 号:定时跑与手动补发同一 bizMonth 结果可复现
LocalDate baseDate = execMonth.atDay(1);
data = renewalReminderQueryService.build(execMonth, baseDate);
// 窗口内无到期保单:不发邮件,记 EMPTY
if (CollectionUtils.isEmpty(data.getDuePolicies())) {
List<RenewalDuePolicy> r1List = data.getDuePolicies();
List<RenewalDuePolicy> r2List = data.getDunPolicies();
// 3. 两轮均空:不发任何邮件,记 EMPTY
if (CollectionUtils.isEmpty(r1List) && CollectionUtils.isEmpty(r2List)) {
saveBatch(existBatch, execMonth, data, null,
RenewalRemindStatusEnum.EMPTY, null, null);
log.info("续期提醒窗口内无到期保单,已记 EMPTY。bizMonth={}", bizMonth);
RenewalRemindStatusEnum.EMPTY, null, null, 0, 0);
log.info("续期提醒两轮均无命中,已记 EMPTY。bizMonth={}", bizMonth);
return;
}
List<String> recipients = parseRecipients();
recipientsSnapshot = String.join(",", recipients);
// 4. 当月逐单留痕:构建已 SUCCESS 集合,重跑绝不重发
Set<String> succeededKeys = buildSucceededKeys(
renewalRemindPolicyMailService.listByBizMonth(bizMonth));
// 5. 逐封发送:R2 先、R1 后;单封失败仅记该封 FAIL 并继续
List<String> failures = new ArrayList<>();
int[] missingTotal = {0};
int[] attempted = {0};
// R2 先发送(宽限期紧迫),R1 后发送
sendRound(r2List, RenewalMailTypeEnum.R2, bizMonth, policyTo, ccSnapshot,
succeededKeys, failures, missingTotal, attempted);
sendRound(r1List, RenewalMailTypeEnum.R1, bizMonth, policyTo, ccSnapshot,
succeededKeys, failures, missingTotal, attempted);
// 6. 存在任一 FAIL:批次 FAIL,不发 Excel 汇总
if (!failures.isEmpty()) {
String errorMsg = StringUtils.substring(String.join("; ", failures), 0, ERROR_MSG_MAX_LEN);
saveBatch(existBatch, execMonth, data, null,
RenewalRemindStatusEnum.FAIL, errorMsg, recipientsSnapshot,
r2List.size(), failures.size());
failBatchRecorded = true;
log.error("续期提醒存在 {} 封逐单邮件失败,批次 FAIL。bizMonth={}, 明细={}",
failures.size(), bizMonth, errorMsg);
throw new BusinessException("续期提醒跑批失败,逐单邮件失败 " + failures.size()
+ " 封: " + errorMsg);
}
// 7. 全部成功:R1 非空才生成并发送 Excel 汇总(仅 R2 命中时无清单可附)
if (CollectionUtils.isNotEmpty(r1List)) {
fileName = renewalPolicyReportService.buildFileName(data, LocalDate.now());
byte[] attachment = renewalPolicyReportService.generate(data);
String subject = buildSubject(data);
String content = buildContent(data);
String content = buildContent(data, missingTotal[0]);
renewalMailService.sendWithAttachment(summaryRecipients, subject, content, fileName, attachment);
}
renewalMailService.sendWithAttachment(recipients, subject, content, fileName, attachment);
saveBatch(existBatch, execMonth, data, fileName,
RenewalRemindStatusEnum.SUCCESS, null, recipientsSnapshot);
log.info("续期提醒发送完成。bizMonth={}, 保单数={}",
bizMonth, data.getDuePolicies().size());
RenewalRemindStatusEnum.SUCCESS, null, recipientsSnapshot, r2List.size(), 0);
log.info("续期提醒发送完成。bizMonth={}, R1={}, R2={}, 待核对项={}",
bizMonth, r1List.size(), r2List.size(), missingTotal[0]);
} catch (BusinessException e) {
// 配置 fail-fast、逐单失败等业务异常:确保批次落 FAIL(逐单失败分支已落库则不重复写)
if (!failBatchRecorded) {
String errorMsg = StringUtils.substring(e.getMessage(), 0, ERROR_MSG_MAX_LEN);
saveBatch(existBatch, execMonth, data, fileName,
RenewalRemindStatusEnum.FAIL, errorMsg, recipientsSnapshot,
data == null ? 0 : data.getDunPolicies().size(), 0);
}
throw e;
} catch (Exception e) {
// 汇总邮件等非逐单环节异常:批次 FAIL(逐单成功封已留痕,重跑只补未竟部分)
String errorMsg = StringUtils.substring(e.getMessage(), 0, ERROR_MSG_MAX_LEN);
saveBatch(existBatch, execMonth, data, fileName,
RenewalRemindStatusEnum.FAIL, errorMsg, recipientsSnapshot);
RenewalRemindStatusEnum.FAIL, errorMsg, recipientsSnapshot,
data == null ? 0 : data.getDunPolicies().size(), 0);
log.error("续期提醒跑批失败。bizMonth={}", bizMonth, e);
throw new BusinessException("续期提醒跑批失败: " + e.getMessage());
}
}
/**
* 发送一轮逐单邮件。已 SUCCESS 的保单邮件跳过;单封异常不中断整轮。
*/
private void sendRound(List<RenewalDuePolicy> items, RenewalMailTypeEnum type, String bizMonth,
String to, String ccSnapshot, Set<String> succeededKeys,
List<String> failures, int[] missingTotal, int[] attempted) {
if (CollectionUtils.isEmpty(items)) {
return;
}
List<String> ccList = ccSnapshot == null
? Collections.emptyList()
: Arrays.asList(ccSnapshot.split(","));
for (RenewalDuePolicy item : items) {
Long policyFollowId = item.getPolicyFollow().getId();
String key = mailKey(policyFollowId, type.getItemValue());
// 模板构建(不依赖网络):缺失字段统计对全部保单进行,保证汇总计数稳定
RenewalPolicyMailBuilder.BuiltMail built;
try {
built = policyMailBuilder.build(item, type);
missingTotal[0] += built.getMissingFieldCount();
} catch (Exception e) {
// 保单号缺失等无法标识保单的情形:记 FAIL 留痕,数据修复后重跑可补发
log.error("续期提醒邮件构建失败,{},policyFollowId={}", type, policyFollowId, e);
upsertMailRecord(item, type, bizMonth, to, ccSnapshot, false, e.getMessage());
failures.add(failureDesc(item, type, e.getMessage()));
continue;
}
// 已 SUCCESS 的保单邮件任何情况下不重发
if (succeededKeys.contains(key)) {
log.info("逐单邮件已成功留痕,跳过。bizMonth={}, key={}", bizMonth, key);
continue;
}
if (attempted[0] > 0 && sendIntervalMs > 0) {
sleepBetweenMails();
}
attempted[0]++;
try {
renewalMailService.sendHtml(to, ccList, built.getSubject(), built.getHtmlContent());
upsertMailRecord(item, type, bizMonth, to, ccSnapshot, true, null);
} catch (Exception e) {
log.error("续期提醒邮件发送失败,{},policyNo={}", type,
item.getPolicyFollow().getPolicyNo(), e);
upsertMailRecord(item, type, bizMonth, to, ccSnapshot, false, e.getMessage());
failures.add(failureDesc(item, type, e.getMessage()));
}
}
}
private void sleepBetweenMails() {
try {
Thread.sleep(sendIntervalMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new BusinessException("续期提醒跑批被中断");
}
}
/**
* 逐封发送结果落留痕表(按唯一键 upsert,FAIL→SUCCESS 更新同一行)
*/
private void upsertMailRecord(RenewalDuePolicy item, RenewalMailTypeEnum type, String bizMonth,
String to, String ccSnapshot, boolean success, String errorMsg) {
RenewalRemindPolicyMail record = new RenewalRemindPolicyMail();
record.setBizMonth(bizMonth);
record.setPolicyFollowId(item.getPolicyFollow().getId());
record.setPolicyNo(item.getPolicyFollow().getPolicyNo());
record.setMailType(type.getItemValue());
record.setDueDate(item.getDueDate());
record.setGraceEnd(item.getGraceEnd());
record.setToRecipients(to);
record.setCcRecipients(ccSnapshot);
record.setStatus(success ? RenewalRemindStatusEnum.SUCCESS.getItemValue()
: RenewalRemindStatusEnum.FAIL.getItemValue());
record.setErrorMsg(success ? null : StringUtils.substring(errorMsg, 0, ERROR_MSG_MAX_LEN));
record.setSendTime(success ? LocalDateTime.now() : null);
renewalRemindPolicyMailService.saveOrUpdateByUk(record);
}
private Set<String> buildSucceededKeys(List<RenewalRemindPolicyMail> records) {
Set<String> keys = new HashSet<>();
if (CollectionUtils.isEmpty(records)) {
return keys;
}
for (RenewalRemindPolicyMail record : records) {
if (RenewalRemindStatusEnum.SUCCESS.getItemValue().equals(record.getStatus())) {
keys.add(mailKey(record.getPolicyFollowId(), record.getMailType()));
}
}
return keys;
}
private String mailKey(Long policyFollowId, String mailType) {
return policyFollowId + "|" + mailType;
}
private String failureDesc(RenewalDuePolicy item, RenewalMailTypeEnum type, String message) {
return StringUtils.defaultIfBlank(item.getPolicyFollow().getPolicyNo(),
"id=" + item.getPolicyFollow().getId())
+ "(" + type.getItemValue() + "): "
+ StringUtils.substring(StringUtils.defaultString(message), 0, 200);
}
private void saveBatch(RenewalRemindBatch exist, YearMonth execMonth, RenewalReminderData data,
String fileName, RenewalRemindStatusEnum status,
String errorMsg, String recipients) {
String errorMsg, String recipients, int dunCount, int mailFailCount) {
RenewalRemindBatch batch = exist == null ? new RenewalRemindBatch() : exist;
if (exist == null) {
batch.setBatchBizId(RandomStringGenerator.generateBizId16(BATCH_BIZ_ID_PREFIX));
......@@ -108,6 +294,8 @@ public class RenewalDispatchServiceImpl implements RenewalDispatchService {
batch.setWindowStart(data != null ? data.getWindowStart() : execMonth.plusMonths(1).atDay(1));
batch.setWindowEnd(data != null ? data.getWindowEnd() : execMonth.plusMonths(2).atEndOfMonth());
batch.setPolicyCount(data != null ? data.getDuePolicies().size() : 0);
batch.setDunCount(dunCount);
batch.setMailFailCount(mailFailCount);
batch.setSkippedCount(data != null && data.getSkipped() != null ? data.getSkipped().size() : 0);
batch.setRecipients(recipients);
batch.setFileName(fileName);
......@@ -119,15 +307,37 @@ public class RenewalDispatchServiceImpl implements RenewalDispatchService {
renewalRemindBatchService.saveOrUpdate(batch);
}
private List<String> parseRecipients() {
List<String> recipients = Arrays.stream(StringUtils.defaultString(recipientsConfig).split(","))
private void validateConfig(List<String> summaryRecipients, List<String> policyToList,
List<String> policyCcList) {
if (CollectionUtils.isEmpty(summaryRecipients)) {
throw new BusinessException("续期提醒汇总收件人未配置,请设置 renewal.reminder.recipients");
}
summaryRecipients.forEach(a -> assertValidEmail(a, "renewal.reminder.recipients"));
if (policyToList.size() != 1) {
throw new BusinessException("renewal.reminder.policy.to 必须且只能配置一个邮箱地址,实际: "
+ policyToList.size() + " 个");
}
assertValidEmail(policyToList.get(0), "renewal.reminder.policy.to");
policyCcList.forEach(a -> assertValidEmail(a, "renewal.reminder.policy.cc"));
if (defaultGraceDays <= 0) {
throw new BusinessException("renewal.reminder.default-grace-days 必须为正整数,实际: " + defaultGraceDays);
}
if (sendIntervalMs < 0) {
throw new BusinessException("renewal.reminder.send-interval-ms 不能为负数,实际: " + sendIntervalMs);
}
}
private void assertValidEmail(String address, String configKey) {
if (!EMAIL_PATTERN.matcher(address).matches()) {
throw new BusinessException("配置 " + configKey + " 存在非法邮箱地址: " + address);
}
}
private List<String> parseAddressList(String config) {
return Arrays.stream(StringUtils.defaultString(config).split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(recipients)) {
throw new BusinessException("续期提醒收件人未配置,请在配置中设置 renewal.reminder.recipients");
}
return recipients;
}
private String buildSubject(RenewalReminderData data) {
......@@ -138,13 +348,17 @@ public class RenewalDispatchServiceImpl implements RenewalDispatchService {
data.getDuePolicies().size());
}
private String buildContent(RenewalReminderData data) {
private String buildContent(RenewalReminderData data, int missingTotal) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("以下保单将于 %s 至 %s 到期应缴续期保费,共 %d 张,详见附件。",
data.getWindowStart(), data.getWindowEnd(), data.getDuePolicies().size()));
if (data.getSkipped() != null && !data.getSkipped().isEmpty()) {
sb.append(String.format(" 另有 %d 张保单因数据异常未纳入,请核对源数据(详见跑批日志)。",
data.getSkipped().size()));
if (CollectionUtils.isNotEmpty(data.getDunPolicies())) {
sb.append(String.format(" 本期宽限期跟进 %d 封。", data.getDunPolicies().size()));
}
int skippedCount = data.getSkipped() == null ? 0 : data.getSkipped().size();
int abnormalTotal = skippedCount + missingTotal;
if (abnormalTotal > 0) {
sb.append(String.format(" 数据异常/待核对 %d 项,请核对源数据(详见跑批日志)。", abnormalTotal));
}
return sb.toString();
}
......
......@@ -2,6 +2,7 @@ package com.yd.csf.api.service.impl;
import com.yd.csf.api.service.RenewalMailService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
......@@ -10,8 +11,8 @@ import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.List;
......@@ -57,6 +58,29 @@ public class RenewalMailServiceImpl implements RenewalMailService {
}
}
@Override
public void sendHtml(String to, List<String> ccList, String subject, String htmlContent) {
try {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(
message, false, StandardCharsets.UTF_8.name());
helper.setFrom(new InternetAddress(resolveFrom(), "续期保费通知", StandardCharsets.UTF_8.name()));
helper.setTo(to);
if (CollectionUtils.isNotEmpty(ccList)) {
helper.setCc(ccList.toArray(new String[0]));
}
helper.setSubject(subject);
helper.setText(htmlContent, true);
javaMailSender.send(message);
log.info("[续期提醒-邮件] HTML发送成功,To={}, CC={}, 主题={}", to, ccList, subject);
} catch (Exception e) {
log.error("[续期提醒-邮件] HTML发送失败,To={}, 主题={}", to, subject, e);
throw new RuntimeException("续期提醒HTML邮件发送失败: " + e.getMessage(), e);
}
}
private String resolveFrom() {
String from = StringUtils.defaultIfBlank(mailFrom, mailUsername);
if (StringUtils.isBlank(from)) {
......
package com.yd.csf.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yd.csf.service.model.RenewalRemindPolicyMail;
/**
* 针对表【renewal_remind_policy_mail(保单续期提醒逐单邮件发送记录表)】的数据库操作 Mapper
*/
public interface RenewalRemindPolicyMailMapper extends BaseMapper<RenewalRemindPolicyMail> {
}
......@@ -7,7 +7,8 @@ import lombok.Data;
import java.time.LocalDate;
/**
* 命中续期提醒窗口的保单及其保费到期日
* 命中续期提醒的保单及其缴费日信息。
* R1 轮次 {@code dueDate} 为下一保费到期日;R2 轮次为当期缴费日。
*/
@Data
@AllArgsConstructor
......@@ -15,5 +16,23 @@ public class RenewalDuePolicy {
private PolicyFollow policyFollow;
/**
* 保费到期日(R1=下一缴费日,R2=当期缴费日)
*/
private LocalDate dueDate;
/**
* 宽限期到期日(dueDate + 宽限天数 - 1,含头尾)
*/
private LocalDate graceEnd;
/**
* true=保单 grace_period 为空/非正,按配置默认宽限天数估算,邮件需标注「待核对」
*/
private boolean graceFallback;
/**
* 实际使用的宽限天数(兜底时为默认值)
*/
private int graceDays;
}
......@@ -6,22 +6,46 @@ import lombok.Data;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* 续期提醒筛选结果:窗口信息、命中保单、异常跳过明细(保单号 -> 原因)
* 续期提醒筛选结果:窗口信息、R1/R2 命中保单、异常跳过明细(保单号 -> 原因)
*/
@Data
@AllArgsConstructor
public class RenewalReminderData {
/**
* R1 窗口起(M+1月1号)
*/
private LocalDate windowStart;
/**
* R1 窗口止(M+2月月末)
*/
private LocalDate windowEnd;
/**
* R1 到期通知名单(下一保费到期日落入两月窗口)
*/
private List<RenewalDuePolicy> duePolicies;
/**
* R2 宽限期跟进名单(当期已到期、宽限期在基准日至次月月末内届满)
*/
private List<RenewalDuePolicy> dunPolicies;
/**
* 异常跳过明细:保单号 -> 跳过原因(仅统计缺字段/无法解析等脏数据)
*/
private Map<String, String> skipped;
/**
* 宽限天数缺失/非正、按默认值估算的保单数(R1+R2)
*/
public long getGraceFallbackCount() {
return Stream.concat(duePolicies.stream(), dunPolicies.stream())
.filter(RenewalDuePolicy::isGraceFallback)
.count();
}
}
package com.yd.csf.service.enums;
/**
* 续期提醒逐单邮件轮次类型
*/
public enum RenewalMailTypeEnum {
R1("到期通知", "R1"),
R2("宽限期跟进", "R2"),
;
//字典项标签(名称)
private String itemLabel;
//字典项值
private String itemValue;
RenewalMailTypeEnum(String itemLabel, String itemValue) {
this.itemLabel = itemLabel;
this.itemValue = itemValue;
}
public String getItemLabel() {
return itemLabel;
}
public String getItemValue() {
return itemValue;
}
}
......@@ -47,11 +47,21 @@ public class RenewalRemindBatch implements Serializable {
private LocalDate windowEnd;
/**
* 清单保单条数
* R1到期通知保单条数
*/
private Integer policyCount;
/**
* R2宽限期跟进发送封数
*/
private Integer dunCount;
/**
* 逐单邮件发送失败数
*/
private Integer mailFailCount;
/**
* 因数据异常跳过的保单条数
*/
private Integer skippedCount;
......
package com.yd.csf.service.model;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 保单续期提醒逐单邮件发送记录表
*
* @TableName renewal_remind_policy_mail
*/
@TableName(value = "renewal_remind_policy_mail")
@Data
public class RenewalRemindPolicyMail implements Serializable {
/**
* 主键
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 邮件业务唯一ID(RM+随机串)
*/
private String mailBizId;
/**
* 执行月(yyyy-MM)
*/
private String bizMonth;
/**
* policy_follow 主键ID
*/
private Long policyFollowId;
/**
* 保单号(冗余留痕)
*/
private String policyNo;
/**
* 轮次类型:R1-到期通知 R2-宽限期跟进
*/
private String mailType;
/**
* 保费到期日(R2为当期缴费日)
*/
private LocalDate dueDate;
/**
* 宽限期到期日
*/
private LocalDate graceEnd;
/**
* 主送人邮箱快照
*/
private String toRecipients;
/**
* 抄送人邮箱快照(逗号分隔,无抄送为空)
*/
private String ccRecipients;
/**
* 发送状态:SUCCESS-成功 FAIL-失败
*/
private String status;
/**
* 失败原因摘要;重发成功后允许置空
*/
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String errorMsg;
/**
* 发送时间;FAIL 时允许置空
*/
@TableField(updateStrategy = FieldStrategy.IGNORED)
private LocalDateTime sendTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
package com.yd.csf.service.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yd.csf.service.model.RenewalRemindPolicyMail;
import java.util.List;
/**
* 保单续期提醒逐单邮件发送记录 服务类
*/
public interface IRenewalRemindPolicyMailService extends IService<RenewalRemindPolicyMail> {
/**
* 查询某执行月全部逐单邮件留痕(R1+R2),用于重跑时构建已成功集合
*
* @param bizMonth 执行月 yyyy-MM
*/
List<RenewalRemindPolicyMail> listByBizMonth(String bizMonth);
/**
* 按唯一键 (biz_month, policy_follow_id, mail_type) upsert:
* 无记录插入,有记录更新同一行(FAIL→SUCCESS 复用原行)。
*/
void saveOrUpdateByUk(RenewalRemindPolicyMail record);
}
......@@ -6,16 +6,17 @@ import java.time.LocalDate;
import java.time.YearMonth;
/**
* 续期保单筛选:查询生效保单、计算保费到期日、过滤提醒窗口
* 续期保单筛选:查询生效保单,同一次构建产出两轮名单。
* R1 到期通知窗口 = [执行月+1 月 1 号, 执行月+2 月月末];
* R2 宽限期跟进窗口 = [基准日(执行月 1 号), 执行月次月月末]。
*/
public interface RenewalReminderQueryService {
/**
* 构建指定执行月的续期提醒数据。
* 窗口 = [执行月+1 月 1 号, 执行月+2 月月末]。
* 构建指定执行月的续期提醒数据(R1 + R2)。
*
* @param execMonth 执行月
* @param today 当前日期(参数化便于测试
* @param baseDate 基准日(定时跑与补发均固定传执行月 1 号,保证结果可复现
*/
RenewalReminderData build(YearMonth execMonth, LocalDate today);
RenewalReminderData build(YearMonth execMonth, LocalDate baseDate);
}
package com.yd.csf.service.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yd.common.utils.RandomStringGenerator;
import com.yd.csf.service.dao.RenewalRemindPolicyMailMapper;
import com.yd.csf.service.model.RenewalRemindPolicyMail;
import com.yd.csf.service.service.IRenewalRemindPolicyMailService;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* 保单续期提醒逐单邮件发送记录 服务实现类
*/
@Service
public class RenewalRemindPolicyMailServiceImpl
extends ServiceImpl<RenewalRemindPolicyMailMapper, RenewalRemindPolicyMail>
implements IRenewalRemindPolicyMailService {
private static final String MAIL_BIZ_ID_PREFIX = "RM";
@Override
public List<RenewalRemindPolicyMail> listByBizMonth(String bizMonth) {
return this.baseMapper.selectList(new LambdaQueryWrapper<RenewalRemindPolicyMail>()
.eq(RenewalRemindPolicyMail::getBizMonth, bizMonth));
}
@Override
public void saveOrUpdateByUk(RenewalRemindPolicyMail record) {
RenewalRemindPolicyMail exist = this.baseMapper.selectOne(
new LambdaQueryWrapper<RenewalRemindPolicyMail>()
.eq(RenewalRemindPolicyMail::getBizMonth, record.getBizMonth())
.eq(RenewalRemindPolicyMail::getPolicyFollowId, record.getPolicyFollowId())
.eq(RenewalRemindPolicyMail::getMailType, record.getMailType())
.last(" limit 1 "));
LocalDateTime now = LocalDateTime.now();
if (exist == null) {
record.setMailBizId(RandomStringGenerator.generateBizId16(MAIL_BIZ_ID_PREFIX));
record.setCreateTime(now);
record.setUpdateTime(now);
this.baseMapper.insert(record);
} else {
record.setId(exist.getId());
record.setMailBizId(exist.getMailBizId());
record.setCreateTime(exist.getCreateTime());
record.setUpdateTime(now);
this.baseMapper.updateById(record);
}
}
}
......@@ -11,6 +11,7 @@ import com.yd.csf.service.utils.RenewalDueDateCalculator;
import com.yd.csf.service.utils.SimpleDateUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
......@@ -23,59 +24,123 @@ import java.util.List;
import java.util.Map;
/**
* 续期保单筛选实现
* 续期保单筛选实现:同一次 build 产出 R1 到期通知与 R2 宽限期跟进两轮名单。
*/
@Service
@Slf4j
public class RenewalReminderQueryServiceImpl implements RenewalReminderQueryService {
/**
* 保单 grace_period 为空/非正时的兜底宽限天数
*/
@Value("${renewal.reminder.default-grace-days:31}")
private Integer configuredDefaultGraceDays;
@Resource
private PolicyFollowMapper policyFollowMapper;
@Override
public RenewalReminderData build(YearMonth execMonth, LocalDate today) {
public RenewalReminderData build(YearMonth execMonth, LocalDate baseDate) {
LocalDate windowStart = execMonth.plusMonths(1).atDay(1);
LocalDate windowEnd = execMonth.plusMonths(2).atEndOfMonth();
// R2 宽限期窗口:[基准日(执行月1号,含), 执行月次月月末(含)]
LocalDate dunWindowEnd = execMonth.plusMonths(1).atEndOfMonth();
int defaultGraceDays = resolveDefaultGraceDays();
List<PolicyFollow> effectivePolicies = queryEffectivePolicies();
List<RenewalDuePolicy> duePolicies = new ArrayList<>();
List<RenewalDuePolicy> dunPolicies = new ArrayList<>();
Map<String, String> skipped = new LinkedHashMap<>();
if (CollectionUtils.isNotEmpty(effectivePolicies)) {
for (PolicyFollow policy : effectivePolicies) {
RenewalDueDateCalculator.Result result = RenewalDueDateCalculator.calculate(
SimpleDateUtils.toLocalDate(policy.getEffectiveDate()),
policy.getPaymentFrequency(),
policy.getIssueNumber(),
today);
LocalDate effectiveDate = SimpleDateUtils.toLocalDate(policy.getEffectiveDate());
if (!result.isIncluded()) {
if (result.isAbnormal()) {
log.warn("续期提醒跳过异常保单,policyNo={}, 原因={}",
policy.getPolicyNo(), result.getSkipReason());
skipped.put(policy.getPolicyNo(), result.getSkipReason());
// R1:下一保费到期日落入 [M+1月1号, M+2月月末]
RenewalDueDateCalculator.Result nextResult = RenewalDueDateCalculator.calculate(
effectiveDate, policy.getPaymentFrequency(), policy.getIssueNumber(), baseDate);
if (!nextResult.isIncluded()) {
markSkippedIfAbnormal(skipped, policy, nextResult);
} else {
LocalDate nextDueDate = nextResult.getDueDate();
if (!nextDueDate.isBefore(windowStart) && !nextDueDate.isAfter(windowEnd)) {
Grace grace = resolveGrace(policy, nextDueDate, defaultGraceDays);
duePolicies.add(new RenewalDuePolicy(
policy, nextDueDate, grace.graceEnd, grace.fallback, grace.graceDays));
}
continue;
}
LocalDate dueDate = result.getDueDate();
if (!dueDate.isBefore(windowStart) && !dueDate.isAfter(windowEnd)) {
duePolicies.add(new RenewalDuePolicy(policy, dueDate));
// R2:当期缴费日 ≤ 基准日,且宽限期到期日落在 [基准日, 次月月末]
RenewalDueDateCalculator.Result currentResult = RenewalDueDateCalculator.currentDueDate(
effectiveDate, policy.getPaymentFrequency(), policy.getIssueNumber(), baseDate);
if (!currentResult.isIncluded()) {
// R2 计算异常不影响 R1(入参相同,正常情况下异常原因一致)
markSkippedIfAbnormal(skipped, policy, currentResult);
continue;
}
LocalDate currentDueDate = currentResult.getDueDate();
// 首期未付新单(k=0,当期缴费日=生效日)不属续期宽限跟进,剔除
if (currentDueDate.equals(effectiveDate)) {
continue;
}
// 预缴保单一律不催 R2(宁可不催,不可误催);R1 不剔除
if (Integer.valueOf(1).equals(policy.getIsPrepay())) {
continue;
}
Grace grace = resolveGrace(policy, currentDueDate, defaultGraceDays);
if (!grace.graceEnd.isBefore(baseDate) && !grace.graceEnd.isAfter(dunWindowEnd)) {
dunPolicies.add(new RenewalDuePolicy(
policy, currentDueDate, grace.graceEnd, grace.fallback, grace.graceDays));
}
}
}
// 清单按保费到期日升序,同日按保单号
duePolicies.sort(Comparator
.comparing(RenewalDuePolicy::getDueDate)
.thenComparing(p -> p.getPolicyFollow().getPolicyNo()));
// R1 按保费到期日升序、R2 按宽限期到期日升序,同日按保单号
Comparator<RenewalDuePolicy> policyNoOrder = Comparator
.comparing(p -> p.getPolicyFollow().getPolicyNo(), Comparator.nullsLast(String::compareTo));
duePolicies.sort(Comparator.comparing(RenewalDuePolicy::getDueDate).thenComparing(policyNoOrder));
dunPolicies.sort(Comparator.comparing(RenewalDuePolicy::getGraceEnd).thenComparing(policyNoOrder));
log.info("续期提醒筛选完成,窗口=[{} ~ {}],生效保单={},命中={},异常跳过={}",
long graceFallbackCount = duePolicies.stream().filter(RenewalDuePolicy::isGraceFallback).count()
+ dunPolicies.stream().filter(RenewalDuePolicy::isGraceFallback).count();
log.info("续期提醒筛选完成,R1窗口=[{} ~ {}],生效保单={},R1命中={},R2命中={},宽限兜底={},异常跳过={}",
windowStart, windowEnd,
effectivePolicies == null ? 0 : effectivePolicies.size(),
duePolicies.size(), skipped.size());
return new RenewalReminderData(windowStart, windowEnd, duePolicies, skipped);
duePolicies.size(), dunPolicies.size(), graceFallbackCount, skipped.size());
return new RenewalReminderData(windowStart, windowEnd, duePolicies, dunPolicies, skipped);
}
private void markSkippedIfAbnormal(Map<String, String> skipped, PolicyFollow policy,
RenewalDueDateCalculator.Result result) {
if (result.isAbnormal()) {
log.warn("续期提醒跳过异常保单,policyNo={}, 原因={}",
policy.getPolicyNo(), result.getSkipReason());
skipped.putIfAbsent(policy.getPolicyNo(), result.getSkipReason());
}
}
/**
* 解析宽限期到期日;grace_period 为空/非正时按默认天数估算并标记兜底。
*/
private Grace resolveGrace(PolicyFollow policy, LocalDate dueDate, int defaultGraceDays) {
Integer gracePeriod = policy.getGracePeriod();
boolean fallback = gracePeriod == null || gracePeriod <= 0;
int graceDays = fallback ? defaultGraceDays : gracePeriod;
LocalDate graceEnd = RenewalDueDateCalculator.graceEndDate(dueDate, graceDays);
if (fallback) {
log.info("保单宽限期为空或非正,按默认 {} 天估算,policyNo={}, gracePeriod={}",
defaultGraceDays, policy.getPolicyNo(), gracePeriod);
}
return new Grace(graceEnd, fallback, graceDays);
}
private int resolveDefaultGraceDays() {
if (configuredDefaultGraceDays == null || configuredDefaultGraceDays <= 0) {
log.warn("配置 renewal.reminder.default-grace-days={} 非法,回退 31", configuredDefaultGraceDays);
return 31;
}
return configuredDefaultGraceDays;
}
/**
......@@ -101,6 +166,7 @@ public class RenewalReminderQueryServiceImpl implements RenewalReminderQueryServ
PolicyFollow::getPaymentFrequency,
PolicyFollow::getIsPrepay,
PolicyFollow::getPrepaidTerm,
PolicyFollow::getGracePeriod,
PolicyFollow::getProductCate,
PolicyFollow::getInsuranceCompany,
PolicyFollow::getReconciliationCompany,
......@@ -112,4 +178,19 @@ public class RenewalReminderQueryServiceImpl implements RenewalReminderQueryServ
.eq(PolicyFollow::getStatus, PolicyFollowStatusEnum.EFFECTIVE.getItemValue())
.eq(PolicyFollow::getIsDeleted, 0));
}
/**
* 宽限期解析中间结果
*/
private static class Grace {
private final LocalDate graceEnd;
private final boolean fallback;
private final int graceDays;
Grace(LocalDate graceEnd, boolean fallback, int graceDays) {
this.graceEnd = graceEnd;
this.fallback = fallback;
this.graceDays = graceDays;
}
}
}
......@@ -72,6 +72,79 @@ public final class RenewalDueDateCalculator {
}
/**
* 计算单张保单的当期缴费日:缴费日序列(k=0 首期 = 生效日)中不晚于基准日的最大者。
*
* <p>与 {@link #calculate} 严格互补:当期缴费日 ≤ 基准日 &lt; 下一缴费日。
* 基准日早于首期(生效日)时返回空;缴费期满后的日期不参与序列,保单缴完时
* 返回最后一个有效缴费日(由上层配合宽限期窗口判断是否命中 R2)。</p>
*
* @param effectiveDate 生效日
* @param frequency 缴费频率(PaymentFrequencyEnum 的 itemValue)
* @param issueNumber 缴费年期(年)
* @param baseDate 基准日(执行月 1 号)
* @return 计算结果;无当期缴费日时 dueDate 为空并带跳过原因
*/
public static Result currentDueDate(LocalDate effectiveDate, String frequency,
Object issueNumber, LocalDate baseDate) {
String freq = StringUtils.trimToEmpty(frequency);
if (PaymentFrequencyEnum.FULL_PAYMENT.getItemValue().equals(freq)) {
return Result.skip("整付保单无续期保费", false);
}
int stepMonths;
if (PaymentFrequencyEnum.YEAR.getItemValue().equals(freq)) {
stepMonths = 12;
} else if (PaymentFrequencyEnum.SEASON.getItemValue().equals(freq)) {
stepMonths = 3;
} else if (PaymentFrequencyEnum.MONTH.getItemValue().equals(freq)) {
stepMonths = 1;
} else if (StringUtils.isBlank(freq)) {
return Result.skip("缴费频率为空", true);
} else {
return Result.skip("无法识别的缴费频率: " + freq, true);
}
if (Objects.isNull(effectiveDate)) {
return Result.skip("生效日为空", true);
}
if (Objects.isNull(baseDate)) {
return Result.skip("基准日为空", true);
}
Integer years = parseIssueNumber(issueNumber);
if (years == null) {
return Result.skip("缴费年期为空或无法解析: " + issueNumber, true);
}
LocalDate endDate = effectiveDate.plusYears(years).minusDays(1);
LocalDate last = null;
LocalDate candidate = effectiveDate;
while (!candidate.isAfter(baseDate)) {
if (candidate.isAfter(endDate)) {
break;
}
last = candidate;
candidate = candidate.plusMonths(stepMonths);
}
return last == null
? Result.skip("基准日早于保单首期缴费日", false)
: Result.included(last);
}
/**
* 宽限期到期日 = 缴费日 + 宽限天数 - 1(含头尾:6/20 起计 31 天 = 7/20)。
*
* @param dueDate 缴费日
* @param graceDays 宽限天数(调用方保证为正数,空值已按配置兜底)
* @return 宽限期到期日;入参非法时返回 null
*/
public static LocalDate graceEndDate(LocalDate dueDate, int graceDays) {
if (dueDate == null || graceDays <= 0) {
return null;
}
return dueDate.plusDays((long) graceDays - 1);
}
/**
* 解析缴费年期为整数年,兼容 "5"、"5.0"、5、5.0(BigDecimal) 等存储形态。
*/
private static Integer parseIssueNumber(Object issueNumber) {
......
package com.yd.csf.service.utils;
import com.yd.csf.service.model.PolicyFollow;
import org.apache.commons.lang3.StringUtils;
import java.math.BigDecimal;
/**
* 续期提醒金额口径:Excel 报表 M 列与逐单邮件「应缴总保费」的唯一来源。
*/
public final class RenewalPremiumUtils {
private RenewalPremiumUtils() {
}
/**
* 应缴总保费 = 期交保费 + 保单征费。
* 期交保费与征费均无值时返回 null(调用方展示「待核对」);征费为空或非数字按 0 处理。
*/
public static BigDecimal totalPremiumAndLevy(PolicyFollow p) {
BigDecimal levy = parseAmount(p.getPolicyLevy());
if (p.getEachIssuePremium() == null && levy == null) {
return null;
}
BigDecimal premium = p.getEachIssuePremium() == null ? BigDecimal.ZERO : p.getEachIssuePremium();
return premium.add(levy == null ? BigDecimal.ZERO : levy);
}
private static BigDecimal parseAmount(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
try {
return new BigDecimal(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
......@@ -4,7 +4,6 @@ import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.yd.csf.service.model.PolicyFollow;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import java.math.BigDecimal;
import java.util.Date;
......@@ -153,24 +152,10 @@ public class RenewalPolicyExportDTO {
/**
* 总保费及征费 = 期交保费 + 保单征费;两者均无值时留空,征费为空或非数字按 0 处理。
* 口径与逐单邮件共用 {@link com.yd.csf.service.utils.RenewalPremiumUtils}。
*/
private static String buildTotalPremiumAndLevy(PolicyFollow p) {
BigDecimal premium = p.getEachIssuePremium() == null ? BigDecimal.ZERO : p.getEachIssuePremium();
BigDecimal levy = parseAmount(p.getPolicyLevy());
if (p.getEachIssuePremium() == null && levy == null) {
return null;
}
return premium.add(levy == null ? BigDecimal.ZERO : levy).toPlainString();
}
private static BigDecimal parseAmount(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
try {
return new BigDecimal(value.trim());
} catch (NumberFormatException e) {
return null;
}
BigDecimal total = com.yd.csf.service.utils.RenewalPremiumUtils.totalPremiumAndLevy(p);
return total == null ? null : total.toPlainString();
}
}
......@@ -11,6 +11,8 @@
<result property="windowStart" column="window_start"/>
<result property="windowEnd" column="window_end"/>
<result property="policyCount" column="policy_count"/>
<result property="dunCount" column="dun_count"/>
<result property="mailFailCount" column="mail_fail_count"/>
<result property="skippedCount" column="skipped_count"/>
<result property="recipients" column="recipients"/>
<result property="fileName" column="file_name"/>
......@@ -24,7 +26,7 @@
<sql id="Base_Column_List">
id
,batch_biz_id,biz_month,window_start,window_end,policy_count,
skipped_count,recipients,file_name,status,error_msg,
send_time,create_time,update_time
dun_count,mail_fail_count,skipped_count,recipients,file_name,
status,error_msg,send_time,create_time,update_time
</sql>
</mapper>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yd.csf.service.dao.RenewalRemindPolicyMailMapper">
<resultMap id="BaseResultMap" type="com.yd.csf.service.model.RenewalRemindPolicyMail">
<id property="id" column="id"/>
<result property="mailBizId" column="mail_biz_id"/>
<result property="bizMonth" column="biz_month"/>
<result property="policyFollowId" column="policy_follow_id"/>
<result property="policyNo" column="policy_no"/>
<result property="mailType" column="mail_type"/>
<result property="dueDate" column="due_date"/>
<result property="graceEnd" column="grace_end"/>
<result property="toRecipients" column="to_recipients"/>
<result property="ccRecipients" column="cc_recipients"/>
<result property="status" column="status"/>
<result property="errorMsg" column="error_msg"/>
<result property="sendTime" column="send_time"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<sql id="Base_Column_List">
id
,mail_biz_id,biz_month,policy_follow_id,policy_no,mail_type,
due_date,grace_end,to_recipients,cc_recipients,status,
error_msg,send_time,create_time,update_time
</sql>
</mapper>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment