Commit b1cd81e8 by zhangxingmin

Merge remote-tracking branch 'origin/dev' into prod

# Conflicts:
#	yd-csf-api/Dockerfile
parents 68111819 bcd2540e
# 基础镜像 # JRE 运行时(原 openjdk:8 官方镜像已停更无补丁);tag 固定到具体小版本保证构建可重现
FROM openjdk:8 # 升级时替换为服务器可拉取的最新 8uXXX-bXX-jre-jammy
# 维护人 FROM eclipse-temurin:8u422-b05-jre-jammy
LABEL maintainer="zxm<2060197959@qq.com>" LABEL maintainer="zxm<2060197959@qq.com>"
# 创建应用目录
RUN mkdir -p /home/app ENV TZ=Asia/Shanghai \
# 创建日志目录并授权(默认 root 可写) JAVA_OPTS="-Xmx256m -Xms128m"
#RUN mkdir -p /var/log/yd-csf-api && chmod 755 /var/log/yd-csf-api
# 固定 UID 1001,与宿主机日志目录属主对齐;提前创建日志目录并授权给非 root 用户
# 拷贝项目jar RUN groupadd -r app --gid=1001 \
COPY target/yd-csf-api-1.0-SNAPSHOT-exec.jar /home/app/yd-csf-api.jar && useradd -r -g app --uid=1001 -d /home/app app \
# 启动命令 && mkdir -p /var/log/yd-csf-api \
ENTRYPOINT ["java", "-Duser.timezone=Asia/Shanghai", "-Xmx256m", "-Xms128m", "-jar", "/home/app/yd-csf-api.jar"] && chown app:app /var/log/yd-csf-api
# COPY 会自动创建父目录 /home/app,无需单独 mkdir
COPY --chown=app:app target/yd-csf-api-1.0-SNAPSHOT-exec.jar /home/app/yd-csf-api.jar
USER app
EXPOSE 9202 EXPOSE 9202
# sh -c + exec:保证 JVM 成为 1 号进程,docker stop 的 SIGTERM 可直达,支持优雅停机
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -Duser.timezone=Asia/Shanghai -jar /home/app/yd-csf-api.jar"]
...@@ -80,6 +80,12 @@ ...@@ -80,6 +80,12 @@
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
</dependency> </dependency>
<!-- 邮件发送:续期提醒等内部通知,直连 SMTP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.yd</groupId> <groupId>com.yd</groupId>
<artifactId>yd-question-feign</artifactId> <artifactId>yd-question-feign</artifactId>
...@@ -103,6 +109,16 @@ ...@@ -103,6 +109,16 @@
<groupId>com.belerweb</groupId> <groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId> <artifactId>pinyin4j</artifactId>
</dependency> </dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
......
...@@ -26,6 +26,7 @@ import com.yd.csf.api.listener.PolicyDataListener; ...@@ -26,6 +26,7 @@ import com.yd.csf.api.listener.PolicyDataListener;
import com.yd.csf.api.service.ApiCommissionConditionService; import com.yd.csf.api.service.ApiCommissionConditionService;
import com.yd.csf.api.service.ApiExpectedFortuneService; import com.yd.csf.api.service.ApiExpectedFortuneService;
import com.yd.csf.api.service.ApiPolicyFollowService; import com.yd.csf.api.service.ApiPolicyFollowService;
import com.yd.csf.api.service.ApiPolicyFollowExportService;
import com.yd.csf.feign.request.expectedfortune.ApiGenerateExpectedFortuneRequest; import com.yd.csf.feign.request.expectedfortune.ApiGenerateExpectedFortuneRequest;
import com.yd.csf.feign.response.appointment.ApiAppointmentDetailResponse; import com.yd.csf.feign.response.appointment.ApiAppointmentDetailResponse;
import com.yd.csf.feign.response.expectedfortune.ApiGenerateExpectedFortuneResponse; import com.yd.csf.feign.response.expectedfortune.ApiGenerateExpectedFortuneResponse;
...@@ -67,6 +68,7 @@ import javax.servlet.ServletOutputStream; ...@@ -67,6 +68,7 @@ import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.sql.SQLIntegrityConstraintViolationException; import java.sql.SQLIntegrityConstraintViolationException;
import java.text.ParseException; import java.text.ParseException;
...@@ -112,6 +114,8 @@ public class ApiPolicyFollowController { ...@@ -112,6 +114,8 @@ public class ApiPolicyFollowController {
private FeignResultHelper feignResultHelper; private FeignResultHelper feignResultHelper;
@Resource @Resource
private ApiExpectedFortuneAsyncService apiExpectedFortuneAsyncService; private ApiExpectedFortuneAsyncService apiExpectedFortuneAsyncService;
@Resource
private ApiPolicyFollowExportService apiPolicyFollowExportService;
@Autowired @Autowired
private ApiCommissionConditionService apiCommissionConditionService; private ApiCommissionConditionService apiCommissionConditionService;
...@@ -510,6 +514,16 @@ public class ApiPolicyFollowController { ...@@ -510,6 +514,16 @@ public class ApiPolicyFollowController {
} }
/** /**
* 按页面查询条件导出新单跟进清单(与列表查询同一筛选口径,30 列模板,R 列为实时计算的保费到期日)
*/
@PostMapping("/export")
@Operation(summary = "导出新单跟进列表")
public void exportPolicyFollow(@RequestBody PolicyFollowQueryRequest policyFollowQueryRequest,
HttpServletResponse response) {
apiPolicyFollowExportService.export(policyFollowQueryRequest, response);
}
/**
* 修改跟进状态 * 修改跟进状态
* *
* @param changePolicyFollowStatusRequest * @param changePolicyFollowStatusRequest
......
package com.yd.csf.api.handler;
import com.xxl.job.core.handler.annotation.XxlJob;
import com.yd.csf.api.service.RenewalDispatchService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 保单续期提醒月度任务处理器。
* 默认每月 1 号由 XXL-Job Admin 触发(cron 在 Admin 维护);
* 支持手动传参 bizMonth=yyyy-MM 补发指定月份。
*/
@Component
@Slf4j
public class RenewalRemindJobHandler {
@Resource
private RenewalDispatchService renewalDispatchService;
@XxlJob("renewalRemindJobHandler")
public void execute() {
String param = com.xxl.job.core.context.XxlJobHelper.getJobParam();
log.info("开始保单续期提醒任务,参数: {}", param);
renewalDispatchService.dispatch(param);
}
}
package com.yd.csf.api.service;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.yd.common.exception.BusinessException;
import com.yd.csf.service.component.TemplateStyleStrategy;
import com.yd.csf.service.dto.PolicyFollowQueryRequest;
import com.yd.csf.service.model.PolicyFollow;
import com.yd.csf.service.service.PolicyFollowService;
import com.yd.csf.service.component.renewal.RenewalDueDateCalculator;
import com.yd.csf.service.utils.SimpleDateUtils;
import com.yd.csf.service.vo.RenewalPolicyExportDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 新单跟进列表导出:行集与分页页面查询同一筛选口径,列沿用保单清单 30 列模板,
* R 列「保费到期日」按当天逐行实时计算。
*/
@Service
@Slf4j
public class ApiPolicyFollowExportService {
/** 单次导出行数硬上限,超过需缩小筛选条件 */
private static final long MAX_EXPORT_ROWS = 50_000L;
private static final long PAGE_SIZE = 2_000L;
private static final String XLSX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
@Resource
private PolicyFollowService policyFollowService;
public void export(PolicyFollowQueryRequest request, HttpServletResponse response) {
QueryWrapper<PolicyFollow> wrapper = policyFollowService.getQueryWrapper(request);
long total = policyFollowService.count(wrapper);
if (total == 0) {
throw new BusinessException("无导出数据");
}
if (total > MAX_EXPORT_ROWS) {
throw new BusinessException(String.format(
"导出数据量 %d 条超过上限 %d 条,请缩小筛选条件后再导出", total, MAX_EXPORT_ROWS));
}
long pages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
LocalDate today = LocalDate.now();
Page<PolicyFollow> firstPage = policyFollowService.page(new Page<>(1, PAGE_SIZE, false), wrapper);
setDownloadHeaders(response, buildFileName(today));
try (ExcelWriter writer = EasyExcel.write(response.getOutputStream(), RenewalPolicyExportDTO.class)
.useDefaultStyle(false)
.registerWriteHandler(new TemplateStyleStrategy())
.build()) {
WriteSheet sheet = EasyExcel.writerSheet("新单跟进").build();
writer.write(toRows(firstPage.getRecords(), today), sheet);
for (long pageNo = 2; pageNo <= pages; pageNo++) {
Page<PolicyFollow> pageData = policyFollowService.page(
new Page<>(pageNo, PAGE_SIZE, false), wrapper);
if (CollectionUtils.isEmpty(pageData.getRecords())) {
break;
}
writer.write(toRows(pageData.getRecords(), today), sheet);
}
} catch (IOException e) {
log.error("新单跟进导出写入失败,total={}", total, e);
throw new BusinessException("导出新单跟进清单失败");
}
}
private List<RenewalPolicyExportDTO> toRows(List<PolicyFollow> policies, LocalDate today) {
List<RenewalPolicyExportDTO> rows = new ArrayList<>(policies.size());
for (PolicyFollow p : policies) {
RenewalDueDateCalculator.Result result = RenewalDueDateCalculator.calculate(
SimpleDateUtils.toLocalDate(p.getEffectiveDate()),
p.getPaymentFrequency(),
p.getIssueNumber(),
today);
Date dueDate = result.isIncluded()
? Date.from(result.getDueDate().atStartOfDay(java.time.ZoneId.systemDefault()).toInstant())
: null;
rows.add(RenewalPolicyExportDTO.toRow(p, dueDate));
}
return rows;
}
private String buildFileName(LocalDate today) {
String raw = "新单跟进清单_" + today.format(DAY_FMT) + ".xlsx";
try {
return URLEncoder.encode(raw, StandardCharsets.UTF_8.name()).replaceAll("\\+", "%20");
} catch (UnsupportedEncodingException e) {
// UTF-8 为 JVM 规范保证支持,理论不可达
throw new IllegalStateException(e);
}
}
private void setDownloadHeaders(HttpServletResponse response, String encodedFileName) {
response.setContentType(XLSX_CONTENT_TYPE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName);
}
}
package com.yd.csf.api.service;
/**
* 续期提醒月度跑批编排:筛选 → 生成清单 → 发邮件 → 批次落库
*/
public interface RenewalDispatchService {
/**
* 执行一次续期提醒跑批
*
* @param bizMonth 执行月参数(yyyy-MM 或 bizMonth=yyyy-MM),为空时取当前月
*/
void dispatch(String bizMonth);
}
package com.yd.csf.api.service;
import java.util.List;
/**
* 续期提醒邮件发送(JavaMail 直连 SMTP,见 RenewalMailServiceImpl)。
*/
public interface RenewalMailService {
/**
* 发送带 Excel 附件的邮件
*
* @param recipients 收件人邮箱列表
* @param subject 邮件主题
* @param content 邮件正文
* @param fileName 附件文件名
* @param attachment 附件字节内容
*/
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);
}
package com.yd.csf.api.service.impl;
import com.yd.common.exception.BusinessException;
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.renewal.RenewalPolicyMailBuilder;
import com.yd.csf.service.component.renewal.RenewalPolicyReportService;
import com.yd.csf.service.component.renewal.RenewalDuePolicy;
import com.yd.csf.service.component.renewal.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;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
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.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 续期提醒月度跑批编排实现:R2 宽限期跟进 + R1 到期通知逐单发送,全部成功后发送 Excel 汇总邮件。
*/
@Service
@Slf4j
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;
@Resource
private RenewalPolicyReportService renewalPolicyReportService;
@Resource
private RenewalMailService renewalMailService;
@Resource
private IRenewalRemindBatchService renewalRemindBatchService;
@Resource
private IRenewalRemindPolicyMailService renewalRemindPolicyMailService;
@Resource
private RenewalPolicyMailBuilder policyMailBuilder;
@Override
public void dispatch(String bizMonthParam) {
YearMonth execMonth = parseExecMonth(bizMonthParam);
String bizMonth = execMonth.toString();
RenewalRemindBatch existBatch = renewalRemindBatchService.queryByBizMonth(bizMonth);
if (existBatch != null
&& RenewalRemindStatusEnum.SUCCESS.getItemValue().equals(existBatch.getStatus())) {
log.info("续期提醒当月已成功发送,跳过。bizMonth={}", bizMonth);
return;
}
RenewalReminderData data = null;
String fileName = null;
String recipientsSnapshot = null;
List<String> failures = new ArrayList<>();
try {
// 1. 配置 fail-fast:任何收件配置错误都在发信前暴露
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);
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, 0, 0);
log.info("续期提醒两轮均无命中,已记 EMPTY。bizMonth={}", bizMonth);
return;
}
// 4. 当月逐单留痕:构建已 SUCCESS 集合,重跑绝不重发
Set<String> succeededKeys = buildSucceededKeys(
renewalRemindPolicyMailService.listByBizMonth(bizMonth));
// 5. 逐封发送:R2 先、R1 后;单封失败仅记该封 FAIL 并继续
int[] missingTotal = {0};
int[] attempted = {0};
// R2 先发送(宽限期紧迫),R1 后发送
sendRound(r2List, RenewalMailTypeEnum.R2, bizMonth, policyTo, ccSnapshot,
succeededKeys, failures, missingTotal, attempted, data.getSkipped());
sendRound(r1List, RenewalMailTypeEnum.R1, bizMonth, policyTo, ccSnapshot,
succeededKeys, failures, missingTotal, attempted, data.getSkipped());
// 6. R1 非空即发送 Excel 汇总:汇总收件人与逐单收件人不同,清单送达不与逐单通道成败挂钩
if (CollectionUtils.isNotEmpty(r1List)) {
fileName = renewalPolicyReportService.buildFileName(data, LocalDate.now());
byte[] attachment = renewalPolicyReportService.generate(data);
String subject = buildSubject(data);
String content = buildContent(data, missingTotal[0]);
renewalMailService.sendWithAttachment(summaryRecipients, subject, content, fileName, attachment);
}
// 7. 逐单存在失败:汇总已送达,批次仍记 FAIL 并抛出,使调度标红、重跑只补失败封
if (!failures.isEmpty()) {
String errorMsg = StringUtils.substring(String.join("; ", failures), 0, ERROR_MSG_MAX_LEN);
log.error("续期提醒 {} 封逐单邮件失败(汇总邮件已发送)。bizMonth={}, 明细={}",
failures.size(), bizMonth, errorMsg);
throw new BusinessException("续期提醒逐单邮件失败 " + failures.size() + " 封: " + errorMsg);
}
saveBatch(existBatch, execMonth, data, fileName,
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(汇总可能已发出,fileName 保留)
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(), failures.size());
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,
data == null ? 0 : data.getDunPolicies().size(), failures.size());
log.error("续期提醒跑批失败。bizMonth={}", bizMonth, e);
throw new BusinessException("续期提醒跑批失败: " + e.getMessage());
}
}
/**
* 发送一轮逐单邮件。已 SUCCESS 的保单邮件跳过;发送失败仅记该封 FAIL 不中断整轮;
* 模板构建失败(正常已在筛选层拦截,如空保单号)跳过该单计入异常明细,不拖垮整批。
*/
private void sendRound(List<RenewalDuePolicy> items, RenewalMailTypeEnum type, String bizMonth,
String to, String ccSnapshot, Set<String> succeededKeys,
List<String> failures, int[] missingTotal, int[] attempted,
Map<String, String> skipped) {
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) {
// 无法构建(如保单号为空,筛选层本应已拦):跳过计异常,批次可 SUCCESS,数据修复后重跑补发
log.error("续期提醒邮件构建失败,跳过该单,{},policyFollowId={}", type, policyFollowId, e);
if (skipped != null) {
skipped.putIfAbsent(String.valueOf(policyFollowId),
"邮件构建失败(" + type.getItemValue() + "): "
+ StringUtils.substring(StringUtils.defaultString(e.getMessage()), 0, 200));
}
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, int dunCount, int mailFailCount) {
RenewalRemindBatch batch = exist == null ? new RenewalRemindBatch() : exist;
if (exist == null) {
batch.setBatchBizId(RandomStringGenerator.generateBizId16(BATCH_BIZ_ID_PREFIX));
batch.setBizMonth(execMonth.toString());
batch.setCreateTime(LocalDateTime.now());
}
// 查询阶段就失败时 data 可能为空,窗口由执行月推导
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);
batch.setStatus(status.getItemValue());
batch.setErrorMsg(errorMsg);
// 仅实际发信成功才记录发送时间;EMPTY 未发信、FAIL 发送失败,均置空
batch.setSendTime(status == RenewalRemindStatusEnum.SUCCESS ? LocalDateTime.now() : null);
batch.setUpdateTime(LocalDateTime.now());
renewalRemindBatchService.saveOrUpdate(batch);
}
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());
}
private String buildSubject(RenewalReminderData data) {
LocalDate start = data.getWindowStart();
LocalDate end = data.getWindowEnd();
// 跨年发生在执行月 11 月(窗口为 12/1~次年 1/31):同月省年、跨年带两年
String period = start.getYear() == end.getYear()
? String.format("%d年%d-%d月", start.getYear(), start.getMonthValue(), end.getMonthValue())
: String.format("%d年%d月-%d年%d月",
start.getYear(), start.getMonthValue(), end.getYear(), end.getMonthValue());
return String.format("【续期提醒】%s到期保单清单(共%d张)", period, data.getDuePolicies().size());
}
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 (CollectionUtils.isNotEmpty(data.getDunPolicies())) {
sb.append(String.format(" 本期宽限期跟进 %d 封。", data.getDunPolicies().size()));
}
// 两个口径分开:跳过是整张保单(张),待核对是字段项(项),不可合并计数
int skippedCount = data.getSkipped() == null ? 0 : data.getSkipped().size();
if (skippedCount > 0) {
sb.append(String.format(" 数据异常跳过 %d 张,请核对源数据(详见跑批日志)。", skippedCount));
}
if (missingTotal > 0) {
sb.append(String.format(" 字段待核对 %d 项。", missingTotal));
}
return sb.toString();
}
/**
* 支持入参为空(当前月)、"yyyy-MM"、"bizMonth=yyyy-MM"
*/
private YearMonth parseExecMonth(String param) {
String value = StringUtils.trimToNull(param);
if (value == null) {
return YearMonth.now();
}
if (value.startsWith("bizMonth=")) {
value = StringUtils.trimToNull(value.substring("bizMonth=".length()));
}
if (value == null) {
return YearMonth.now();
}
try {
return YearMonth.parse(value);
} catch (DateTimeParseException e) {
throw new BusinessException("续期提醒任务参数格式错误,应为 yyyy-MM,实际: " + param);
}
}
}
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;
import org.springframework.mail.javamail.JavaMailSender;
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.nio.charset.StandardCharsets;
import java.util.List;
/**
* 续期提醒邮件发送:直连 SMTP。
* 配置见 Nacos:spring.mail.* 与 renewal.reminder.mail-from。
*/
@Service
@Slf4j
public class RenewalMailServiceImpl implements RenewalMailService {
@Resource
private JavaMailSender javaMailSender;
/**
* 发件人地址;不配置时取 spring.mail.username
*/
@Value("${renewal.reminder.mail-from:}")
private String mailFrom;
@Value("${spring.mail.username:}")
private String mailUsername;
@Override
public void sendWithAttachment(List<String> recipients, String subject, String content,
String fileName, byte[] attachment) {
try {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(
message, true, StandardCharsets.UTF_8.name());
helper.setFrom(resolveFrom());
helper.setTo(recipients.toArray(new String[0]));
helper.setSubject(subject);
helper.setText(content, false);
helper.addAttachment(fileName, new ByteArrayResource(attachment));
javaMailSender.send(message);
log.info("[续期提醒-邮件] 发送成功,收件人={}, 主题={}, 附件={}", recipients, subject, fileName);
} catch (Exception e) {
log.error("[续期提醒-邮件] 发送失败,收件人={}, 主题={}", recipients, subject, e);
throw new RuntimeException("续期提醒邮件发送失败: " + e.getMessage(), e);
}
}
@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)) {
throw new IllegalStateException("未配置发件人:renewal.reminder.mail-from 或 spring.mail.username");
}
return from;
}
}
...@@ -13,6 +13,8 @@ ...@@ -13,6 +13,8 @@
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${BASE_LOG_DIR}/%d{yyyy-MM-dd}/app.log</fileNamePattern> <fileNamePattern>${BASE_LOG_DIR}/%d{yyyy-MM-dd}/app.log</fileNamePattern>
<maxHistory>30</maxHistory> <maxHistory>30</maxHistory>
<!-- 日志已 bind mount 到宿主机,限制总大小防止高频部署写满磁盘 -->
<totalSizeCap>2GB</totalSizeCap>
</rollingPolicy> </rollingPolicy>
<encoder> <encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
......
package com.yd.csf.service.component;
import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.handler.SheetWriteHandler;
import com.alibaba.excel.write.handler.context.CellWriteHandlerContext;
import com.alibaba.excel.write.handler.context.SheetWriteHandlerContext;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
/**
* 严格复刻 policy报表模版.xlsx 的样式:
* 宋体 11 不加粗、四边细边框、垂直居中;对齐与数字格式按列区分;冻结首行;按模板列宽。
* 模板中表头与数据使用同一套样式。
*/
public class TemplateStyleStrategy implements SheetWriteHandler, CellWriteHandler {
/** 文本:常规左对齐(模板 cellXfs 8) */
private static final int TEXT_LEFT = 0;
/** 居中(模板 9:D/U/V) */
private static final int CENTER = 1;
/** 数值 #,##0.00 左对齐(模板 10:J 每期保费) */
private static final int NUMBER_LEFT = 2;
/** 常规右对齐(模板 13:L/M/S) */
private static final int GENERAL_RIGHT = 3;
/** 日期 yyyy/MM/dd 右对齐(模板 12:N/O/Q;P/R 写入真实日期需日期格式) */
private static final int DATE_RIGHT = 4;
/** 30 列(A..AD)对应的样式类别 */
private static final int[] STYLE_BY_COL = {
TEXT_LEFT, // A id
TEXT_LEFT, // B 保单号
TEXT_LEFT, // C 产品名称
CENTER, // D PI
TEXT_LEFT, // E 保单持有人(中文)
TEXT_LEFT, // F 保单持有人(英文)
TEXT_LEFT, // G 受保人(中文)
TEXT_LEFT, // H 受保人(英文)
TEXT_LEFT, // I 状态
NUMBER_LEFT, // J 每期保费
TEXT_LEFT, // K 保单币种
GENERAL_RIGHT, // L 征费
GENERAL_RIGHT, // M 总保费及征费
DATE_RIGHT, // N 生效日
DATE_RIGHT, // O 签单日
DATE_RIGHT, // P 缮发日期
DATE_RIGHT, // Q 冷静期结束日期
DATE_RIGHT, // R 保费到期日
GENERAL_RIGHT, // S 缴费年期
TEXT_LEFT, // T 缴费频率
CENTER, // U 是否预缴
CENTER, // V 预缴年期
TEXT_LEFT, // W 产品险种
TEXT_LEFT, // X 保险公司
TEXT_LEFT, // Y 出单经纪公司
TEXT_LEFT, // Z 转介人
TEXT_LEFT, // AA 签单员
TEXT_LEFT, // AB 签单员牌照号码
TEXT_LEFT, // AC 预约编号
TEXT_LEFT // AD 签单地点
};
/** 模板列宽(字符单位);日期列保证最小宽度避免显示 ### */
private static final double[] COL_WIDTH = {
4.43, 22.00, 35.57, 7.14, 19.29, 20.57, 23.14, 16.14, 12.57, 18.14,
15.14, 10.71, 16.29, 12.43, 12.43, 12.00, 16.29, 12.00, 12.29, 9.57,
9.71, 9.57, 9.57, 11.71, 14.00, 16.14, 14.86, 16.14, 20.00, 34.00
};
private CellStyle[] styles;
/**
* 必须在内置默认样式策略(DefaultStyle,order=0)之后执行,
* 否则其按列设置的单元格样式会被默认样式覆盖回 Calibri 无框。
*/
@Override
public int order() {
return Integer.MAX_VALUE;
}
@Override
public void afterSheetCreate(SheetWriteHandlerContext context) {
Sheet sheet = context.getWriteSheetHolder().getSheet();
Workbook workbook = context.getWriteWorkbookHolder().getWorkbook();
initStyles(workbook);
for (int i = 0; i < COL_WIDTH.length; i++) {
sheet.setColumnWidth(i, (int) Math.round(COL_WIDTH[i] * 256));
}
// 冻结表头首行
sheet.createFreezePane(0, 1);
}
@Override
public void afterCellDispose(CellWriteHandlerContext context) {
Cell cell = context.getCell();
int col = cell.getColumnIndex();
if (styles == null || col < 0 || col >= STYLE_BY_COL.length) {
return;
}
// 表头与数据使用同一套按列样式
cell.setCellStyle(styles[STYLE_BY_COL[col]]);
}
private void initStyles(Workbook workbook) {
Font font = workbook.createFont();
font.setFontName("宋体");
font.setFontHeightInPoints((short) 11);
font.setBold(false);
styles = new CellStyle[5];
styles[TEXT_LEFT] = baseStyle(workbook, font, HorizontalAlignment.LEFT, null);
styles[CENTER] = baseStyle(workbook, font, HorizontalAlignment.CENTER, null);
styles[NUMBER_LEFT] = baseStyle(workbook, font, HorizontalAlignment.LEFT, "#,##0.00");
styles[GENERAL_RIGHT] = baseStyle(workbook, font, HorizontalAlignment.RIGHT, null);
styles[DATE_RIGHT] = baseStyle(workbook, font, HorizontalAlignment.RIGHT, "yyyy/MM/dd");
}
private CellStyle baseStyle(Workbook workbook, Font font, HorizontalAlignment align, String fmt) {
CellStyle style = workbook.createCellStyle();
style.setFont(font);
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setTopBorderColor(IndexedColors.BLACK.getIndex());
style.setBottomBorderColor(IndexedColors.BLACK.getIndex());
style.setLeftBorderColor(IndexedColors.BLACK.getIndex());
style.setRightBorderColor(IndexedColors.BLACK.getIndex());
style.setVerticalAlignment(VerticalAlignment.CENTER);
style.setAlignment(align);
if (fmt != null) {
style.setDataFormat(workbook.createDataFormat().getFormat(fmt));
}
return style;
}
}
package com.yd.csf.service.component.renewal;
import com.yd.csf.feign.enums.PaymentFrequencyEnum;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Objects;
/**
* 保单续期保费到期日计算器。
*
* <p>规则对齐现有 Excel 模板「保费到期日」公式:</p>
* <ul>
* <li>年缴每 12 个月、季缴每 3 个月、月缴每 1 个月为一个缴费日,以生效日为锚点;</li>
* <li>取第一个严格晚于今天的缴费日;</li>
* <li>缴费期满日 = 生效日 + 缴费年期年数 - 1 天,下一缴费日晚于期满日表示已缴完;</li>
* <li>闰日(2/29)在非闰年由 LocalDate.plusMonths/plusYears 自动钳为 2/28。</li>
* </ul>
*/
public final class RenewalDueDateCalculator {
private RenewalDueDateCalculator() {
}
/**
* 计算单张保单的下一期保费到期日。
*
* @param effectiveDate 生效日
* @param frequency 缴费频率(PaymentFrequencyEnum 的 itemValue:YEAR/SEASON/MONTH/FULL_PAYMENT)
* @param issueNumber 缴费年期(年),库中类型不规整,可能为 Integer/String/BigDecimal
* @param today 当前日期(参数化便于测试)
* @return 计算结果;不纳入时 dueDate 为空并带跳过原因
*/
public static Result calculate(LocalDate effectiveDate, String frequency, Object issueNumber, LocalDate today) {
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);
}
Integer years = parseIssueNumber(issueNumber);
if (years == null) {
return Result.skip("缴费年期为空或无法解析: " + issueNumber, true);
}
LocalDate endDate = effectiveDate.plusYears(years).minusDays(1);
LocalDate candidate = effectiveDate;
while (!candidate.isAfter(today)) {
candidate = candidate.plusMonths(stepMonths);
if (candidate.isAfter(endDate)) {
return Result.skip("保单缴费期已满", false);
}
}
return Result.included(candidate);
}
/**
* 计算单张保单的当期缴费日:缴费日序列(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.00(BigDecimal) 等整数存储形态;
* 非正、非整数年(如 5.9,旧逻辑静默截断为 5)一律判空,由上层计异常。
*/
private static Integer parseIssueNumber(Object issueNumber) {
if (Objects.isNull(issueNumber)) {
return null;
}
BigDecimal bd;
if (issueNumber instanceof BigDecimal) {
bd = (BigDecimal) issueNumber;
} else if (issueNumber instanceof Number) {
bd = new BigDecimal(issueNumber.toString());
} else {
String s = StringUtils.trimToNull(issueNumber.toString());
if (s == null) {
return null;
}
try {
bd = new BigDecimal(s);
} catch (NumberFormatException e) {
return null;
}
}
if (bd.signum() <= 0 || bd.stripTrailingZeros().scale() > 0) {
return null;
}
try {
return bd.intValueExact();
} catch (ArithmeticException e) {
return null;
}
}
/**
* 计算结果:dueDate 非空表示纳入提醒,为空时 skipReason 说明原因。
*/
@Getter
public static class Result {
private final LocalDate dueDate;
private final String skipReason;
/**
* 是否为异常数据跳过(缺字段/无法解析);整付、缴完等正常排除为 false
*/
private final boolean abnormal;
private Result(LocalDate dueDate, String skipReason, boolean abnormal) {
this.dueDate = dueDate;
this.skipReason = skipReason;
this.abnormal = abnormal;
}
public static Result included(LocalDate dueDate) {
return new Result(dueDate, null, false);
}
public static Result skip(String reason, boolean abnormal) {
return new Result(null, reason, abnormal);
}
public boolean isIncluded() {
return dueDate != null;
}
}
}
package com.yd.csf.service.component.renewal;
import com.yd.csf.service.model.PolicyFollow;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDate;
/**
* 命中续期提醒的保单及其缴费日信息。
* R1 轮次 {@code dueDate} 为下一保费到期日;R2 轮次为当期缴费日。
*/
@Data
@AllArgsConstructor
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;
}
package com.yd.csf.service.component.renewal;
import com.yd.csf.service.enums.RenewalMailTypeEnum;
import com.yd.csf.service.model.PolicyFollow;
import com.yd.csf.service.utils.SimpleDateUtils;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 逐单续期保费通知邮件构建器:简繁双语 HTML(内联 CSS、无图片无外链)。
* 版式按需求稿:字段单列、开场句日期加粗下划线、应缴总保费与备注红色、底部日期加粗下划线。
* 静态标签简体段/繁体段各一份,数据值原样填充不做简繁转换,全部做 HTML 转义。
*/
@Component
public class RenewalPolicyMailBuilder {
private static final String MISSING_SC = "待核对";
private static final String MISSING_TC = "待核對";
private static final String FONT = "'Microsoft YaHei','PingFang SC',Arial,sans-serif";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
/**
* 需求稿强调色:应缴总保费、备注整行红色
*/
private static final String EMPHASIS_RED = "#FF0000";
/**
* 标签对齐宽度(全角字符数,含冒号);最长标签「保单持有人:」为 6
*/
private static final int LABEL_WIDTH = 6;
/**
* 构建一封逐单通知邮件。
*
* @return 主题、HTML 正文、缺失字段数(非主键字段「待核对」项数,用于批次数据异常统计)
*/
public BuiltMail build(RenewalDuePolicy item, RenewalMailTypeEnum type) {
PolicyFollow p = item.getPolicyFollow();
String policyNo = StringUtils.trimToNull(p.getPolicyNo());
if (policyNo == null) {
// 无法标识保单的主键级缺失:不发信,由编排层计入跳过
throw new IllegalArgumentException("保单号为空,无法构建逐单通知邮件,policyFollowId=" + p.getId());
}
AtomicInteger missing = new AtomicInteger(0);
String company = escapedOrNull(p.getInsuranceCompany(), missing);
String product = escapedOrNull(p.getProductName(), missing);
String holderRaw = StringUtils.defaultIfBlank(p.getPolicyHolder(), p.getPolicyHolderEn());
String holder = escapedOrNull(holderRaw, missing);
String insured = escapedOrNull(p.getInsured(), missing);
BigDecimal total = RenewalPremiumUtils.totalPremiumAndLevy(p);
String currency = StringUtils.trimToNull(p.getPolicyCurrency());
if (currency == null) {
missing.incrementAndGet();
}
if (total == null) {
missing.incrementAndGet();
}
// 金额 HTML(已转义)或 null(由各语言段替换为本地「待核对」)
String money = total == null ? null : formatMoney(currency, total);
LocalDate effectiveDate = SimpleDateUtils.toLocalDate(p.getEffectiveDate());
String dueDateText = item.getDueDate().format(DATE_FMT);
String graceEndText = item.getGraceEnd().format(DATE_FMT);
String effectiveText = effectiveDate == null ? null : effectiveDate.format(DATE_FMT);
// 兜底天数取自筛选侧解析结果(item.graceDays),模板不重复持有配置
String graceNoteSc = item.isGraceFallback()
? "(宽限天数待核对(按默认 " + item.getGraceDays() + " 天估算))"
: "(获取保费到期日起计" + item.getGraceDays() + "天)";
String graceNoteTc = item.isGraceFallback()
? "(寬限天數待核對(按預設 " + item.getGraceDays() + " 天估算))"
: "(获取保费到期日起计" + item.getGraceDays() + "天)";
// 主题进入邮件头:剥除控制字符(CR/LF 截断头注入,其余 Cntrl 破坏头编码)
String subject = sanitizeHeader((type == RenewalMailTypeEnum.R2 ? "【宽限期跟进】" : "")
+ "续期保费通知-【" + policyNo + "】");
String html = "<div style=\"font-family:" + FONT + ";font-size:14px;color:#000000;line-height:1.9;\">"
+ segment(Labels.SC, MISSING_SC, type, company, product, esc(policyNo), holder, insured,
money, effectiveText, dueDateText, graceEndText, graceNoteSc)
+ "<hr style=\"border:none;border-top:1px solid #dddddd;margin:18px 0;\">"
+ segment(Labels.TC, MISSING_TC, type, company, product, esc(policyNo), holder, insured,
money, effectiveText, dueDateText, graceEndText, graceNoteTc)
+ "<p style=\"margin:16px 0 0 0;color:#888888;font-size:12px;\">"
+ "本邮件由系统自动发送,请勿直接回复。<br>"
+ "本郵件由系統自動發送,請勿直接回覆。"
+ "</p>"
+ "</div>";
return new BuiltMail(subject, html, missing.get());
}
/**
* 渲染一个语言段(简体或繁体)。数据值在入参前统一转义,繁体段不做简繁转换;
* 为空的字段以本段语言的「待核对」占位展示。
*/
private String segment(Labels l, String missingMark, RenewalMailTypeEnum type,
String company, String product, String policyNo, String holder, String insured,
String money, String effectiveText, String dueDateText,
String graceEndText, String graceNote) {
String opening;
String boldDue = boldUnderline(dueDateText);
if (type == RenewalMailTypeEnum.R2) {
opening = l.openingR2Prefix + boldDue + l.openingR2Middle
+ boldUnderline(graceEndText) + l.openingR2Suffix;
} else {
opening = l.openingR1Prefix + boldDue + l.openingR1Suffix;
}
StringBuilder sb = new StringBuilder();
sb.append("<p style=\"margin:0 0 8px 0;font-weight:bold;\">").append(l.segmentTitle).append("</p>");
sb.append("<p style=\"margin:0 0 10px 0;\">").append(opening).append("</p>");
// 保单资料:每字段单独一行,标签全角补齐对齐
sb.append("<div style=\"line-height:2;\">");
sb.append(fieldLine(l.company, markOrValue(company, missingMark)));
sb.append(fieldLine(l.product, markOrValue(product, missingMark)));
sb.append(fieldLine(l.policyNo, policyNo));
sb.append(fieldLine(l.holder, markOrValue(holder, missingMark)));
sb.append(fieldLine(l.insured, markOrValue(insured, missingMark)));
sb.append(fieldLine(l.premiumAndLevy, markOrValue(money, missingMark)));
sb.append(fieldLine(l.effectiveDate, markOrValue(effectiveText, missingMark)));
sb.append("</div>");
// 应缴总保费:标签+金额整行红色加粗
sb.append("<div style=\"margin-top:14px;color:").append(EMPHASIS_RED)
.append(";font-weight:bold;\">")
.append(l.totalPremium).append(":").append(markOrValue(money, missingMark))
.append("</div>");
// 备注:整行红色、常规字重(繁体段文案按需求稿原文)
sb.append("<div style=\"margin-top:4px;color:").append(EMPHASIS_RED).append(";\">")
.append(l.remark).append("</div>");
// 到期日区域与备注间空一行
sb.append("<div style=\"margin-top:22px;\">")
.append(l.dueDateLabel).append(":").append(boldDue)
.append("</div>");
sb.append("<div style=\"margin-top:6px;\">")
.append(l.graceEndLabel).append(":").append(boldUnderline(graceEndText))
.append("&nbsp;&nbsp;").append(graceNote)
.append("</div>");
return sb.toString();
}
private String fieldLine(String label, String value) {
return "<div>" + padLabel(label) + value + "</div>";
}
private String boldUnderline(String text) {
return "<span style=\"font-weight:bold;text-decoration:underline;\">" + text + "</span>";
}
/**
* 标签(含全角冒号)右侧补全角空格到固定宽度,使各字段值纵向对齐
*/
private String padLabel(String labelWithColon) {
StringBuilder sb = new StringBuilder(labelWithColon);
while (sb.length() < LABEL_WIDTH) {
sb.append(' ');
}
return sb.toString();
}
/**
* 取非空字段值并 HTML 转义;空/空白时返回 null(段落渲染时替换为对应语言的「待核对」),计数 +1。
*/
private String escapedOrNull(String raw, AtomicInteger missing) {
String value = StringUtils.trimToNull(raw);
if (value == null) {
missing.incrementAndGet();
return null;
}
return esc(value);
}
private String markOrValue(String escapedValue, String missingMark) {
return escapedValue == null ? missingMark : escapedValue;
}
private String formatMoney(String currency, BigDecimal total) {
// DecimalFormat 非线程安全,局部创建
String number = new DecimalFormat("#,##0.00").format(total);
return currency == null ? number : esc(currency) + " " + number;
}
/**
* 剥除主题中的 ASCII 控制字符(含 CR/LF/Tab),防邮件头注入与非法头。
*/
private String sanitizeHeader(String header) {
return header.replaceAll("\\p{Cntrl}", "");
}
/**
* HTML 转义数据值(& &lt; &gt; " ')
*/
static String esc(String value) {
if (value == null) {
return null;
}
StringBuilder sb = new StringBuilder(value.length() + 16);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '&':
sb.append("&amp;");
break;
case '<':
sb.append("&lt;");
break;
case '>':
sb.append("&gt;");
break;
case '"':
sb.append("&quot;");
break;
case '\'':
sb.append("&#39;");
break;
default:
sb.append(c);
}
}
return sb.toString();
}
/**
* 构建结果
*/
@Getter
@AllArgsConstructor
public static class BuiltMail {
/**
* 邮件主题
*/
private final String subject;
/**
* HTML 正文
*/
private final String htmlContent;
/**
* 非主键字段缺失(显示「待核对」)数量
*/
private final int missingFieldCount;
}
/**
* 一套语言下的全部静态文案;数据值不在两套文案间转换。
*/
private static final class Labels {
private final String segmentTitle;
private final String company;
private final String product;
private final String policyNo;
private final String effectiveDate;
private final String holder;
private final String insured;
private final String premiumAndLevy;
private final String totalPremium;
private final String remark;
private final String dueDateLabel;
private final String graceEndLabel;
private final String openingR1Prefix;
private final String openingR1Suffix;
private final String openingR2Prefix;
private final String openingR2Middle;
private final String openingR2Suffix;
private Labels(String segmentTitle, String company, String product, String policyNo,
String effectiveDate, String holder, String insured,
String premiumAndLevy, String totalPremium, String remark,
String dueDateLabel, String graceEndLabel,
String openingR1Prefix, String openingR1Suffix,
String openingR2Prefix, String openingR2Middle, String openingR2Suffix) {
this.segmentTitle = segmentTitle;
this.company = company;
this.product = product;
this.policyNo = policyNo;
this.effectiveDate = effectiveDate;
this.holder = holder;
this.insured = insured;
this.premiumAndLevy = premiumAndLevy;
this.totalPremium = totalPremium;
this.remark = remark;
this.dueDateLabel = dueDateLabel;
this.graceEndLabel = graceEndLabel;
this.openingR1Prefix = openingR1Prefix;
this.openingR1Suffix = openingR1Suffix;
this.openingR2Prefix = openingR2Prefix;
this.openingR2Middle = openingR2Middle;
this.openingR2Suffix = openingR2Suffix;
}
private static final Labels SC = new Labels(
"【简体】", "产品公司:", "计划名称:", "保单号码:", "保单生效日:",
"保单持有人:", "受保人:", "保费及征费:", "应缴总保费",
"备注:此金额未计入保费优惠,保费优惠待确认后通知客户。",
"保费到期日", "宽限期到期日",
"以下保单的保费将于", "到期,请在此日期前缴付应缴保费。保单资料如下:",
"以下保单的保费已于", "到期,宽限期将于", "届满,请尽快跟进确认缴费情况。保单资料如下:");
private static final Labels TC = new Labels(
"【繁體】", "產品公司:", "計劃名稱:", "保單號碼:", "保單生效日:",
"保單持有人:", "受保人:", "保費及征費:", "應繳總保費",
// 繁体段备注按需求稿原文(「备注」「客户」保留稿中用字)
"备注:此金額未計入保費優惠,保費優惠待確認後通知客户。",
"保費到期日", "寬限期到期日",
"以下保單的保費將於", "到期,請在此日期前繳付應繳保費。保單資料如下:",
"以下保單的保費已於", "到期,寬限期將於", "屆滿,請盡快跟進確認繳費情況。保單資料如下:");
}
}
package com.yd.csf.service.component.renewal;
import com.alibaba.excel.EasyExcel;
import com.yd.csf.service.component.TemplateStyleStrategy;
import com.yd.csf.service.vo.RenewalPolicyExportDTO;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* 续期保单清单 Excel 生成(30 列,对齐 policy报表模版.xlsx)
*/
@Component
public class RenewalPolicyReportService {
private static final String FILE_NAME_PATTERN = "续期保单清单_%s-%s_%s.xlsx";
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
/**
* 生成清单文件字节
*/
public byte[] generate(RenewalReminderData data) {
List<RenewalPolicyExportDTO> rows = toRows(data);
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
EasyExcel.write(out, RenewalPolicyExportDTO.class)
.useDefaultStyle(false)
.registerWriteHandler(new TemplateStyleStrategy())
.sheet("续期保单")
.doWrite(rows);
return out.toByteArray();
} catch (IOException e) {
throw new RuntimeException("生成续期保单清单失败", e);
}
}
/**
* 文件名:续期保单清单_&lt;窗口首月&gt;-&lt;窗口末月&gt;_&lt;生成日&gt;.xlsx
*/
public String buildFileName(RenewalReminderData data, LocalDate today) {
return String.format(FILE_NAME_PATTERN,
data.getWindowStart().format(MONTH_FMT),
data.getWindowEnd().format(MONTH_FMT),
today.format(DAY_FMT));
}
private List<RenewalPolicyExportDTO> toRows(RenewalReminderData data) {
List<RenewalPolicyExportDTO> rows = new ArrayList<>();
if (data == null || CollectionUtils.isEmpty(data.getDuePolicies())) {
return rows;
}
for (RenewalDuePolicy item : data.getDuePolicies()) {
java.util.Date dueDate = java.util.Date.from(item.getDueDate()
.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant());
rows.add(RenewalPolicyExportDTO.toRow(item.getPolicyFollow(), dueDate));
}
return rows;
}
}
package com.yd.csf.service.component.renewal;
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;
}
}
}
package com.yd.csf.service.component.renewal;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* 续期提醒筛选结果:窗口信息、R1/R2 命中保单、异常跳过明细(policyFollowId -> 原因)
*/
@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;
/**
* 异常跳过明细:policyFollowId(String) -> 跳过原因(缺字段/无法解析/保单号为空等脏数据)
*/
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.config; package com.yd.csf.service.config;
//
//import com.xxl.job.core.executor.impl.XxlJobSpringExecutor; import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
//import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
//import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
//import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
//import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
//import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
//
//@Slf4j /**
//@Configuration * XXL-Job 执行器配置。
//@ConditionalOnClass(XxlJobSpringExecutor.class) * 配置项见 Nacos:xxl.job.admin.addresses / accessToken / executor.*
//public class XxlJobConfig { */
// @Slf4j
// @Value("${xxl.job.admin.addresses:http://139.224.145.34:8686/xxl-job-admin}") @Configuration
// private String adminAddresses; @ConditionalOnClass(XxlJobSpringExecutor.class)
// public class XxlJobConfig {
// @Value("${xxl.job.executor.appname:csf-executor}")
// private String appname; @Value("${xxl.job.admin.addresses:http://139.224.145.34:8686/xxl-job-admin}")
// private String adminAddresses;
// @Value("${xxl.job.executor.port:9999}")
// private int port; @Value("${xxl.job.accessToken:default_token}")
// private String accessToken;
// @Value("${xxl.job.accessToken:default_token}")
// private String accessToken; @Value("${xxl.job.executor.appname:csf-executor}")
// private String appname;
// @Bean
// public XxlJobSpringExecutor xxlJobExecutor() { /**
// log.info(">>>>>>>>>>> xxl-job config init. appname: {}, port: {}, accessToken: {}", * 容器多网卡/Admin 跨网络时显式指定注册 IP,留空自动获取
// appname, port, StringUtils.isNotBlank(accessToken) ? "已配置" : "未配置"); */
// @Value("${xxl.job.executor.ip:}")
// XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor(); private String ip;
// xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
// xxlJobSpringExecutor.setAppname(appname); /**
// xxlJobSpringExecutor.setPort(port); * 执行器内嵌 Netty 端口,默认 9999;注意与业务 HTTP 端口(9202)区分
// // 设置accessToken */
// xxlJobSpringExecutor.setAccessToken(accessToken); @Value("${xxl.job.executor.port:9999}")
// xxlJobSpringExecutor.setLogRetentionDays(30); private int port;
//
// return xxlJobSpringExecutor; @Value("${xxl.job.executor.logpath:/data/applogs/xxl-job/jobhandler}")
// } private String logPath;
//}
\ No newline at end of file @Value("${xxl.job.executor.logretentiondays:30}")
private int logRetentionDays;
@Bean(initMethod = "start", destroyMethod = "destroy")
public XxlJobSpringExecutor xxlJobExecutor() {
log.info(">>>>>>>>>>> xxl-job config init. adminAddresses={}, appname={}, ip={}, port={}",
adminAddresses, appname, StringUtils.defaultIfBlank(ip, "auto"), port);
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
executor.setAdminAddresses(adminAddresses);
executor.setAppname(appname);
executor.setIp(ip);
executor.setPort(port);
executor.setAccessToken(accessToken);
executor.setLogPath(logPath);
executor.setLogRetentionDays(logRetentionDays);
return executor;
}
}
package com.yd.csf.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yd.csf.service.model.RenewalRemindBatch;
/**
* 针对表【renewal_remind_batch(保单续期提醒发送批次表)】的数据库操作 Mapper
*/
public interface RenewalRemindBatchMapper extends BaseMapper<RenewalRemindBatch> {
}
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> {
}
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;
}
}
package com.yd.csf.service.enums;
/**
* 保单续期提醒批次发送状态
*/
public enum RenewalRemindStatusEnum {
SUCCESS("成功", "SUCCESS"),
FAIL("失败", "FAIL"),
EMPTY("无到期保单", "EMPTY"),
;
//字典项标签(名称)
private String itemLabel;
//字典项值
private String itemValue;
RenewalRemindStatusEnum(String itemLabel, String itemValue) {
this.itemLabel = itemLabel;
this.itemValue = itemValue;
}
public String getItemLabel() {
return itemLabel;
}
public String getItemValue() {
return itemValue;
}
}
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_batch
*/
@TableName(value = "renewal_remind_batch")
@Data
public class RenewalRemindBatch implements Serializable {
/**
* 主键
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 批次业务唯一ID
*/
private String batchBizId;
/**
* 执行月(yyyy-MM)
*/
private String bizMonth;
/**
* 本次提醒窗口起(M+1月1号)
*/
private LocalDate windowStart;
/**
* 本次提醒窗口止(M+2月月末)
*/
private LocalDate windowEnd;
/**
* R1到期通知保单条数
*/
private Integer policyCount;
/**
* R2宽限期跟进发送封数
*/
private Integer dunCount;
/**
* 逐单邮件发送失败数
*/
private Integer mailFailCount;
/**
* 因数据异常跳过的保单条数
*/
private Integer skippedCount;
/**
* 实际收件人邮箱(逗号分隔快照);状态切换时允许置空
*/
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String recipients;
/**
* 附件文件名;状态切换时允许置空
*/
@TableField(updateStrategy = FieldStrategy.IGNORED)
private String fileName;
/**
* 发送状态:SUCCESS-成功 FAIL-失败 EMPTY-无到期保单
*/
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.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.RenewalRemindBatch;
/**
* 保单续期提醒发送批次 服务类
*/
public interface IRenewalRemindBatchService extends IService<RenewalRemindBatch> {
/**
* 按执行月查询批次
*
* @param bizMonth 执行月 yyyy-MM
* @return 批次记录,不存在返回 null
*/
RenewalRemindBatch queryByBizMonth(String bizMonth);
}
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);
}
package com.yd.csf.service.service;
import com.yd.csf.service.component.renewal.RenewalReminderData;
import java.time.LocalDate;
import java.time.YearMonth;
/**
* 续期保单筛选:查询生效保单,同一次构建产出两轮名单。
* R1 到期通知窗口 = [执行月+1 月 1 号, 执行月+2 月月末];
* R2 宽限期跟进窗口 = [基准日(执行月 1 号), 执行月次月月末]。
*/
public interface RenewalReminderQueryService {
/**
* 构建指定执行月的续期提醒数据(R1 + R2)。
*
* @param execMonth 执行月
* @param baseDate 基准日(定时跑与补发均固定传执行月 1 号,保证结果可复现)
*/
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.csf.service.dao.RenewalRemindBatchMapper;
import com.yd.csf.service.model.RenewalRemindBatch;
import com.yd.csf.service.service.IRenewalRemindBatchService;
import org.springframework.stereotype.Service;
/**
* 保单续期提醒发送批次 服务实现类
*/
@Service
public class RenewalRemindBatchServiceImpl
extends ServiceImpl<RenewalRemindBatchMapper, RenewalRemindBatch>
implements IRenewalRemindBatchService {
@Override
public RenewalRemindBatch queryByBizMonth(String bizMonth) {
return this.baseMapper.selectOne(new LambdaQueryWrapper<RenewalRemindBatch>()
.eq(RenewalRemindBatch::getBizMonth, bizMonth)
.last(" limit 1 "));
}
}
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);
}
}
}
package com.yd.csf.service.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yd.csf.service.dao.PolicyFollowMapper;
import com.yd.csf.service.component.renewal.RenewalDuePolicy;
import com.yd.csf.service.component.renewal.RenewalReminderData;
import com.yd.csf.service.enums.PolicyFollowStatusEnum;
import com.yd.csf.service.model.PolicyFollow;
import com.yd.csf.service.service.RenewalReminderQueryService;
import com.yd.csf.service.component.renewal.RenewalDueDateCalculator;
import com.yd.csf.service.utils.SimpleDateUtils;
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.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
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 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) {
// 保单号是主题与留痕标识,为空无法发信:跳过计异常,批次照常,数据修复后重跑可补
if (StringUtils.isBlank(policy.getPolicyNo())) {
markSkipped(skipped, policy, "保单号为空");
continue;
}
LocalDate effectiveDate = SimpleDateUtils.toLocalDate(policy.getEffectiveDate());
// R2 先判定(宽限紧迫,发送顺序也在先)
boolean r2Hit = false;
RenewalDueDateCalculator.Result currentResult = RenewalDueDateCalculator.currentDueDate(
effectiveDate, policy.getPaymentFrequency(), policy.getIssueNumber(), baseDate);
if (!currentResult.isIncluded()) {
// R2 计算异常不影响 R1(入参相同,正常情况下异常原因一致)
markSkippedIfAbnormal(skipped, policy, currentResult);
} else {
LocalDate currentDueDate = currentResult.getDueDate();
// 首期未付新单(k=0,当期缴费日=生效日)不属续期宽限跟进
// 预缴保单一律不催 R2(宁可不催,不可误催);两者均不影响 R1
if (!currentDueDate.equals(effectiveDate)
&& !Integer.valueOf(1).equals(policy.getIsPrepay())) {
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));
r2Hit = true;
}
}
}
// R2 已命中则不再发 R1:月缴且缴费日恰为 1 号时,当期宽限届满日恰等于下一缴费日,
// 同一保单会同时落入两轮,按「只催 R2」剔除 R1
if (r2Hit) {
continue;
}
// 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));
}
}
}
}
// 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));
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(), 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()) {
markSkipped(skipped, policy, result.getSkipReason());
}
}
/**
* skipped 以 policyFollowId 为键:保单号本身可能为空(空保单号脏数据也要可统计)。
*/
private void markSkipped(Map<String, String> skipped, PolicyFollow policy, String reason) {
String key = String.valueOf(policy.getId());
if (skipped.putIfAbsent(key, reason) == null) {
log.warn("续期提醒跳过异常保单,policyFollowId={}, policyNo={}, 原因={}",
policy.getId(), policy.getPolicyNo(), reason);
}
}
/**
* 解析宽限期到期日;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;
}
/**
* 查询全部生效且未删除的保单,只取报表与计算所需字段
*/
private List<PolicyFollow> queryEffectivePolicies() {
return policyFollowMapper.selectList(new LambdaQueryWrapper<PolicyFollow>()
.select(PolicyFollow::getId,
PolicyFollow::getPolicyNo,
PolicyFollow::getProductName,
PolicyFollow::getPolicyHolder,
PolicyFollow::getPolicyHolderEn,
PolicyFollow::getInsured,
PolicyFollow::getStatus,
PolicyFollow::getEachIssuePremium,
PolicyFollow::getPolicyCurrency,
PolicyFollow::getPolicyLevy,
PolicyFollow::getEffectiveDate,
PolicyFollow::getSignDate,
PolicyFollow::getIssueDate,
PolicyFollow::getCoolingOffEndDate,
PolicyFollow::getIssueNumber,
PolicyFollow::getPaymentFrequency,
PolicyFollow::getIsPrepay,
PolicyFollow::getPrepaidTerm,
PolicyFollow::getGracePeriod,
PolicyFollow::getProductCate,
PolicyFollow::getInsuranceCompany,
PolicyFollow::getReconciliationCompany,
PolicyFollow::getFirstBroker,
PolicyFollow::getSigner,
PolicyFollow::getPracticeCode,
PolicyFollow::getAppointmentNo,
PolicyFollow::getSignLocation)
.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;
}
}
}
package com.yd.csf.service.utils;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
public class SimpleDateUtils {
private static final ZoneId ZONE = ZoneId.systemDefault();
public static LocalDate toLocalDate(Date date) {
return date != null ? date.toInstant().atZone(ZONE).toLocalDate() : null;
}
}
\ No newline at end of file
package com.yd.csf.service.vo;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.yd.csf.service.component.renewal.RenewalPremiumUtils;
import com.yd.csf.service.model.PolicyFollow;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
import java.util.Objects;
/**
* 保单清单导出 DTO(续期跑批与新单跟进导出共用),列顺序与表头严格对齐 policy报表模版.xlsx(30 列)。
* 字体/边框/对齐/列宽/冻结统一由 {@link com.yd.csf.service.component.TemplateStyleStrategy} 复刻模板。
*/
@Data
public class RenewalPolicyExportDTO {
@ExcelProperty("id")
private Long id;
@ExcelProperty("保单号")
private String policyNo;
@ExcelProperty("产品名称")
private String productName;
@ExcelProperty("PI")
private String pi;
@ExcelProperty("保单持有人(中文)")
private String policyHolder;
@ExcelProperty("保单持有人(英文)")
private String policyHolderEn;
@ExcelProperty("受保人(中文)")
private String insured;
@ExcelProperty("受保人(英文)")
private String insuredEn;
@ExcelProperty("状态")
private String status;
@ExcelProperty("每期保费")
private BigDecimal eachIssuePremium;
@ExcelProperty("保单币种")
private String policyCurrency;
@ExcelProperty("征费")
private String policyLevy;
@ExcelProperty("总保费及征费")
private String totalPremiumAndLevy;
@ExcelProperty("生效日")
@DateTimeFormat("yyyy/MM/dd")
private Date effectiveDate;
@ExcelProperty("签单日")
@DateTimeFormat("yyyy/MM/dd")
private Date signDate;
@ExcelProperty("缮发日期")
@DateTimeFormat("yyyy/MM/dd")
private Date issueDate;
@ExcelProperty("冷静期结束日期")
@DateTimeFormat("yyyy/MM/dd")
private Date coolingOffEndDate;
@ExcelProperty("保费到期日")
@DateTimeFormat("yyyy/MM/dd")
private Date dueDate;
@ExcelProperty("缴费年期")
private String issueNumber;
@ExcelProperty("缴费频率")
private String paymentFrequency;
@ExcelProperty("是否预缴")
private Integer isPrepay;
@ExcelProperty("预缴年期")
private Integer prepaidTerm;
@ExcelProperty("产品险种")
private String productCate;
@ExcelProperty("保险公司")
private String insuranceCompany;
@ExcelProperty("出单经纪公司")
private String reconciliationCompany;
@ExcelProperty("转介人")
private String firstBroker;
@ExcelProperty("签单员")
private String signer;
@ExcelProperty("签单员牌照号码")
private String practiceCode;
@ExcelProperty("预约编号")
private String appointmentNo;
@ExcelProperty("签单地点")
private String signLocation;
/**
* 按 PolicyFollow 字段搬运生成一行;PI/受保人英文无数据源留空,状态保留字典码。
*
* @param p 保单记录
* @param dueDate 保费到期日(R 列),无下一期缴费日时传 null
*/
public static RenewalPolicyExportDTO toRow(PolicyFollow p, Date dueDate) {
RenewalPolicyExportDTO row = new RenewalPolicyExportDTO();
row.setId(p.getId());
row.setPolicyNo(p.getPolicyNo());
row.setProductName(p.getProductName());
row.setPolicyHolder(p.getPolicyHolder());
row.setPolicyHolderEn(p.getPolicyHolderEn());
row.setInsured(p.getInsured());
row.setStatus(p.getStatus());
row.setEachIssuePremium(p.getEachIssuePremium());
row.setPolicyCurrency(p.getPolicyCurrency());
row.setPolicyLevy(p.getPolicyLevy());
row.setTotalPremiumAndLevy(buildTotalPremiumAndLevy(p));
row.setEffectiveDate(p.getEffectiveDate());
row.setSignDate(p.getSignDate());
row.setIssueDate(p.getIssueDate());
row.setCoolingOffEndDate(p.getCoolingOffEndDate());
row.setDueDate(dueDate);
row.setIssueNumber(Objects.toString(p.getIssueNumber(), null));
row.setPaymentFrequency(p.getPaymentFrequency());
row.setIsPrepay(p.getIsPrepay());
row.setPrepaidTerm(p.getPrepaidTerm());
row.setProductCate(p.getProductCate());
row.setInsuranceCompany(p.getInsuranceCompany());
row.setReconciliationCompany(p.getReconciliationCompany());
row.setFirstBroker(p.getFirstBroker());
row.setSigner(p.getSigner());
row.setPracticeCode(p.getPracticeCode());
row.setAppointmentNo(p.getAppointmentNo());
row.setSignLocation(p.getSignLocation());
return row;
}
/**
* 总保费及征费 = 期交保费 + 保单征费;两者均无值时留空,征费为空或非数字按 0 处理。
* 口径与逐单邮件共用 {@link RenewalPremiumUtils}。
*/
private static String buildTotalPremiumAndLevy(PolicyFollow p) {
BigDecimal total = RenewalPremiumUtils.totalPremiumAndLevy(p);
return total == null ? null : total.toPlainString();
}
}
<?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.RenewalRemindBatchMapper">
<resultMap id="BaseResultMap" type="com.yd.csf.service.model.RenewalRemindBatch">
<id property="id" column="id"/>
<result property="batchBizId" column="batch_biz_id"/>
<result property="bizMonth" column="biz_month"/>
<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"/>
<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
,batch_biz_id,biz_month,window_start,window_end,policy_count,
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