Commit b1cd81e8 by zhangxingmin

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

# Conflicts:
#	yd-csf-api/Dockerfile
parents 68111819 bcd2540e
# 基础镜像
FROM openjdk:8
# 维护人
# JRE 运行时(原 openjdk:8 官方镜像已停更无补丁);tag 固定到具体小版本保证构建可重现
# 升级时替换为服务器可拉取的最新 8uXXX-bXX-jre-jammy
FROM eclipse-temurin:8u422-b05-jre-jammy
LABEL maintainer="zxm<2060197959@qq.com>"
# 创建应用目录
RUN mkdir -p /home/app
# 创建日志目录并授权(默认 root 可写)
#RUN mkdir -p /var/log/yd-csf-api && chmod 755 /var/log/yd-csf-api
# 拷贝项目jar
COPY target/yd-csf-api-1.0-SNAPSHOT-exec.jar /home/app/yd-csf-api.jar
# 启动命令
ENTRYPOINT ["java", "-Duser.timezone=Asia/Shanghai", "-Xmx256m", "-Xms128m", "-jar", "/home/app/yd-csf-api.jar"]
ENV TZ=Asia/Shanghai \
JAVA_OPTS="-Xmx256m -Xms128m"
# 固定 UID 1001,与宿主机日志目录属主对齐;提前创建日志目录并授权给非 root 用户
RUN groupadd -r app --gid=1001 \
&& useradd -r -g app --uid=1001 -d /home/app app \
&& mkdir -p /var/log/yd-csf-api \
&& 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
# 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 @@
<artifactId>hutool-all</artifactId>
</dependency>
<!-- 邮件发送:续期提醒等内部通知,直连 SMTP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.yd</groupId>
<artifactId>yd-question-feign</artifactId>
......@@ -103,6 +109,16 @@
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
</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>
<build>
......
......@@ -26,6 +26,7 @@ import com.yd.csf.api.listener.PolicyDataListener;
import com.yd.csf.api.service.ApiCommissionConditionService;
import com.yd.csf.api.service.ApiExpectedFortuneService;
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.response.appointment.ApiAppointmentDetailResponse;
import com.yd.csf.feign.response.expectedfortune.ApiGenerateExpectedFortuneResponse;
......@@ -67,6 +68,7 @@ import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.constraints.NotBlank;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.sql.SQLIntegrityConstraintViolationException;
import java.text.ParseException;
......@@ -112,6 +114,8 @@ public class ApiPolicyFollowController {
private FeignResultHelper feignResultHelper;
@Resource
private ApiExpectedFortuneAsyncService apiExpectedFortuneAsyncService;
@Resource
private ApiPolicyFollowExportService apiPolicyFollowExportService;
@Autowired
private ApiCommissionConditionService apiCommissionConditionService;
......@@ -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
......
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.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 @@
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${BASE_LOG_DIR}/%d{yyyy-MM-dd}/app.log</fileNamePattern>
<maxHistory>30</maxHistory>
<!-- 日志已 bind mount 到宿主机,限制总大小防止高频部署写满磁盘 -->
<totalSizeCap>2GB</totalSizeCap>
</rollingPolicy>
<encoder>
<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.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;
//
//import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
//import lombok.extern.slf4j.Slf4j;
//import org.apache.commons.lang3.StringUtils;
//import org.springframework.beans.factory.annotation.Value;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
//import org.springframework.context.annotation.Bean;
//import org.springframework.context.annotation.Configuration;
//
//@Slf4j
//@Configuration
//@ConditionalOnClass(XxlJobSpringExecutor.class)
//public class XxlJobConfig {
//
// @Value("${xxl.job.admin.addresses:http://139.224.145.34:8686/xxl-job-admin}")
// private String adminAddresses;
//
// @Value("${xxl.job.executor.appname:csf-executor}")
// private String appname;
//
// @Value("${xxl.job.executor.port:9999}")
// private int port;
//
// @Value("${xxl.job.accessToken:default_token}")
// private String accessToken;
//
// @Bean
// public XxlJobSpringExecutor xxlJobExecutor() {
// log.info(">>>>>>>>>>> xxl-job config init. appname: {}, port: {}, accessToken: {}",
// appname, port, StringUtils.isNotBlank(accessToken) ? "已配置" : "未配置");
//
// XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
// xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
// xxlJobSpringExecutor.setAppname(appname);
// xxlJobSpringExecutor.setPort(port);
// // 设置accessToken
// xxlJobSpringExecutor.setAccessToken(accessToken);
// xxlJobSpringExecutor.setLogRetentionDays(30);
//
// return xxlJobSpringExecutor;
// }
//}
\ No newline at end of file
package com.yd.csf.service.config;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* XXL-Job 执行器配置。
* 配置项见 Nacos:xxl.job.admin.addresses / accessToken / executor.*
*/
@Slf4j
@Configuration
@ConditionalOnClass(XxlJobSpringExecutor.class)
public class XxlJobConfig {
@Value("${xxl.job.admin.addresses:http://139.224.145.34:8686/xxl-job-admin}")
private String adminAddresses;
@Value("${xxl.job.accessToken:default_token}")
private String accessToken;
@Value("${xxl.job.executor.appname:csf-executor}")
private String appname;
/**
* 容器多网卡/Admin 跨网络时显式指定注册 IP,留空自动获取
*/
@Value("${xxl.job.executor.ip:}")
private String ip;
/**
* 执行器内嵌 Netty 端口,默认 9999;注意与业务 HTTP 端口(9202)区分
*/
@Value("${xxl.job.executor.port:9999}")
private int port;
@Value("${xxl.job.executor.logpath:/data/applogs/xxl-job/jobhandler}")
private String logPath;
@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.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